Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 |
@@ -52,6 +52,17 @@ jobs:
|
|||||||
|
|
||||||
if [ -n "$pkg_dateien" ]; then
|
if [ -n "$pkg_dateien" ]; then
|
||||||
echo "== Node/TypeScript erkannt =="
|
echo "== Node/TypeScript erkannt =="
|
||||||
|
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||||
|
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||||
|
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||||
|
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||||
|
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||||
|
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||||
|
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||||
|
if [ "$node_major" -lt 22 ]; then
|
||||||
|
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
while IFS= read -r pkg; do
|
while IFS= read -r pkg; do
|
||||||
[ -n "$pkg" ] || continue
|
[ -n "$pkg" ] || continue
|
||||||
d=$(dirname "$pkg")
|
d=$(dirname "$pkg")
|
||||||
|
|||||||
@@ -55,3 +55,6 @@ logs/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||||
|
design-entwuerfe/
|
||||||
|
|||||||
+1131
File diff suppressed because it is too large
Load Diff
+348
-1
@@ -1,6 +1,353 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026)
|
## Aktueller Stand: Einstellungen neu + Version 5.1.0 (31.08.2026, nachts)
|
||||||
|
|
||||||
|
> Der Commander fand den Einstellungs-Tab „sehr mager" — jetzt ist er
|
||||||
|
> eine erklärende Karten-Seite im Kino-Mix (`b64ddf5`, 209 Tests, Dev-
|
||||||
|
> und Paket-Smoke grün): links Sektions-Navigation, rechts je Karte
|
||||||
|
> EIN Satz Einordnung plus die Felder. Neu darin: die
|
||||||
|
> Ablage-Struktur-Vorschau (Filme/Musik/ISO/roh), die gemessenen
|
||||||
|
> Encoder als Badges, eine eigene MakeMKV-Karte (Live-Schlüsselstand +
|
||||||
|
> Update-/Prüf-Knopf + Dauerlizenz), Schalter statt nackter
|
||||||
|
> Checkboxen, Klartext-Fundstellen für die API-Keys — und erstmals die
|
||||||
|
> drei **„Eigener Pfad"-Felder** (`werkzeug.*`), die der Katalog seit
|
||||||
|
> je unterstützt, denen aber das UI fehlte.
|
||||||
|
>
|
||||||
|
> **Version 5.1.0** (erste Fassung ohne Vorab-Suffix): Der Bau erzeugt
|
||||||
|
> damit erstmals `latest.yml` — der Update-Kanal aus § 6.8 ist bereit;
|
||||||
|
> die w0.yml-Kanalfalle ist Geschichte. Setup:
|
||||||
|
> `dist-setup/RippySetup-5.1.0.exe`, SHA-256
|
||||||
|
> `147D6C7E457EBB073E4EB5606B4CB0AA4514F7BE17AE91C9A68D8B0F17B1C94F`.
|
||||||
|
>
|
||||||
|
> Handwerk: `#einstellungen`/`#bibliothek` als Startanker
|
||||||
|
> (RIPPY_START_BEREICH) und RIPPY_SMOKE_WARTE_MS fürs Beweis-Bild —
|
||||||
|
> die Einstellungen und die Encoder-Messung treffen erst NACH den
|
||||||
|
> 750 ms des Standard-Screenshots ein (zweimal „leeres Feld" gejagt,
|
||||||
|
> dann gemessen: reines Foto-Timing, die App war korrekt).
|
||||||
|
> Beweis-Bild: `beweise/ui-einstellungen-neu.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Kino-Mix-Design des Commanders (31.08.2026, abends)
|
||||||
|
|
||||||
|
> Der Commander wollte am Design „extrem schrauben". Auf einem
|
||||||
|
> Design-Canvas (Claude-Artifact „Rippy Redesign") standen drei
|
||||||
|
> Richtungen zur Wahl — Kinosaal, Schaltpult, Galerie; er wählte
|
||||||
|
> **einen Mix aus Kinosaal + Schaltpult** („Das passt erstmal so").
|
||||||
|
> Der Mix ist jetzt DURCHGÄNGIG in der App (`b9de2ba`, 209 Tests,
|
||||||
|
> Dev- und Paket-Smoke grün): Übersicht, Einstellungen, Bibliothek,
|
||||||
|
> Erst-Einrichtung, Dialoge. Setup: `dist-setup/RippySetup-5.0.0-w0.exe`,
|
||||||
|
> SHA-256 `58056BF8C996451DDA182554391B996E11EB0A4A0A57036FD24389DFC34E355C`.
|
||||||
|
|
||||||
|
### Was der Mix ist
|
||||||
|
|
||||||
|
* **Vom Kinosaal:** warmes Bühnen-Schwarz (oklch, nie kaltes Grau) mit
|
||||||
|
Saallicht-Glühherden und Filmkorn, Gold als einziger Akzent, Poster
|
||||||
|
mit Goldkante und großem Schatten, Plakat-Titel in Bricolage
|
||||||
|
Grotesque, Poster-Regal als Bibliothek.
|
||||||
|
* **Vom Schaltpult:** REC-Glühen in der Kopfzeile bei laufendem Rip,
|
||||||
|
Info-Kacheln unterm Titel (MEDIUM/GRÖSSE/ERKENNUNG/**PRESET** — mit
|
||||||
|
der echten Preset-Kette eigene Wahl → allgemein → Empfehlung →
|
||||||
|
Default), die 20-Segment-Aussteuerungsanzeige in Gold mit großer
|
||||||
|
Mono-Prozentzahl, Mono-Protokollzeilen mit `>`-Präfix, und die
|
||||||
|
**LED-Systemleiste** in der Fußzeile (KERN/DATENBANK/LEINE/PONG)
|
||||||
|
statt vier Status-Kacheln — Systemfehler werden zur roten Karte
|
||||||
|
oben in der Übersicht (R4 bleibt gewahrt).
|
||||||
|
|
||||||
|
### Handwerkliches
|
||||||
|
|
||||||
|
* Schriften (Bricolage Grotesque, Instrument Sans, JetBrains Mono)
|
||||||
|
kommen als fontsource-Pakete INS BUNDLE — OFL-Lizenzen liegen unter
|
||||||
|
`bau/lizenzen/SCHRIFT-*`, QUELLEN.md ergänzt. Zur Laufzeit lädt
|
||||||
|
Rippy weiterhin nichts aus dem Netz; CSP unverändert.
|
||||||
|
* Farb- und Schrift-Tokens sind EINE Quelle: `fenster/stil.css`
|
||||||
|
(@theme) — buehne/karte/samt, gold(-hell/-tief), rec, schnee/dunst/
|
||||||
|
nebel, gruen/warn/rot. Fensterfarbe beim Start: #171412.
|
||||||
|
* Design-Arbeitsdateien liegen in `design-entwuerfe/` (im Artifact
|
||||||
|
gesichert, per .gitignore vom Repo ferngehalten).
|
||||||
|
* Beweis-Bild: `beweise/ui-kino-mix.png` — dabei nebenbei gesehen:
|
||||||
|
**Das Laufwerk ist aufgewacht** (5042-Zustand vorbei) und meldet
|
||||||
|
eine eingelegte Blu-ray (33,76 GB); die PRESET-Kachel zeigt live
|
||||||
|
`H.265 VCN 1080p`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MakeMKV-Selbst-Update + Kino-Look (31.08.2026)
|
||||||
|
|
||||||
|
> Zwei Pakete in einem Wurf (`d5ea48c`, Ampel GRÜN, 209 Tests, Dev- und
|
||||||
|
> Paket-Smoke grün): Rippy kann MakeMKV jetzt SELBST aktualisieren —
|
||||||
|
> der Ein-Klick-Ausweg aus Fund 1 — und das Fenster hat den Kino-Look.
|
||||||
|
> Neues Setup am dokumentierten Ort: `dist-setup/RippySetup-5.0.0-w0.exe`,
|
||||||
|
> SHA-256 `75C1B613A2DD6E33C09764838F7B68B21EFB0BB8B2CCB78B2EC0971A49602706`.
|
||||||
|
|
||||||
|
### MakeMKV-Selbst-Update (§ 9) — `kern/werkzeuge/beschaffen.ts`
|
||||||
|
|
||||||
|
* **Quellenkette nachgemessen (31.08.):** Hersteller-Seite weiterhin
|
||||||
|
HTTP 525 (tot), Forum 200/0,2 s → 1.18.4, Archiv 200/7,8 s → 1.18.2.
|
||||||
|
Regeln wie in der Vorlage `tools/beschaffen.py`: Hersteller maßgeblich,
|
||||||
|
sonst HÖCHSTE Nummer; 8-s-Zeitgrenze, 2 Versuche.
|
||||||
|
* **Geprüft wird, was ankommt:** MZ-Kopf + Versions-Ressource
|
||||||
|
(CompanyName „GuinpinSoft inc", ProductName „MakeMKV", FileVersion
|
||||||
|
„v1.18.4" — an der echten Datei gemessen, MakeMKV signiert nicht).
|
||||||
|
Download in `.neu`, erst nach Prüfung getauscht; Mindestgröße 1 MB
|
||||||
|
fängt Fehlerseiten mit HTTP 200.
|
||||||
|
* **Gestartet wird über das Haupt** (`shell.openPath` → UAC): MakeMKVs
|
||||||
|
Installer verlangt Adminrechte (CreateProcess bräche mit Fehler 740 ab,
|
||||||
|
beschaffen.py-Messung); durch die Erhöhung läuft er über den
|
||||||
|
AppInfo-Dienst und hängt bewusst NICHT an der Prozess-Leine. Das Haupt
|
||||||
|
startet nur `.exe`-Dateien aus Rippys eigenem Downloads-Ordner.
|
||||||
|
* **Danach wartet der Kern** (alle 15 s, max. 20 min) darauf, dass die
|
||||||
|
Versions-Ressource der gefundenen makemkvcon-EXE wechselt — dann läuft
|
||||||
|
die Schlüsselkette automatisch neu und die Werkzeuge werden neu gemessen.
|
||||||
|
* **Ehrlicher Zweig:** installierte == neueste angekündigte Fassung
|
||||||
|
(heute der Fall: beide 1.18.4) → KEIN sinnloser Download, sondern der
|
||||||
|
Satz „sobald der Hersteller eine neuere veröffentlicht, holt dieser
|
||||||
|
Knopf sie". `SchluesselStand` trägt jetzt die Version als Feld.
|
||||||
|
* Neue Nachrichten: `makemkv-update`/`schluessel-pruefen` (Fenster→Kern),
|
||||||
|
`beschaffung-status` (Kern→Fenster), `installer-starten`/
|
||||||
|
`installer-ergebnis` (Kern↔Haupt). Tests: `test/beschaffung.test.ts`
|
||||||
|
(Quellenketten, höchste-Version, MZ/Ressourcen-Prüfung, Quellen-Ausfall).
|
||||||
|
|
||||||
|
### Kino-Look — `fenster/App.tsx`
|
||||||
|
|
||||||
|
* Laufwerks-Kachel als Bühne: TMDb-**Backdrop** als Panorama (w780,
|
||||||
|
abgedunkelte Verläufe), Poster (w342), großer Titel, Beschreibung
|
||||||
|
(2 Zeilen), Badges für Typ/Größe/Sicherheit, Phasen-Label
|
||||||
|
(„rippt (verlustfrei)" …), Prozent groß + Restzeit, **aufklappbares
|
||||||
|
Protokoll** (die Statuszeilen des Vorgangs, clientseitig gesammelt).
|
||||||
|
* Bibliothek als **Poster-Regal** (Grid, Hover-Zoom, Tooltip mit
|
||||||
|
Ablageort/Datum/Dauer) — dafür Schema 3: Spalte `poster_pfad` samt
|
||||||
|
Migration per `PRAGMA table_info` (Bestandseinträge bleiben, nur ohne
|
||||||
|
Poster). Die Pipeline schreibt den Posterpfad jetzt mit.
|
||||||
|
* Schlüssel-Karte mit „MakeMKV aktualisieren …"-Knopf (nur bei
|
||||||
|
abgelehntem Key), „Neu prüfen", Beschaffungs-Statuszeile + Balken,
|
||||||
|
MakeMKV-Version als Badge.
|
||||||
|
* `MetaTreffer`/`DiscInfoStand` um `backdropPfad`/`beschreibung`
|
||||||
|
erweitert (OMDb-Weg ohne Backdrop — fällt aufs Poster zurück).
|
||||||
|
* Beweis-Bild: `beweise/ui-kino-look.png` (Übersicht im Smoke-Profil —
|
||||||
|
zeigt auch den 5042-Klartext, siehe unten).
|
||||||
|
|
||||||
|
### Beobachtungen dieser Runde
|
||||||
|
|
||||||
|
* **Das Laufwerk ist WIEDER im 5042-Zustand** („beantwortet keine
|
||||||
|
Medien-Abfragen mehr") — wie vor dem Commander-Test. Die Kachel sagt
|
||||||
|
jetzt die Abhilfe im Klartext (Taste am Laufwerk / Neustart). Für den
|
||||||
|
nächsten Rip-Test muss das Laufwerk erst wieder aufwachen.
|
||||||
|
* **`win-unpacked`-Sperre verstanden und dokumentiert (BAUEN.md):** Nach
|
||||||
|
einem Paket-Smoke hält ein Filter-Treiber `app.asar` mitunter
|
||||||
|
stundenlang (kein Prozess sichtbar; `dist-setup` von gestern war heute
|
||||||
|
wieder frei). electron-builder scheitert dann mit `EBUSY` — bei
|
||||||
|
**Exit-Code 0**! Ausweg dokumentiert: frisches Ausgabeverzeichnis,
|
||||||
|
Setup-EXE nach `dist-setup/` kopieren. `.gitignore` deckt jetzt
|
||||||
|
`dist-setup*/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erster Commander-Test bestanden — drei Funde, drei Fixes (30.08.2026, spät)
|
||||||
|
|
||||||
|
> Der Commander hat v5 am echten Gerät getestet: **„lief alles sofort"**
|
||||||
|
> — und drei Dinge gemeldet. Alle drei sind behoben (`c5dd90c`, Ampel
|
||||||
|
> grün, 192 Tests) und als Testfälle mit den LIVE gemessenen Fixtures
|
||||||
|
> festgehalten. Neues Setup: `dist-setup-frisch/RippySetup-5.0.0-w0.exe`,
|
||||||
|
> SHA-256 `C8225690F02AC544E3E25957E7D34BADBC296C31D44362905A5A0D1DE2104F72`.
|
||||||
|
|
||||||
|
### Fund 1 — „Der MakeMKV-Key ist korrekt, wird aber als ungültig angezeigt"
|
||||||
|
|
||||||
|
Beides stimmt, und die Messung zeigt warum: Der aktuelle Forum-Key ist
|
||||||
|
in Ordnung — **MakeMKV 1.18.4 ist ZU ALT für ihn** (makemkvcon meldet
|
||||||
|
5020 UND 5021 zusammen; live am Gerät nachgestellt). Die alte Prüfung
|
||||||
|
brach beim ersten Code ab und beschuldigte den Schlüssel. Jetzt: Alle
|
||||||
|
Codes werden gesammelt, **5021 schlägt 5020**, die MakeMKV-Version wird
|
||||||
|
aus MSG 1005 mitgelesen, und die Kachel sagt den wahren Satz samt
|
||||||
|
Abhilfe (MakeMKV von makemkv.com aktualisieren — danach hinterlegt
|
||||||
|
Rippy den Key automatisch neu). Der Beta-Modus ohne Key rippt weiter —
|
||||||
|
deshalb ist der Zustand jetzt GELB (Warnung), nicht rot. Wichtig:
|
||||||
|
Rippy NIMMT den abgelehnten Key bewusst ZURÜCK — mit abgelehntem Key
|
||||||
|
verweigert MakeMKV alles, ohne läuft es (gemessen).
|
||||||
|
|
||||||
|
### Fund 2 — „Komprimieren auf der CPU statt der GPU"
|
||||||
|
|
||||||
|
Der alte Default `H.265 MKV 1080p30` ist ein CPU-Preset. Neu:
|
||||||
|
**gemessene Preset-Empfehlung** (`gemeinsam/preset-empfehlung.ts`) aus
|
||||||
|
den echten Backends und der echten Preset-Liste des eigenen HandBrake —
|
||||||
|
VCN → NVENC → QSV → CPU, nur Namen, die wirklich existieren. Auf dem
|
||||||
|
Commander-PC heißt das: DVD/Blu-ray → `H.265 VCN 1080p`, 4K →
|
||||||
|
`H.265 VCN 2160p 4K` (die RX 9070 XT hat den Spartacus-Beweis in
|
||||||
|
2,1 min geschafft). Kette: eigene Wahl → allgemeines Preset →
|
||||||
|
Empfehlung → CPU-Default; die Auswahlfelder zeigen
|
||||||
|
„automatisch: <Empfehlung>".
|
||||||
|
|
||||||
|
### Fund 3 — „Keine Alternative als Metadaten wählbar"
|
||||||
|
|
||||||
|
Gebaut (§ 5 Schritt 4): **„Ändern …"** an der Laufwerks-Kachel öffnet
|
||||||
|
die TMDb-Suche (Filme + Serien, mit Postern); ein Klick übernimmt —
|
||||||
|
die Wahl gilt als 100 % und steuert Ablage-Ordner, NFO und Poster.
|
||||||
|
Dazu: Poster in der Kachel (CSP um image.tmdb.org erweitert) und eine
|
||||||
|
Restzeit-Schätzung am Fortschritt.
|
||||||
|
|
||||||
|
### Notizen
|
||||||
|
|
||||||
|
* Das UI ist bewusst die W-5-Grundausstattung plus diese Runde — ein
|
||||||
|
größerer optischer Ausbau (Kino-Look wie die Web-Fassung) ist ein
|
||||||
|
eigenes Paket, wenn gewünscht.
|
||||||
|
* `dist-setup/` (der ALTE Bau) hält Windows gerade eine Dateisperre auf
|
||||||
|
`win-unpacked\resources\app.asar` (kein Prozess sichtbar — vermutlich
|
||||||
|
Virenscanner); deshalb liegt der frische Bau in `dist-setup-frisch/`.
|
||||||
|
Nach einem Neustart lässt sich `dist-setup/` löschen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rippy v5 — ALLE Etappen W-0 bis W-7 gebaut, Ampel grün (30.08.2026, abends)
|
||||||
|
|
||||||
|
> **An einem Tag von der leeren Wiese zum installierbaren Programm.** Alle
|
||||||
|
> acht Konzept-Etappen sind gebaut, jede mit grüner Ampel gepusht
|
||||||
|
> (Commits `c539e65` W-0 → `a45069a` W-1 → `844bca3`+`ca338ad` W-2 →
|
||||||
|
> `3dc296f`+`e840b7f` W-3 → `5f386e5`+`a9fa8fa` W-4 → `c78ff22` W-5 →
|
||||||
|
> `2d9cef2` W-7 → `9fa3df5` W-6). **185 Tests grün**, Smoke-Beweis auch
|
||||||
|
> aus dem GEPACKTEN Programm grün. Parallel ist Entscheid 7 umgesetzt:
|
||||||
|
> Branch `aufraeumen-windows-standalone` (`cedab61`) räumt den
|
||||||
|
> Windows-Anteil aus `main` (−10.039 Zeilen; eigener SAVEPOINT-Eintrag
|
||||||
|
> dort). **Der Commander merged ihn nach eigener Prüfung nach `main`.**
|
||||||
|
|
||||||
|
### Die Beweise des Tages (alles gemessen, nichts behauptet)
|
||||||
|
|
||||||
|
* **Setup gebaut:** `rippy-windows/dist-setup/RippySetup-5.0.0-w0.exe`
|
||||||
|
— 130,8 MB, SHA-256
|
||||||
|
`07D39D6BAB74A47C21AA53D847FB5A5DB6990AE9FA1CBC7BE814B51050883115`,
|
||||||
|
mit Blockmap; die mitgelieferten Werkzeuge (HandBrakeCLI, flac samt
|
||||||
|
libFLAC.dll) und die Lizenztexte liegen drin. Das GEPACKTE Programm
|
||||||
|
besteht den Smoke und startet mit der Erst-Einrichtung.
|
||||||
|
* **W-3 end-zu-end am echten Material:** Der 17,71-GB-Spartacus-
|
||||||
|
Rohschnitt (die Datei, die in rc11 am falschen Schalter starb) wurde
|
||||||
|
mit Preset „H.265 VCN 1080p" in **2,1 Minuten auf 1,17 GB**
|
||||||
|
komprimiert und liegt als `E:\Rippy-v5-Beweis\Filme\Spartacus - Gods
|
||||||
|
of the Arena - Disc 2 (2011)\` samt `movie.nfo`.
|
||||||
|
* **W-4 live gegen die echten APIs** (Schlüssel aus den
|
||||||
|
Docker-Settings der VM, nicht persistiert): Akira→95 % ·
|
||||||
|
Evangelion 2.22→80 % (der dokumentierte Fund) · Spartacus GotA→
|
||||||
|
**Serie** 90 % · „Bd Evg D2"→ehrlicher 60-%-Vorschlag. Zwei
|
||||||
|
Kaskaden-Schwächen dabei LIVE gefunden und gehärtet (gekürzte
|
||||||
|
Kandidaten schlugen die spezifische Serie; OMDb erfand zu Kryptischem
|
||||||
|
selbstbewusst Filme — jetzt Wort-Jaccard-Gate).
|
||||||
|
* **W-1 am echten BU40N:** Laufwerk, Typ (bluray, 33,76 GB), Modell,
|
||||||
|
Serial — exakt die `beweise/`-Werte, jetzt aus dem produktiven
|
||||||
|
Treiber. `taskkill /F` aufs Haupt → der Kern stirbt mit (Leine).
|
||||||
|
* **MusicBrainz-DiscID** gegen die echte AC/DC-Kennung
|
||||||
|
(`3KVSIWn_…`) getestet — samt der 150-Frames- und 2048er-Fallen.
|
||||||
|
|
||||||
|
### Was NUR eine Hand am Gerät noch beweisen kann
|
||||||
|
|
||||||
|
Das Laufwerk steckt seit dem abgebrochenen rc11-Rip vom Mittag im
|
||||||
|
**5042-Zustand**: MakeMKV sieht GAR KEIN Laufwerk (leere DRV-Liste),
|
||||||
|
und inzwischen verweigert auch der Storage-Stack die Medien-Abfragen —
|
||||||
|
Rippy v5 zeigt dazu den Klartext-Grund samt Abhilfe in der
|
||||||
|
Laufwerks-Kachel (statt eines stummen „unknown"). **Abhilfe: Disc über
|
||||||
|
die Laufwerkstaste auswerfen und neu einlegen, oder das USB-Kabel kurz
|
||||||
|
ab- und anstecken.** Danach stehen bereit:
|
||||||
|
`RIPPY_MESSUNG_RIP=1`-Lauf (voller Blu-ray-Rip, `messung.rip.test.ts`)
|
||||||
|
und der Auswurf-Beweis (`messung.auswurf.test.ts`). Ebenfalls offen:
|
||||||
|
ein DVD- und ein Audio-CD-Durchlauf (kein Medium da), „ein Film ist in
|
||||||
|
Jellyfin sichtbar" (Jellyfin-Zugang), die automatische
|
||||||
|
MakeMKV-Beschaffung im Setup (heute: klarer Hinweis auf makemkv.com)
|
||||||
|
und die `latest.yml`-Messung am ersten echten Gitea-Release.
|
||||||
|
|
||||||
|
### Nächste Schritte für den Commander
|
||||||
|
|
||||||
|
1. Laufwerk wecken (siehe oben), dann in Rippy v5 „Rippen" drücken —
|
||||||
|
oder die zwei Messläufe aus `rippy-windows/BAUEN.md` fahren.
|
||||||
|
2. `aufraeumen-windows-standalone` prüfen und nach `main` mergen
|
||||||
|
(Ampel ist grün; die VM merkt davon nichts, bis dort gepullt wird).
|
||||||
|
3. Fürs erste echte Release: Version ohne `-w0` bauen (dann entsteht
|
||||||
|
`latest.yml` statt `w0.yml`), die drei Dateien ins Gitea-Release-Tag
|
||||||
|
`aktuell` laden.
|
||||||
|
4. **Termin § 9.1:** MakeMKVs Beta-Key läuft Ende September ab — die
|
||||||
|
Schlüsselkette prüft täglich und warnt im Dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe W-0 — das Gerüst steht (30.08.2026)
|
||||||
|
|
||||||
|
> **Rippy v5 hat sein Fundament.** Auf dem Branch `worktree-windows-electron`
|
||||||
|
> steht jetzt neben dem Konzept das erste lauffähige Programm: Electron 44,
|
||||||
|
> durchgehend TypeScript, drei Prozesse, eine Datenbank, die Prozess-Leine.
|
||||||
|
> Kein Python, kein Docker-Code, kein HTTP-Server — genau wie
|
||||||
|
> `KONZEPT-WINDOWS.md` es festlegt. 18/18 Tests grün, Smoke-Beweis grün.
|
||||||
|
|
||||||
|
### Was gebaut wurde (`rippy-windows/`)
|
||||||
|
|
||||||
|
* **Drei Prozesse, wie im Konzept § 4.1:** Das Haupt (`src/haupt/`) verwaltet
|
||||||
|
Fenster und Kern; der Kern (`src/kern/`, ein Electron-utilityProcess) macht
|
||||||
|
die Arbeit; das Fenster (`src/fenster/`, React) zeigt nur an. Fenster und
|
||||||
|
Kern reden über einen **direkten MessagePort** — kein localhost, kein Port,
|
||||||
|
kein Polling.
|
||||||
|
* **Die Datenbank läuft** — `node:sqlite`, wie in `beweise/` gemessen, und
|
||||||
|
sie hat genau **einen** Zugriffsort (`kern/speicher/db.ts`, § 6.7). Der
|
||||||
|
Kern schreibt beim Start eine Einstellung und liest sie zurück; „geöffnet"
|
||||||
|
allein gilt nicht als Beweis.
|
||||||
|
* **Die Prozess-Leine ist scharf:** Das Haupt legt die Windows-Arbeitsgruppe
|
||||||
|
aus `beweise/leine.js` an und hängt sich selbst hinein — alles, was Rippy
|
||||||
|
je startet, stirbt mit ihm. Eine bewusste, im Code begründete Abweichung
|
||||||
|
von der Ordnerliste § 4.2: Die Leine liegt in `haupt/leine.ts`, nicht im
|
||||||
|
Kern — das Job-Handle muss bei dem Prozess liegen, dessen Tod alles
|
||||||
|
mitreißen soll (§ 4.1: „ALLES stirbt mit ihm").
|
||||||
|
* **Wächter-Tests nach § 4.3** — mechanisch, wie versprochen: koffi nur an
|
||||||
|
den zwei benannten Orten (R1) · kein leerer catch, kein catch, das leere
|
||||||
|
Listen erfindet (R2/R4) · kein HTTP-Server (§ 4.1) · `node:sqlite` nur in
|
||||||
|
`db.ts` (§ 6.7). Dazu Tests für Datenbank und Nachrichten-Schema.
|
||||||
|
* **Sicherheitsrahmen:** contextIsolation, Sandbox, kein Node im Fenster,
|
||||||
|
strikte CSP im gebauten HTML, Einzelinstanz-Sperre, Kern-Neustart-Wache
|
||||||
|
(stirbt der Kern, startet ihn das Haupt neu — höchstens dreimal je Minute,
|
||||||
|
danach steht der Fehler sichtbar im Fenster).
|
||||||
|
|
||||||
|
### Der Beweis
|
||||||
|
|
||||||
|
`npm run smoke` startet das ECHTE Programm und prüft alle fünf Punkte
|
||||||
|
maschinell — Ausgabe vom 30.08.2026 auf dem Commander-PC:
|
||||||
|
|
||||||
|
```
|
||||||
|
SMOKE: OK — fenster=geladen kern=pid:13676,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.0.0-w0
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu nachgemessen, nicht geglaubt: `taskkill /F` auf den Haupt-Prozess
|
||||||
|
(ohne `/T` — der Absturz-Fall, der in rc10 verwaiste makemkvcon
|
||||||
|
hinterließ) → **der Kern ist danach tot.** Der Wirkungs-Test mit einem
|
||||||
|
echten Werkzeug-Kind folgt in W-2, wenn es erstmals eines gibt.
|
||||||
|
|
||||||
|
Das W-0-Fertigkriterium aus § 12 — „Ein Fenster geht auf, der Kern läuft,
|
||||||
|
ein Testlauf ist grün" — ist damit erfüllt.
|
||||||
|
|
||||||
|
### Die Ampel prüft v5 mit
|
||||||
|
|
||||||
|
`ci.yml` läuft jetzt fest auf **Node 24** (vorher: Zufalls-Node des
|
||||||
|
Runners; `docker/ui` unter Node 24 vorher lokal nachgemessen). Die Ampel
|
||||||
|
baut und testet `rippy-windows/` auf Linux mit — nur der Smoke bleibt ein
|
||||||
|
Windows-Beweis und steht deshalb hier mit Ausgabe.
|
||||||
|
|
||||||
|
### Stolperdrähte, dokumentiert in `rippy-windows/BAUEN.md`
|
||||||
|
|
||||||
|
npm 11 blockt Electrons Install-Skript — nach `npm install` fehlt die
|
||||||
|
`electron.exe`, `node node_modules/electron/install.js` holt sie nach
|
||||||
|
(§ 3.5, beim Bau erneut bestätigt) · `@vitejs/plugin-react` 6 verlangt
|
||||||
|
Vite 8, deshalb die 5er-Linie · TypeScript fest auf 5.9.3 gepinnt (die
|
||||||
|
neue Go-Fassung 7.x ist eine bewusste Entscheidung für später).
|
||||||
|
|
||||||
|
### Was W-0 absichtlich NICHT kann
|
||||||
|
|
||||||
|
Kein Tray, kein Autostart, kein Update, keine Laufwerks-Erkennung — das
|
||||||
|
sind W-1 bis W-6. Bis das Tray kommt (W-5), gilt: Fenster zu = Rippy zu.
|
||||||
|
|
||||||
|
### Nächste Schritte
|
||||||
|
|
||||||
|
1. **W-1 — Laufwerk:** `kern/laufwerk/win32.ts` aus dem § 3.2-Beweis,
|
||||||
|
Wache im 3-Sekunden-Takt, Disc-Typ, Auswerfen mit Nachsehen.
|
||||||
|
2. **Parallel, eigener Branch von `main`:** das Aufräumen nach § 11
|
||||||
|
(A → C, Remote-Worker bleibt) — noch nicht begonnen.
|
||||||
|
3. **Termin im Blick (§ 9.1):** MakeMKVs Beta-Key läuft **Ende September
|
||||||
|
2026** ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026)
|
||||||
|
|
||||||
> **Ein falscher Kommandozeilen-Schalter kostete einen fertigen 16,5-GB-Rip —
|
> **Ein falscher Kommandozeilen-Schalter kostete einen fertigen 16,5-GB-Rip —
|
||||||
> und HandBrake meldete es als Erfolg.** Dazu vierzehn weitere Funde aus
|
> und HandBrake meldete es als Erfolg.** Dazu vierzehn weitere Funde aus
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||||
|
|
||||||
|
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||||
|
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||||
|
Konzept hängt:
|
||||||
|
|
||||||
|
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||||
|
|
||||||
|
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||||
|
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||||
|
|
||||||
|
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install koffi
|
||||||
|
node laufwerk.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||||
|
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||||
|
|
||||||
|
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||||
|
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||||
|
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||||
|
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||||
|
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||||
|
| Rev: 1.03 | Serial: 0025114C0149
|
||||||
|
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||||
|
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||||
|
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||||
|
```
|
||||||
|
|
||||||
|
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||||
|
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||||
|
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||||
|
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||||
|
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||||
|
|
||||||
|
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||||
|
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||||
|
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||||
|
ein antwortendes Laufwerk für tot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `leine.js` — die Prozess-Leine (Job Object)
|
||||||
|
|
||||||
|
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||||
|
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||||
|
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||||
|
|
||||||
|
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||||
|
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||||
|
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||||
|
Drüber-Installieren passiert.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||||
|
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||||
|
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||||
|
4. Kindprozess gestartet, PID 16040
|
||||||
|
|
||||||
|
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||||
|
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||||
|
danach: Eltern lebt: False Kind lebt: False
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachstellen (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||||
|
Start-Sleep 3
|
||||||
|
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||||
|
taskkill /F /PID $p.Id
|
||||||
|
Start-Sleep 2
|
||||||
|
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||||
|
|
||||||
|
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||||
|
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||||
|
überhaupt vorhanden?
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||||
|
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||||
|
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||||
|
Kern beendet mit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||||
|
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||||
|
Messung zuvor ebenfalls grün.)
|
||||||
|
|
||||||
|
Nachstellen:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install electron@44 --no-save
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||||
|
|
||||||
|
```
|
||||||
|
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||||
|
```
|
||||||
|
|
||||||
|
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||||
|
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||||
|
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||||
|
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||||
|
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||||
|
Tages jemand an der falschen Stelle.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||||
|
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
|
||||||
|
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||||
|
const ctl = (typ, fn, methode, zugriff) =>
|
||||||
|
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||||
|
|
||||||
|
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||||
|
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||||
|
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||||
|
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||||
|
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||||
|
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||||
|
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||||
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
|
||||||
|
|
||||||
|
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||||
|
const DRIVE_CDROM = 5;
|
||||||
|
|
||||||
|
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||||
|
const maske = GetLogicalDrives();
|
||||||
|
const optische = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
if (!(maske & (1 << i))) continue;
|
||||||
|
const buchstabe = String.fromCharCode(65 + i);
|
||||||
|
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||||
|
}
|
||||||
|
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||||
|
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const lw = optische[0];
|
||||||
|
|
||||||
|
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||||
|
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||||
|
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||||
|
function oeffnen(zugriff) {
|
||||||
|
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||||
|
const adr = koffi.address(h);
|
||||||
|
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||||
|
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||||
|
return { h, fehler: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ohneMedium = oeffnen(0);
|
||||||
|
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||||
|
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||||
|
const mitLesen = oeffnen(GENERIC_READ);
|
||||||
|
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||||
|
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||||
|
|
||||||
|
const h = ohneMedium.h;
|
||||||
|
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const rueck = Buffer.alloc(4);
|
||||||
|
|
||||||
|
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||||
|
const anfrage = Buffer.alloc(12);
|
||||||
|
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||||
|
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||||
|
const antwort = Buffer.alloc(1024);
|
||||||
|
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||||
|
const text = (off) => {
|
||||||
|
const start = antwort.readUInt32LE(off);
|
||||||
|
if (!start || start >= antwort.length) return '';
|
||||||
|
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||||
|
return antwort.toString('latin1', start, ende).trim();
|
||||||
|
};
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||||
|
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||||
|
} else {
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||||
|
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||||
|
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||||
|
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||||
|
|
||||||
|
// ── 5. Audio- oder Datenspur?
|
||||||
|
const typBuf = Buffer.alloc(1);
|
||||||
|
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||||
|
const t = typBuf[0];
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||||
|
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||||
|
} else {
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||||
|
const laenge = Buffer.alloc(8);
|
||||||
|
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||||
|
const bytes = laenge.readBigUInt64LE(0);
|
||||||
|
const gb = Number(bytes) / 1e9;
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||||
|
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||||
|
} else {
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(h);
|
||||||
|
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||||
|
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||||
|
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||||
|
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||||
|
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||||
|
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||||
|
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
|
||||||
|
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||||
|
const JobObjectExtendedLimitInformation = 9;
|
||||||
|
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||||
|
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||||
|
|
||||||
|
const job = CreateJobObjectW(null, null);
|
||||||
|
if (koffi.address(job) === 0n) {
|
||||||
|
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||||
|
|
||||||
|
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||||
|
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||||
|
const info = Buffer.alloc(144);
|
||||||
|
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||||
|
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||||
|
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||||
|
|
||||||
|
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||||
|
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||||
|
|
||||||
|
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||||
|
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||||
|
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||||
|
console.log('ELTERN-PID=' + process.pid);
|
||||||
|
console.log('KIND-PID=' + kind.pid);
|
||||||
|
|
||||||
|
// Offen halten, bis uns jemand hart abschiesst.
|
||||||
|
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||||
|
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||||
|
try {
|
||||||
|
const s = require('node:sqlite');
|
||||||
|
const db = new s.DatabaseSync(':memory:');
|
||||||
|
db.exec('CREATE TABLE t (a TEXT)');
|
||||||
|
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||||
|
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||||
|
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||||
|
} catch (e) {
|
||||||
|
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||||
|
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||||
|
//
|
||||||
|
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||||
|
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||||
|
const { app, utilityProcess } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||||
|
kind.on('message', (antwort) => {
|
||||||
|
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||||
|
app.exit(antwort.ok ? 0 : 1);
|
||||||
|
});
|
||||||
|
kind.on('exit', (code) => {
|
||||||
|
console.log('Kern beendet mit ' + code);
|
||||||
|
if (code !== 0) app.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
@@ -197,10 +197,16 @@ def test_englisch_wird_bevorzugt(tmp_path):
|
|||||||
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||||
|
|
||||||
|
|
||||||
def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||||
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei
|
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||||
installiertem MakeMKV None zurueck -- Windows-Programme liegen in
|
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||||
„Programme", nicht im PATH. Dieser Zweig lief dort also NIE."""
|
Programme liegen in „Programme", nicht im PATH) und war unter Docker
|
||||||
|
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
|
||||||
|
der Aufruf noch die shutil.which-Suche zurueckkommen.
|
||||||
|
|
||||||
|
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden` —
|
||||||
|
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
|
||||||
|
die Entfernung deshalb erst dort gemeldet.)"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
hier = os.path.dirname(os.path.abspath(__file__))
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
|||||||
quelle = "".join(code)
|
quelle = "".join(code)
|
||||||
assert "shutil.which(" not in quelle, (
|
assert "shutil.which(" not in quelle, (
|
||||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||||
assert 'werkzeug_katalog.finden("makemkv")' in quelle
|
assert "makemkvcon" not in quelle, (
|
||||||
|
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||||
|
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
||||||
|
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
||||||
|
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
||||||
|
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
|
||||||
|
dist-setup*/
|
||||||
|
vendor/
|
||||||
|
.electron-cache/
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Rippy v5 bauen — Anleitung
|
||||||
|
|
||||||
|
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||||
|
> die Messungen dazu liegen in `beweise/`.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Werkzeug | Version | Anmerkung |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||||
|
| npm | 11 | kommt mit Node 24 |
|
||||||
|
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||||
|
|
||||||
|
## Einrichten — zwei Schritte, nicht einer
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||||
|
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||||
|
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||||
|
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||||
|
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||||
|
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||||
|
|
||||||
|
## Die Läufe
|
||||||
|
|
||||||
|
| Befehl | Was er tut |
|
||||||
|
|--------|------------|
|
||||||
|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||||
|
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||||
|
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||||
|
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||||
|
|
||||||
|
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||||
|
fasst die echten Einstellungen nicht an.
|
||||||
|
|
||||||
|
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||||
|
|
||||||
|
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||||
|
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||||
|
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||||
|
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||||
|
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||||
|
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||||
|
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||||
|
|
||||||
|
## Das Setup bauen (W-6)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run paket
|
||||||
|
```
|
||||||
|
|
||||||
|
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
|
||||||
|
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
||||||
|
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||||
|
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||||
|
|
||||||
|
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||||
|
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||||
|
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||||
|
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||||
|
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||||
|
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||||
|
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||||
|
(`npx electron-builder --win --config electron-builder.yml
|
||||||
|
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||||
|
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||||
|
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||||
|
|
||||||
|
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||||
|
liegen nicht im Git, 74 MB):
|
||||||
|
|
||||||
|
```
|
||||||
|
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
|
||||||
|
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
|
||||||
|
Aufruf ohne eine Zeile Ausgabe)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
|
||||||
|
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
|
||||||
|
in `bau/lizenzen/QUELLEN.md`.
|
||||||
|
|
||||||
|
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
|
||||||
|
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
|
||||||
|
ERSETZEN) — `electron-updater` prüft
|
||||||
|
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
|
||||||
|
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
|
||||||
|
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
|
||||||
|
|
||||||
|
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
|
||||||
|
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
|
||||||
|
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
|
||||||
|
erkennen.
|
||||||
|
|
||||||
|
## Festgelegte Versionen und warum
|
||||||
|
|
||||||
|
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||||
|
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||||
|
Release prüfen, ob eine neue Linie ansteht.
|
||||||
|
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||||
|
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||||
|
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||||
|
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||||
|
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||||
|
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||||
|
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||||
|
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||||
|
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||||
|
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Copyright (C) 2000-2009 Josh Coalson
|
||||||
|
Copyright (C) 2011-2025 Xiph.Org Foundation
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
- Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
- Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
- Neither the name of the Xiph.Org Foundation nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
<https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Moe Ghoul>, 1 April 1989
|
||||||
|
Moe Ghoul, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
|
||||||
|
|
||||||
|
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
|
||||||
|
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
|
||||||
|
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
|
||||||
|
holt es beim Einrichten vom Hersteller.
|
||||||
|
|
||||||
|
## HandBrakeCLI
|
||||||
|
|
||||||
|
- Lizenz: GNU General Public License v2 — Volltext in
|
||||||
|
`HANDBRAKE-GPL-2.0.txt` (bezogen von
|
||||||
|
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
|
||||||
|
- Quelltext und Projekt: https://handbrake.fr bzw.
|
||||||
|
https://github.com/HandBrake/HandBrake — der Quelltext der
|
||||||
|
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
|
||||||
|
|
||||||
|
## FLAC (flac.exe + libFLAC.dll)
|
||||||
|
|
||||||
|
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
|
||||||
|
`FLAC-COPYING.Xiph` (bezogen von
|
||||||
|
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
|
||||||
|
30.08.2026).
|
||||||
|
- Projekt: https://xiph.org/flac/
|
||||||
|
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
|
||||||
|
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
|
||||||
|
|
||||||
|
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
|
||||||
|
|
||||||
|
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
|
||||||
|
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
|
||||||
|
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
|
||||||
|
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
|
||||||
|
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
|
||||||
|
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
|
||||||
|
keine Schriften aus dem Netz.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,42 @@
|
|||||||
|
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
|
||||||
|
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
|
||||||
|
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
|
||||||
|
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
|
||||||
|
appId: de.krbrz.rippy
|
||||||
|
productName: Rippy
|
||||||
|
directories:
|
||||||
|
output: dist-setup
|
||||||
|
buildResources: bau
|
||||||
|
files:
|
||||||
|
- out/**
|
||||||
|
- package.json
|
||||||
|
extraResources:
|
||||||
|
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
|
||||||
|
- from: vendor
|
||||||
|
to: vendor
|
||||||
|
- from: bau/lizenzen
|
||||||
|
to: lizenzen
|
||||||
|
- from: bau/tray.png
|
||||||
|
to: bau/tray.png
|
||||||
|
win:
|
||||||
|
icon: bau/icon.ico
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
nsis:
|
||||||
|
oneClick: true
|
||||||
|
perMachine: false
|
||||||
|
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
|
||||||
|
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
|
||||||
|
allowToChangeInstallationDirectory: false
|
||||||
|
deleteAppDataOnUninstall: false
|
||||||
|
artifactName: RippySetup-${version}.${ext}
|
||||||
|
publish:
|
||||||
|
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
|
||||||
|
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
|
||||||
|
# ersten echten Release gemessen — § 3.6 ⚠).
|
||||||
|
provider: generic
|
||||||
|
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
|
||||||
|
electronDownload:
|
||||||
|
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
|
||||||
|
# lädt die Binärdateien selbst über diesen Cache-Weg.
|
||||||
|
cache: .electron-cache
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||||
|
// haupt → out/haupt/index.js (Electron Main)
|
||||||
|
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||||
|
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||||
|
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||||
|
// fenster → out/fenster/ (React)
|
||||||
|
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||||
|
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||||
|
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||||
|
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||||
|
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||||
|
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||||
|
function cspNurImBau(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'csp-nur-im-bau',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<head>',
|
||||||
|
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||||
|
// bleibt alles bei 'self'.
|
||||||
|
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/haupt',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||||
|
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/bruecke',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: 'src/fenster',
|
||||||
|
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/fenster',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+7289
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "rippy",
|
||||||
|
"productName": "Rippy",
|
||||||
|
"version": "5.1.0",
|
||||||
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
|
"main": "out/haupt/index.js",
|
||||||
|
"author": "KrBrZ",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"smoke": "electron-vite build && electron . --smoke",
|
||||||
|
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||||
|
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
|
"koffi": "^3.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@fontsource/instrument-sans": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"electron": "44.0.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "^7.3.6",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
|
||||||
|
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
|
||||||
|
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
|
||||||
|
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
|
||||||
|
// Kontextgrenze — contextBridge kann keine Ports übertragen)
|
||||||
|
// 2. den Haupt-Status (Kanal 'haupt-status')
|
||||||
|
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
|
||||||
|
//
|
||||||
|
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
|
||||||
|
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
|
||||||
|
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
|
||||||
|
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
|
||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
|
||||||
|
let seiteBereit = false
|
||||||
|
const wartendePorts: IpcRendererEvent[] = []
|
||||||
|
|
||||||
|
ipcRenderer.on('kern-port', (ereignis) => {
|
||||||
|
if (seiteBereit) {
|
||||||
|
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||||
|
} else {
|
||||||
|
wartendePorts.push(ereignis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let letzterStatus: unknown = null
|
||||||
|
const statusHorcher = new Set<(status: unknown) => void>()
|
||||||
|
|
||||||
|
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
|
||||||
|
letzterStatus = status
|
||||||
|
for (const horcher of statusHorcher) horcher(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('rippy', {
|
||||||
|
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
|
||||||
|
bereit(): void {
|
||||||
|
seiteBereit = true
|
||||||
|
for (const ereignis of wartendePorts.splice(0)) {
|
||||||
|
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
|
||||||
|
aufStatus(horcher: (status: unknown) => void): void {
|
||||||
|
statusHorcher.add(horcher)
|
||||||
|
if (letzterStatus !== null) horcher(letzterStatus)
|
||||||
|
},
|
||||||
|
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
|
||||||
|
melden(art: string): void {
|
||||||
|
ipcRenderer.send('fenster-meldung', String(art))
|
||||||
|
},
|
||||||
|
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
|
||||||
|
ordnerWaehlen(titel: string): Promise<string | null> {
|
||||||
|
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
|
||||||
|
},
|
||||||
|
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
|
||||||
|
autostartSetzen(an: boolean): Promise<boolean> {
|
||||||
|
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
rippy: {
|
||||||
|
bereit(): void
|
||||||
|
aufStatus(horcher: (status: unknown) => void): void
|
||||||
|
melden(art: string): void
|
||||||
|
ordnerWaehlen(titel: string): Promise<string | null>
|
||||||
|
autostartSetzen(an: boolean): Promise<boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
|
||||||
|
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
|
||||||
|
import '@fontsource-variable/bricolage-grotesque'
|
||||||
|
import '@fontsource/instrument-sans/400.css'
|
||||||
|
import '@fontsource/instrument-sans/500.css'
|
||||||
|
import '@fontsource/instrument-sans/600.css'
|
||||||
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
|
import '@fontsource/jetbrains-mono/500.css'
|
||||||
|
import '@fontsource/jetbrains-mono/700.css'
|
||||||
|
import './stil.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('wurzel')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rippy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wurzel"></div>
|
||||||
|
<script type="module" src="./haupt.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
|
||||||
|
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
|
||||||
|
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
|
||||||
|
//
|
||||||
|
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
|
||||||
|
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||||
|
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||||
|
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||||
|
import {
|
||||||
|
istKernNachricht,
|
||||||
|
type BeschaffungStand,
|
||||||
|
type BibliothekEintrag,
|
||||||
|
type DiscInfoStand,
|
||||||
|
type GeraeteInfo,
|
||||||
|
type KernNachricht,
|
||||||
|
type MetaVorschlag,
|
||||||
|
type RipStatus,
|
||||||
|
type WerkzeugAuskunft,
|
||||||
|
} from '../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export interface Verbindung {
|
||||||
|
verbunden: boolean
|
||||||
|
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||||
|
pongLatenzMs: number | null
|
||||||
|
laufwerke: GeraeteInfo[]
|
||||||
|
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||||
|
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||||
|
rips: Record<string, RipStatus>
|
||||||
|
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||||
|
discInfos: Record<string, DiscInfoStand>
|
||||||
|
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||||
|
vorschlaege: Record<string, MetaVorschlag[]>
|
||||||
|
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||||
|
ripStart: Record<string, number>
|
||||||
|
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||||
|
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||||
|
* Vorgang beginnt. */
|
||||||
|
ripProtokoll: Record<string, string[]>
|
||||||
|
einstellungen: Record<string, string>
|
||||||
|
werkzeuge: WerkzeugAuskunft | null
|
||||||
|
bibliothek: BibliothekEintrag[]
|
||||||
|
schluessel: {
|
||||||
|
quelle: string
|
||||||
|
angenommen: boolean | null
|
||||||
|
weiterBetrieb: boolean
|
||||||
|
gueltigBis: string
|
||||||
|
geholtAm: string
|
||||||
|
text: string
|
||||||
|
version: string
|
||||||
|
} | null
|
||||||
|
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||||
|
beschaffung: BeschaffungStand | null
|
||||||
|
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||||
|
meldungen: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Horcher = (stand: Verbindung) => void
|
||||||
|
|
||||||
|
const MAX_MELDUNGEN = 20
|
||||||
|
|
||||||
|
const stand: Verbindung = {
|
||||||
|
verbunden: false,
|
||||||
|
kern: null,
|
||||||
|
pongLatenzMs: null,
|
||||||
|
laufwerke: [],
|
||||||
|
rips: {},
|
||||||
|
discInfos: {},
|
||||||
|
vorschlaege: {},
|
||||||
|
ripStart: {},
|
||||||
|
ripProtokoll: {},
|
||||||
|
einstellungen: {},
|
||||||
|
werkzeuge: null,
|
||||||
|
bibliothek: [],
|
||||||
|
schluessel: null,
|
||||||
|
beschaffung: null,
|
||||||
|
meldungen: [],
|
||||||
|
}
|
||||||
|
const horcher = new Set<Horcher>()
|
||||||
|
let gestartet = false
|
||||||
|
let port: MessagePort | null = null
|
||||||
|
|
||||||
|
function melden(): void {
|
||||||
|
for (const h of horcher) {
|
||||||
|
h({
|
||||||
|
...stand,
|
||||||
|
laufwerke: [...stand.laufwerke],
|
||||||
|
rips: { ...stand.rips },
|
||||||
|
discInfos: { ...stand.discInfos },
|
||||||
|
vorschlaege: { ...stand.vorschlaege },
|
||||||
|
einstellungen: { ...stand.einstellungen },
|
||||||
|
bibliothek: [...stand.bibliothek],
|
||||||
|
meldungen: [...stand.meldungen],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldung(text: string): void {
|
||||||
|
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
function kernNachricht(nachricht: KernNachricht): void {
|
||||||
|
switch (nachricht.art) {
|
||||||
|
case 'kern-bereit':
|
||||||
|
stand.kern = nachricht
|
||||||
|
break
|
||||||
|
case 'pong':
|
||||||
|
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
|
||||||
|
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
|
||||||
|
window.rippy.melden('kern-pong')
|
||||||
|
break
|
||||||
|
case 'kern-fehler':
|
||||||
|
meldung(`⚠ ${nachricht.text}`)
|
||||||
|
break
|
||||||
|
case 'laufwerke':
|
||||||
|
stand.laufwerke = nachricht.liste
|
||||||
|
for (const ereignis of nachricht.ereignisse) {
|
||||||
|
if (ereignis.art === 'disc-eingelegt') {
|
||||||
|
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||||
|
} else if (ereignis.art === 'disc-entnommen') {
|
||||||
|
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||||
|
} else if (ereignis.art === 'laufwerk-weg') {
|
||||||
|
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'auswurf-ergebnis': {
|
||||||
|
const text =
|
||||||
|
nachricht.grund === 'ok'
|
||||||
|
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||||
|
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||||
|
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'disc-info': {
|
||||||
|
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||||
|
if (nachricht.info.titel.length > 0) {
|
||||||
|
meldung(
|
||||||
|
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'metadaten-vorschlaege':
|
||||||
|
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||||
|
break
|
||||||
|
case 'einstellungen':
|
||||||
|
stand.einstellungen = nachricht.werte
|
||||||
|
break
|
||||||
|
case 'werkzeuge':
|
||||||
|
stand.werkzeuge = nachricht.auskunft
|
||||||
|
break
|
||||||
|
case 'bibliothek':
|
||||||
|
stand.bibliothek = nachricht.eintraege
|
||||||
|
break
|
||||||
|
case 'schluessel-status':
|
||||||
|
stand.schluessel = nachricht.stand
|
||||||
|
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||||
|
break
|
||||||
|
case 'beschaffung-status':
|
||||||
|
stand.beschaffung = nachricht.stand
|
||||||
|
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||||
|
break
|
||||||
|
case 'rip-status': {
|
||||||
|
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||||
|
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||||
|
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||||
|
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||||
|
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||||
|
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||||
|
} else if (!laeuft) {
|
||||||
|
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||||
|
stand.ripStart = rest
|
||||||
|
}
|
||||||
|
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||||
|
// wiederholen den Text — nur echte Änderungen zählen).
|
||||||
|
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||||
|
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||||
|
stand.ripProtokoll = {
|
||||||
|
...stand.ripProtokoll,
|
||||||
|
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nachricht.status.phase === 'fertig') {
|
||||||
|
meldung(
|
||||||
|
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||||
|
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||||
|
)
|
||||||
|
} else if (nachricht.status.phase === 'fehler') {
|
||||||
|
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||||
|
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||||
|
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
melden()
|
||||||
|
}
|
||||||
|
|
||||||
|
function portAnnehmen(neuerPort: MessagePort): void {
|
||||||
|
port = neuerPort
|
||||||
|
stand.verbunden = true
|
||||||
|
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||||
|
const nachricht: unknown = ereignis.data
|
||||||
|
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||||
|
}
|
||||||
|
neuerPort.start()
|
||||||
|
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||||
|
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||||
|
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||||
|
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||||
|
melden()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
|
||||||
|
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
|
||||||
|
export function verbindungStarten(): void {
|
||||||
|
if (gestartet) return
|
||||||
|
gestartet = true
|
||||||
|
window.addEventListener('message', (ereignis: MessageEvent) => {
|
||||||
|
const daten: unknown = ereignis.data
|
||||||
|
if (
|
||||||
|
typeof daten === 'object' &&
|
||||||
|
daten !== null &&
|
||||||
|
(daten as Record<string, unknown>).art === 'kern-port' &&
|
||||||
|
ereignis.ports.length > 0
|
||||||
|
) {
|
||||||
|
portAnnehmen(ereignis.ports[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.rippy.bereit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aufVerbindung(h: Horcher): () => void {
|
||||||
|
horcher.add(h)
|
||||||
|
h({
|
||||||
|
...stand,
|
||||||
|
laufwerke: [...stand.laufwerke],
|
||||||
|
rips: { ...stand.rips },
|
||||||
|
discInfos: { ...stand.discInfos },
|
||||||
|
vorschlaege: { ...stand.vorschlaege },
|
||||||
|
einstellungen: { ...stand.einstellungen },
|
||||||
|
bibliothek: [...stand.bibliothek],
|
||||||
|
meldungen: [...stand.meldungen],
|
||||||
|
})
|
||||||
|
return () => horcher.delete(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||||
|
if (port === null) {
|
||||||
|
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||||
|
melden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port.postMessage(nachricht)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auswerfen(id: string): void {
|
||||||
|
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ripStarten(id: string): void {
|
||||||
|
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ripAbbrechen(id: string): void {
|
||||||
|
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metadatenSuchen(id: string, text: string): void {
|
||||||
|
if (port === null) return
|
||||||
|
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||||
|
if (port === null) return
|
||||||
|
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makemkvUpdate(): void {
|
||||||
|
if (port === null) {
|
||||||
|
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||||
|
melden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port.postMessage({ art: 'makemkv-update' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schluesselPruefen(): void {
|
||||||
|
if (port === null) return
|
||||||
|
port.postMessage({ art: 'schluessel-pruefen' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||||
|
if (port === null) {
|
||||||
|
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||||
|
melden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
|
||||||
|
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
|
||||||
|
Geräte-Präzision — Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
|
||||||
|
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
|
||||||
|
@theme {
|
||||||
|
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
|
||||||
|
--font-sans: 'Instrument Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', Consolas, monospace;
|
||||||
|
|
||||||
|
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
|
||||||
|
--color-buehne: oklch(0.13 0.012 60);
|
||||||
|
--color-karte: oklch(0.17 0.015 60);
|
||||||
|
--color-samt: oklch(0.16 0.015 60);
|
||||||
|
|
||||||
|
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
|
||||||
|
--color-gold-hell: oklch(0.88 0.09 80);
|
||||||
|
--color-gold: oklch(0.83 0.11 75);
|
||||||
|
--color-gold-tief: oklch(0.74 0.13 60);
|
||||||
|
|
||||||
|
/* Die Aufnahme-LED aus dem Schaltpult. */
|
||||||
|
--color-rec: oklch(0.72 0.17 45);
|
||||||
|
|
||||||
|
/* Schriftstufen — warmes Weiß bis Nebel. */
|
||||||
|
--color-schnee: oklch(0.93 0.01 80);
|
||||||
|
--color-dunst: oklch(0.68 0.015 80);
|
||||||
|
--color-nebel: oklch(0.55 0.015 80);
|
||||||
|
|
||||||
|
/* Zustandsfarben, alle in der warmen Familie. */
|
||||||
|
--color-gruen: oklch(0.78 0.13 150);
|
||||||
|
--color-warn: oklch(0.8 0.12 85);
|
||||||
|
--color-rot: oklch(0.68 0.19 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer — liegt als
|
||||||
|
festes Bühnenbild hinter allem. */
|
||||||
|
.buehnenlicht {
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
|
||||||
|
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
|
||||||
|
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
|
||||||
|
.filmkorn {
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
|
||||||
|
opacity: 0.05;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
|
||||||
|
.segment-gluehen {
|
||||||
|
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
|
||||||
|
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
||||||
|
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
||||||
|
// Datei läuft im Fenster UND im Kern.
|
||||||
|
//
|
||||||
|
// Wege:
|
||||||
|
// Kern → Haupt über process.parentPort (KernNachricht)
|
||||||
|
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
||||||
|
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
||||||
|
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
||||||
|
|
||||||
|
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||||||
|
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||||||
|
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||||
|
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||||||
|
|
||||||
|
export interface GeraeteInfo {
|
||||||
|
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
typ: DiscTyp
|
||||||
|
pfad: string
|
||||||
|
status: LaufwerkStatus
|
||||||
|
modell: string
|
||||||
|
seriennummer: string
|
||||||
|
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||||||
|
groesseBytes: number
|
||||||
|
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||||||
|
grund: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WacheEreignis =
|
||||||
|
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||||||
|
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||||||
|
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||||||
|
| { art: 'laufwerk-weg'; id: string }
|
||||||
|
|
||||||
|
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||||
|
|
||||||
|
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||||
|
export type RipPhase =
|
||||||
|
| 'liest-info'
|
||||||
|
| 'rippt'
|
||||||
|
| 'komprimiert'
|
||||||
|
| 'legt-ab'
|
||||||
|
| 'fertig'
|
||||||
|
| 'fehler'
|
||||||
|
| 'abgebrochen'
|
||||||
|
|
||||||
|
export interface RipStatus {
|
||||||
|
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||||
|
id: string
|
||||||
|
phase: RipPhase
|
||||||
|
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||||||
|
prozent: number
|
||||||
|
text: string
|
||||||
|
dateien: string[]
|
||||||
|
lesefehler: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||||
|
export interface DiscInfoStand {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||||||
|
confidence: number
|
||||||
|
quelle: string
|
||||||
|
posterPfad: string
|
||||||
|
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||||||
|
backdropPfad: string
|
||||||
|
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||||||
|
beschreibung: string
|
||||||
|
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||||
|
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||||
|
schonGerippt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||||
|
export interface BibliothekEintrag {
|
||||||
|
fingerprint: string
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
typ: string
|
||||||
|
groesseBytes: number
|
||||||
|
ablageort: string
|
||||||
|
datum: string
|
||||||
|
dauerS: number
|
||||||
|
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||||||
|
posterPfad: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||||||
|
export interface MetaVorschlag {
|
||||||
|
typ: 'movie' | 'tv'
|
||||||
|
tmdbId: number
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
posterPfad: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
||||||
|
export interface WerkzeugAuskunft {
|
||||||
|
makemkvcon: string
|
||||||
|
handbrake: string
|
||||||
|
flac: string
|
||||||
|
presets: string[]
|
||||||
|
backends: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||||||
|
export interface BeschaffungStand {
|
||||||
|
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||||||
|
werkzeug: string
|
||||||
|
laeuft: boolean
|
||||||
|
text: string
|
||||||
|
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||||
|
anteil: number | null
|
||||||
|
fehler: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachrichten, die der Kern verschickt. */
|
||||||
|
export type KernNachricht =
|
||||||
|
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||||
|
| { art: 'pong'; zeit: number }
|
||||||
|
| { art: 'kern-fehler'; text: string }
|
||||||
|
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||||
|
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||||
|
| { art: 'rip-status'; status: RipStatus }
|
||||||
|
| { art: 'disc-info'; info: DiscInfoStand }
|
||||||
|
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||||
|
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||||
|
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||||
|
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||||
|
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||||||
|
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||||||
|
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||||||
|
| { art: 'installer-starten'; pfad: string }
|
||||||
|
| {
|
||||||
|
art: 'schluessel-status'
|
||||||
|
stand: {
|
||||||
|
quelle: string
|
||||||
|
angenommen: boolean | null
|
||||||
|
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
||||||
|
weiterBetrieb: boolean
|
||||||
|
gueltigBis: string
|
||||||
|
geholtAm: string
|
||||||
|
text: string
|
||||||
|
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||||
|
export type FensterNachricht =
|
||||||
|
| { art: 'ping'; zeit: number }
|
||||||
|
| { art: 'auswerfen'; id: string }
|
||||||
|
| { art: 'rip-start'; id: string }
|
||||||
|
| { art: 'rip-abbruch'; id: string }
|
||||||
|
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||||
|
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||||
|
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||||
|
| { art: 'einstellungen-laden' }
|
||||||
|
| { art: 'werkzeuge-laden' }
|
||||||
|
| { art: 'bibliothek-laden' }
|
||||||
|
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||||||
|
| { art: 'makemkv-update' }
|
||||||
|
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||||
|
| { art: 'schluessel-pruefen' }
|
||||||
|
|
||||||
|
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||||||
|
export type HauptNachricht =
|
||||||
|
| { art: 'fenster-port' }
|
||||||
|
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||||||
|
| { art: 'installer-ergebnis'; fehler: string | null }
|
||||||
|
|
||||||
|
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||||||
|
export interface HauptStatus {
|
||||||
|
version: string
|
||||||
|
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
||||||
|
leine: { ok: boolean; fehler?: string }
|
||||||
|
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||||
|
autostart: boolean
|
||||||
|
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
||||||
|
update: { letzteErfolgreichePruefung: string; text: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||||
|
return typeof wert === 'object' && wert !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
switch (wert.art) {
|
||||||
|
case 'kern-bereit':
|
||||||
|
return (
|
||||||
|
typeof wert.pid === 'number' &&
|
||||||
|
typeof wert.nodeVersion === 'string' &&
|
||||||
|
istDbStatus(wert.datenbank)
|
||||||
|
)
|
||||||
|
case 'pong':
|
||||||
|
return typeof wert.zeit === 'number'
|
||||||
|
case 'kern-fehler':
|
||||||
|
return typeof wert.text === 'string'
|
||||||
|
case 'laufwerke':
|
||||||
|
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||||||
|
case 'auswurf-ergebnis':
|
||||||
|
return (
|
||||||
|
typeof wert.id === 'string' &&
|
||||||
|
typeof wert.grund === 'string' &&
|
||||||
|
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||||
|
)
|
||||||
|
case 'rip-status':
|
||||||
|
return istRipStatus(wert.status)
|
||||||
|
case 'disc-info':
|
||||||
|
return (
|
||||||
|
istObjekt(wert.info) &&
|
||||||
|
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||||||
|
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||||
|
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||||
|
)
|
||||||
|
case 'einstellungen':
|
||||||
|
return istObjekt(wert.werte)
|
||||||
|
case 'werkzeuge':
|
||||||
|
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||||||
|
case 'bibliothek':
|
||||||
|
return Array.isArray(wert.eintraege)
|
||||||
|
case 'schluessel-status':
|
||||||
|
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||||
|
case 'beschaffung-status':
|
||||||
|
return (
|
||||||
|
istObjekt(wert.stand) &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||||||
|
)
|
||||||
|
case 'installer-starten':
|
||||||
|
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||||||
|
case 'metadaten-vorschlaege':
|
||||||
|
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return (
|
||||||
|
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
||||||
|
typeof wert.tmdbId === 'number' &&
|
||||||
|
typeof wert.titel === 'string' &&
|
||||||
|
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
||||||
|
typeof wert.posterPfad === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return (
|
||||||
|
typeof wert.id === 'string' &&
|
||||||
|
typeof wert.phase === 'string' &&
|
||||||
|
typeof wert.prozent === 'number' &&
|
||||||
|
typeof wert.text === 'string' &&
|
||||||
|
Array.isArray(wert.dateien) &&
|
||||||
|
typeof wert.lesefehler === 'boolean'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||||||
|
if (wert.ok === false) return typeof wert.fehler === 'string'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||||
|
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||||||
|
return typeof wert.id === 'string' && wert.id.length > 0
|
||||||
|
}
|
||||||
|
if (wert.art === 'einstellung-setzen') {
|
||||||
|
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
||||||
|
}
|
||||||
|
if (wert.art === 'metadaten-suchen') {
|
||||||
|
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
||||||
|
}
|
||||||
|
if (wert.art === 'zuordnung-setzen') {
|
||||||
|
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
wert.art === 'einstellungen-laden' ||
|
||||||
|
wert.art === 'werkzeuge-laden' ||
|
||||||
|
wert.art === 'bibliothek-laden' ||
|
||||||
|
wert.art === 'makemkv-update' ||
|
||||||
|
wert.art === 'schluessel-pruefen'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.art === 'fenster-port') return true
|
||||||
|
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
|
||||||
|
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
|
||||||
|
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
|
||||||
|
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
|
||||||
|
// (VCN) denselben Film in 2 statt 60+ Minuten.
|
||||||
|
//
|
||||||
|
// Regeln:
|
||||||
|
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
|
||||||
|
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
|
||||||
|
// Name (die „Invalid preset"-Falle).
|
||||||
|
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
|
||||||
|
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
|
||||||
|
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
|
||||||
|
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
|
||||||
|
// DVDs richtig; hochskaliert wird nichts).
|
||||||
|
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
|
||||||
|
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
|
||||||
|
|
||||||
|
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
||||||
|
|
||||||
|
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
||||||
|
['vce', 'VCN'],
|
||||||
|
['nvenc', 'NVENC'],
|
||||||
|
['qsv', 'QSV'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
||||||
|
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
|
||||||
|
export function presetEmpfehlung(
|
||||||
|
discTyp: string,
|
||||||
|
backends: readonly string[],
|
||||||
|
presets: readonly string[],
|
||||||
|
): string {
|
||||||
|
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
|
||||||
|
for (const [backend, kennung] of FAMILIEN) {
|
||||||
|
if (!backends.includes(backend)) continue
|
||||||
|
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
||||||
|
if (treffer !== undefined) return treffer
|
||||||
|
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
|
||||||
|
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
||||||
|
if (kleiner !== undefined) return kleiner
|
||||||
|
}
|
||||||
|
return DEFAULT_HB_PRESET
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
|
||||||
|
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
|
||||||
|
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
|
||||||
|
// eine Administrator-Abfrage und könnte kein Tray zeigen.
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export function autostartSetzen(an: boolean): void {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: an })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autostartAktiv(): boolean {
|
||||||
|
return app.getLoginItemSettings().openAtLogin
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
|
||||||
|
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
|
||||||
|
// (src/bruecke) und den MessagePort zum Kern.
|
||||||
|
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
export function fensterErstellen(): BrowserWindow {
|
||||||
|
const fenster = new BrowserWindow({
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
minWidth: 720,
|
||||||
|
minHeight: 480,
|
||||||
|
show: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||||
|
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
||||||
|
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
|
||||||
|
backgroundColor: '#171412',
|
||||||
|
title: 'Rippy',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../bruecke/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
||||||
|
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||||
|
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||||
|
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||||
|
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||||
|
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||||
|
if (devUrl !== undefined && devUrl.length > 0) {
|
||||||
|
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
|
||||||
|
} else {
|
||||||
|
void fenster.loadFile(
|
||||||
|
join(__dirname, '../fenster/index.html'),
|
||||||
|
startBereich.length > 0 ? { hash: startBereich } : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fenster
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
|
||||||
|
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
|
||||||
|
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
|
||||||
|
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
|
||||||
|
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
|
||||||
|
//
|
||||||
|
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
|
||||||
|
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
||||||
|
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
||||||
|
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
||||||
|
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
|
||||||
|
import { writeFile } from 'node:fs/promises'
|
||||||
|
import { join, win32 } from 'node:path'
|
||||||
|
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||||
|
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
|
||||||
|
import { autostartAktiv, autostartSetzen } from './autostart'
|
||||||
|
import { fensterErstellen } from './fenster'
|
||||||
|
import { KernVerwaltung } from './kernstart'
|
||||||
|
import { leineSetzen, type LeineStatus } from './leine'
|
||||||
|
import { trayErstellen, trayHinweis } from './tray'
|
||||||
|
import { updateEinrichten, updateStand } from './update'
|
||||||
|
|
||||||
|
const smokeModus = process.argv.includes('--smoke')
|
||||||
|
|
||||||
|
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||||
|
if (smokeModus) {
|
||||||
|
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
||||||
|
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.exit(0)
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): void {
|
||||||
|
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
|
||||||
|
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
|
||||||
|
const leine: LeineStatus = leineSetzen()
|
||||||
|
if (!leine.ok) {
|
||||||
|
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
|
||||||
|
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fenster: BrowserWindow | null = null
|
||||||
|
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
|
||||||
|
let beendenGewollt = smokeModus
|
||||||
|
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
|
||||||
|
// warten dann.
|
||||||
|
const aktiveVorgaenge = new Set<string>()
|
||||||
|
let updateGestartet = false
|
||||||
|
|
||||||
|
const smoke = {
|
||||||
|
fenster: false,
|
||||||
|
kern: false,
|
||||||
|
datenbank: false,
|
||||||
|
leine: leine.ok,
|
||||||
|
pong: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const kernVerwaltung = new KernVerwaltung(
|
||||||
|
join(app.getPath('userData'), 'rippy.db'),
|
||||||
|
(nachricht) => {
|
||||||
|
if (nachricht.art === 'kern-bereit') {
|
||||||
|
kernBereit = nachricht
|
||||||
|
smoke.kern = true
|
||||||
|
smoke.datenbank = nachricht.datenbank.ok
|
||||||
|
if (!nachricht.datenbank.ok) {
|
||||||
|
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
||||||
|
}
|
||||||
|
statusSenden()
|
||||||
|
smokePruefen()
|
||||||
|
} else if (nachricht.art === 'rip-status') {
|
||||||
|
ripStatusVerarbeiten(nachricht.status)
|
||||||
|
} else if (nachricht.art === 'einstellungen') {
|
||||||
|
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
||||||
|
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
||||||
|
if (!updateGestartet && !smokeModus) {
|
||||||
|
updateGestartet = true
|
||||||
|
updateEinrichten(
|
||||||
|
nachricht.werte['updateUrl'] ?? '',
|
||||||
|
() => aktiveVorgaenge.size > 0,
|
||||||
|
(text) => toast('Rippy-Update', text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (nachricht.art === 'installer-starten') {
|
||||||
|
void installerStarten(nachricht.pfad)
|
||||||
|
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
||||||
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||||
|
toast(
|
||||||
|
'Disc erkannt',
|
||||||
|
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
||||||
|
(nachricht.info.schonGerippt !== null
|
||||||
|
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
|
||||||
|
: ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => statusSenden(),
|
||||||
|
smokeModus,
|
||||||
|
)
|
||||||
|
|
||||||
|
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||||
|
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||||
|
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||||
|
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
||||||
|
aktiveVorgaenge.add(status.id)
|
||||||
|
} else {
|
||||||
|
aktiveVorgaenge.delete(status.id)
|
||||||
|
}
|
||||||
|
if (fenster !== null && !fenster.isDestroyed()) {
|
||||||
|
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
|
||||||
|
fenster.setProgressBar(status.prozent / 100)
|
||||||
|
} else if (status.phase === 'fehler') {
|
||||||
|
fenster.setProgressBar(1, { mode: 'error' })
|
||||||
|
} else {
|
||||||
|
fenster.setProgressBar(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.phase === 'rippt') {
|
||||||
|
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||||
|
} else if (status.phase === 'komprimiert') {
|
||||||
|
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||||
|
} else {
|
||||||
|
trayHinweis('')
|
||||||
|
}
|
||||||
|
if (smokeModus) return
|
||||||
|
if (status.phase === 'fertig') {
|
||||||
|
toast('Rippy ist fertig', status.text)
|
||||||
|
} else if (status.phase === 'fehler') {
|
||||||
|
toast('Rippy: Fehler', status.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
|
||||||
|
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
|
||||||
|
// Manifest Administratorrechte an — CreateProcess bricht dann mit
|
||||||
|
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
|
||||||
|
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
|
||||||
|
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
|
||||||
|
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
|
||||||
|
async function installerStarten(pfad: string): Promise<void> {
|
||||||
|
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
|
||||||
|
// Rippys eigenem Downloads-Ordner.
|
||||||
|
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
|
||||||
|
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
|
||||||
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fehler = await shell.openPath(pfad)
|
||||||
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(titel: string, text: string): void {
|
||||||
|
try {
|
||||||
|
if (!Notification.isSupported()) return
|
||||||
|
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
|
||||||
|
meldung.on('click', () => fensterZeigen())
|
||||||
|
meldung.show()
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusSenden(): void {
|
||||||
|
if (fenster === null || fenster.isDestroyed()) return
|
||||||
|
const status: HauptStatus = {
|
||||||
|
version: app.getVersion(),
|
||||||
|
leine,
|
||||||
|
kern: kernVerwaltung.zustand(),
|
||||||
|
autostart: autostartAktiv(),
|
||||||
|
update: updateStand(),
|
||||||
|
}
|
||||||
|
fenster.webContents.send('haupt-status', status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fensterVerdrahten(): void {
|
||||||
|
if (fenster === null) return
|
||||||
|
fenster.webContents.on('did-finish-load', () => {
|
||||||
|
smoke.fenster = true
|
||||||
|
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
|
||||||
|
kernVerwaltung.fensterVerbinden(fenster!.webContents)
|
||||||
|
statusSenden()
|
||||||
|
smokePruefen()
|
||||||
|
})
|
||||||
|
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
||||||
|
fenster.on('close', (ereignis) => {
|
||||||
|
if (beendenGewollt) return
|
||||||
|
ereignis.preventDefault()
|
||||||
|
fenster?.hide()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fensterZeigen(): void {
|
||||||
|
if (fenster === null || fenster.isDestroyed()) {
|
||||||
|
fenster = fensterErstellen()
|
||||||
|
fensterVerdrahten()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fenster.isMinimized()) fenster.restore()
|
||||||
|
fenster.show()
|
||||||
|
fenster.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function beenden(): void {
|
||||||
|
beendenGewollt = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
||||||
|
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
||||||
|
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
||||||
|
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
|
||||||
|
if (art === 'kern-pong') {
|
||||||
|
smoke.pong = true
|
||||||
|
smokePruefen()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
||||||
|
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
||||||
|
autostartSetzen(an === true)
|
||||||
|
statusSenden()
|
||||||
|
return autostartAktiv()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
|
||||||
|
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
|
||||||
|
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
|
||||||
|
if (fenster === null) return null
|
||||||
|
const ergebnis = await dialog.showOpenDialog(fenster, {
|
||||||
|
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
})
|
||||||
|
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
fensterZeigen()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
|
||||||
|
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (beendenGewollt) app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
beendenGewollt = true
|
||||||
|
kernVerwaltung.beenden()
|
||||||
|
})
|
||||||
|
|
||||||
|
void app.whenReady().then(() => {
|
||||||
|
kernVerwaltung.starten()
|
||||||
|
if (!smokeModus) {
|
||||||
|
trayErstellen(fensterZeigen, beenden)
|
||||||
|
}
|
||||||
|
fenster = fensterErstellen()
|
||||||
|
fensterVerdrahten()
|
||||||
|
})
|
||||||
|
|
||||||
|
let smokeAbgeschlossen = false
|
||||||
|
|
||||||
|
function smokePruefen(): void {
|
||||||
|
if (!smokeModus || smokeAbgeschlossen) return
|
||||||
|
const fehlt = Object.entries(smoke)
|
||||||
|
.filter(([, wert]) => !wert)
|
||||||
|
.map(([name]) => name)
|
||||||
|
if (fehlt.length > 0) return
|
||||||
|
smokeAbgeschlossen = true
|
||||||
|
void smokeAbschliessen()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smokeAbschliessen(): Promise<void> {
|
||||||
|
const bildPfad = process.env['RIPPY_SMOKE_BILD']
|
||||||
|
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
|
||||||
|
try {
|
||||||
|
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
||||||
|
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
||||||
|
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
|
||||||
|
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
|
||||||
|
// späte Kern-Antworten warten.
|
||||||
|
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
|
||||||
|
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
|
||||||
|
const bild = await fenster.webContents.capturePage()
|
||||||
|
await writeFile(bildPfad, bild.toPNG())
|
||||||
|
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kernInfo =
|
||||||
|
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
|
||||||
|
console.log(
|
||||||
|
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
|
||||||
|
)
|
||||||
|
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
|
||||||
|
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
|
||||||
|
kernVerwaltung.beenden()
|
||||||
|
setTimeout(() => app.exit(0), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smokeModus) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (smokeAbgeschlossen) return
|
||||||
|
smokeAbgeschlossen = true
|
||||||
|
const fehlt = Object.entries(smoke)
|
||||||
|
.filter(([, wert]) => !wert)
|
||||||
|
.map(([name]) => name)
|
||||||
|
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
|
||||||
|
kernVerwaltung.beenden()
|
||||||
|
setTimeout(() => app.exit(2), 100)
|
||||||
|
}, 25_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
|
||||||
|
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
|
||||||
|
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
|
||||||
|
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
|
||||||
|
import { app, MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export interface KernZustand {
|
||||||
|
laeuft: boolean
|
||||||
|
pid?: number
|
||||||
|
neustarts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
|
||||||
|
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
|
||||||
|
const MAX_NEUSTARTS_JE_MINUTE = 3
|
||||||
|
|
||||||
|
export class KernVerwaltung {
|
||||||
|
private kern: UtilityProcess | null = null
|
||||||
|
private beendenGewollt = false
|
||||||
|
private neustarts = 0
|
||||||
|
private neustartZeiten: number[] = []
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly datenbankPfad: string,
|
||||||
|
private readonly beiNachricht: (nachricht: KernNachricht) => void,
|
||||||
|
private readonly beiZustand: (zustand: KernZustand) => void,
|
||||||
|
private readonly smokeModus = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
starten(): void {
|
||||||
|
const kernDatei = join(__dirname, 'kern.js')
|
||||||
|
const args = [`--datenbank=${this.datenbankPfad}`]
|
||||||
|
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
|
||||||
|
// die Schlüsselkette dann aus.
|
||||||
|
if (this.smokeModus) args.push('--smoke-kern')
|
||||||
|
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
|
||||||
|
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
|
||||||
|
const umgebung: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
if (app.isPackaged) {
|
||||||
|
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
|
||||||
|
}
|
||||||
|
const kern = utilityProcess.fork(kernDatei, args, {
|
||||||
|
serviceName: 'Rippy-Kern',
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: umgebung,
|
||||||
|
})
|
||||||
|
this.kern = kern
|
||||||
|
|
||||||
|
kern.on('message', (nachricht: unknown) => {
|
||||||
|
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
|
||||||
|
})
|
||||||
|
|
||||||
|
kern.on('spawn', () => {
|
||||||
|
this.beiZustand(this.zustand())
|
||||||
|
})
|
||||||
|
|
||||||
|
kern.on('exit', (code) => {
|
||||||
|
this.kern = null
|
||||||
|
if (this.beendenGewollt) return
|
||||||
|
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
|
||||||
|
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
|
||||||
|
const jetzt = Date.now()
|
||||||
|
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
|
||||||
|
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
|
||||||
|
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
|
||||||
|
this.beiZustand(this.zustand())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.neustartZeiten.push(jetzt)
|
||||||
|
this.neustarts += 1
|
||||||
|
this.starten()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
|
||||||
|
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
|
||||||
|
* ins Fenster (§ 4.1).
|
||||||
|
*/
|
||||||
|
fensterVerbinden(webContents: WebContents): void {
|
||||||
|
if (this.kern === null) return
|
||||||
|
const { port1, port2 } = new MessageChannelMain()
|
||||||
|
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||||
|
this.kern.postMessage(nachricht, [port1])
|
||||||
|
webContents.postMessage('kern-port', null, [port2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
|
||||||
|
senden(nachricht: HauptNachricht): void {
|
||||||
|
this.kern?.postMessage(nachricht)
|
||||||
|
}
|
||||||
|
|
||||||
|
zustand(): KernZustand {
|
||||||
|
return {
|
||||||
|
laeuft: this.kern !== null,
|
||||||
|
pid: this.kern?.pid,
|
||||||
|
neustarts: this.neustarts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beenden(): void {
|
||||||
|
this.beendenGewollt = true
|
||||||
|
this.kern?.kill()
|
||||||
|
this.kern = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
|
||||||
|
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
|
||||||
|
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
|
||||||
|
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
|
||||||
|
//
|
||||||
|
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
|
||||||
|
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
|
||||||
|
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
|
||||||
|
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
|
||||||
|
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
|
||||||
|
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
|
||||||
|
//
|
||||||
|
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
|
||||||
|
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
|
||||||
|
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
|
||||||
|
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
|
||||||
|
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
|
||||||
|
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||||
|
import koffi from 'koffi'
|
||||||
|
|
||||||
|
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||||
|
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||||
|
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||||
|
|
||||||
|
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||||
|
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||||
|
// bei Offset 16.
|
||||||
|
const STRUKTUR_GROESSE = 144
|
||||||
|
const LIMIT_FLAGS_OFFSET = 16
|
||||||
|
|
||||||
|
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
let gesetzt: LeineStatus | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
|
||||||
|
* VOR dem Start des Kerns — Kinder erben die Mitgliedschaft nur, wenn sie
|
||||||
|
* nach dem Anlegen gestartet werden.
|
||||||
|
*
|
||||||
|
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
|
||||||
|
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
|
||||||
|
*/
|
||||||
|
export function leineSetzen(): LeineStatus {
|
||||||
|
if (gesetzt !== null) return gesetzt
|
||||||
|
try {
|
||||||
|
const kernel32 = koffi.load('kernel32.dll')
|
||||||
|
const CreateJobObjectW = kernel32.func(
|
||||||
|
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
|
||||||
|
)
|
||||||
|
const SetInformationJobObject = kernel32.func(
|
||||||
|
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
|
||||||
|
)
|
||||||
|
const AssignProcessToJobObject = kernel32.func(
|
||||||
|
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
|
||||||
|
)
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
|
||||||
|
const job = CreateJobObjectW(null, null)
|
||||||
|
if (koffi.address(job) === 0n) {
|
||||||
|
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
|
||||||
|
return gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||||
|
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
|
||||||
|
if (
|
||||||
|
!SetInformationJobObject(
|
||||||
|
job,
|
||||||
|
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
|
||||||
|
info,
|
||||||
|
STRUKTUR_GROESSE,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
|
||||||
|
return gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||||
|
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
|
||||||
|
return gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
|
||||||
|
// Prozess-Tod IST der Mechanismus.
|
||||||
|
gesetzt = { ok: true }
|
||||||
|
return gesetzt
|
||||||
|
} catch (fehler) {
|
||||||
|
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||||
|
return gesetzt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
|
||||||
|
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
|
||||||
|
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
|
||||||
|
// beendet wird über das Kontextmenü oder den Menüpunkt.
|
||||||
|
import { Menu, nativeImage, Tray } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
|
||||||
|
try {
|
||||||
|
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
|
||||||
|
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
|
||||||
|
// per extraResources dorthin).
|
||||||
|
const kandidaten = [
|
||||||
|
join(__dirname, '..', '..', 'bau', 'tray.png'),
|
||||||
|
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
|
||||||
|
]
|
||||||
|
let bild = nativeImage.createEmpty()
|
||||||
|
for (const kandidat of kandidaten) {
|
||||||
|
const versuch = nativeImage.createFromPath(kandidat)
|
||||||
|
if (!versuch.isEmpty()) {
|
||||||
|
bild = versuch
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tray = new Tray(bild)
|
||||||
|
tray.setToolTip('Rippy — bereit')
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ label: 'Rippy öffnen', click: oeffnen },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Beenden', click: beenden },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
tray.on('click', oeffnen)
|
||||||
|
return tray
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
|
||||||
|
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trayHinweis(text: string): void {
|
||||||
|
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trayWeg(): void {
|
||||||
|
tray?.destroy()
|
||||||
|
tray = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
|
||||||
|
// Tests sie ohne Electron laden können.
|
||||||
|
|
||||||
|
export const STANDARD_UPDATE_URL =
|
||||||
|
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
|
||||||
|
|
||||||
|
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1) —
|
||||||
|
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
|
||||||
|
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
|
||||||
|
* akzeptiert. */
|
||||||
|
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
|
||||||
|
const sauber = url.trim()
|
||||||
|
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
|
||||||
|
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
grund:
|
||||||
|
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
|
||||||
|
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
|
||||||
|
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
||||||
|
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
|
||||||
|
// 2. NIE während eines Rips — weder laden noch installieren.
|
||||||
|
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
|
||||||
|
// letzten erfolgreichen Prüfung steht im Status.
|
||||||
|
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
|
||||||
|
import { app } from 'electron'
|
||||||
|
import electronUpdater from 'electron-updater'
|
||||||
|
import { updateUrlPruefen } from './update-regeln'
|
||||||
|
|
||||||
|
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
|
||||||
|
|
||||||
|
const { autoUpdater } = electronUpdater
|
||||||
|
|
||||||
|
export interface UpdateStand {
|
||||||
|
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||||
|
letzteErfolgreichePruefung: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
|
||||||
|
|
||||||
|
export function updateStand(): UpdateStand {
|
||||||
|
return { ...stand }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEinrichten(
|
||||||
|
eigeneUrl: string,
|
||||||
|
ripLaeuft: () => boolean,
|
||||||
|
melden: (text: string) => void,
|
||||||
|
): void {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||||
|
if (!pruefung.ok) {
|
||||||
|
stand.text = pruefung.grund
|
||||||
|
melden(pruefung.grund)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (eigeneUrl.trim().length > 0) {
|
||||||
|
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||||
|
}
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', () => {
|
||||||
|
if (ripLaeuft()) {
|
||||||
|
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||||
|
// tägliche Takt versucht es wieder.
|
||||||
|
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
|
||||||
|
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
|
||||||
|
melden(stand.text)
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
stand.letzteErfolgreichePruefung = new Date().toISOString()
|
||||||
|
stand.text = 'Rippy ist aktuell.'
|
||||||
|
})
|
||||||
|
autoUpdater.on('error', (fehler) => {
|
||||||
|
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
||||||
|
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pruefen = (): void => {
|
||||||
|
if (ripLaeuft()) return
|
||||||
|
void autoUpdater.checkForUpdates().catch(() => {
|
||||||
|
// der error-Handler oben trägt den Grund ein
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pruefen()
|
||||||
|
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
|
||||||
|
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
|
||||||
|
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
|
||||||
|
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
|
||||||
|
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
|
||||||
|
|
||||||
|
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
|
||||||
|
|
||||||
|
export async function bibliothekRefresh(
|
||||||
|
server: string,
|
||||||
|
url: string,
|
||||||
|
apiKey: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<string> {
|
||||||
|
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
|
||||||
|
try {
|
||||||
|
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
|
||||||
|
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Emby-Token': apiKey },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (antwort.status < 300) {
|
||||||
|
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
|
||||||
|
}
|
||||||
|
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
|
||||||
|
}
|
||||||
|
if (server === 'kodi') {
|
||||||
|
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (apiKey.includes(':')) {
|
||||||
|
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
|
||||||
|
}
|
||||||
|
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: kopf,
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
|
||||||
|
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
|
||||||
|
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
|
||||||
|
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
|
||||||
|
|
||||||
|
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
|
||||||
|
|
||||||
|
export interface NfoMeta {
|
||||||
|
typ?: 'film' | 'serie'
|
||||||
|
beschreibung?: string
|
||||||
|
genres?: readonly string[]
|
||||||
|
laufzeitMinuten?: number
|
||||||
|
quelle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function xml(wert: string): string {
|
||||||
|
return wert
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimales NFO (pur, testbar). */
|
||||||
|
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
|
||||||
|
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
|
||||||
|
const zeilen = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
`<${wurzel}>`,
|
||||||
|
` <title>${xml(titel ?? '')}</title>`,
|
||||||
|
]
|
||||||
|
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
|
||||||
|
zeilen.push(` <year>${xml(String(jahr))}</year>`)
|
||||||
|
}
|
||||||
|
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
|
||||||
|
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
|
||||||
|
}
|
||||||
|
for (const genre of meta.genres ?? []) {
|
||||||
|
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
|
||||||
|
}
|
||||||
|
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
||||||
|
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
||||||
|
}
|
||||||
|
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
||||||
|
zeilen.push(`</${wurzel}>`)
|
||||||
|
return zeilen.join('\n') + '\n'
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
|
||||||
|
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
|
||||||
|
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
|
||||||
|
// Meldung sagt was passiert ist).
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
export function posterUrl(posterPfad: string): string {
|
||||||
|
if (posterPfad.length === 0) return ''
|
||||||
|
if (posterPfad.startsWith('http')) return posterPfad
|
||||||
|
return `https://image.tmdb.org/t/p/w500${posterPfad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function posterSpeichern(
|
||||||
|
posterPfad: string,
|
||||||
|
zielDatei: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = posterUrl(posterPfad)
|
||||||
|
if (url.length === 0) return ''
|
||||||
|
try {
|
||||||
|
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
|
||||||
|
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
|
||||||
|
const daten = Buffer.from(await antwort.arrayBuffer())
|
||||||
|
if (daten.length === 0) return 'Poster: leere Antwort'
|
||||||
|
writeFileSync(zielDatei, daten)
|
||||||
|
return 'poster.jpg gespeichert'
|
||||||
|
} catch (fehler) {
|
||||||
|
return `Poster fehlgeschlagen: ${String(fehler)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
|
||||||
|
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
|
||||||
|
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
|
||||||
|
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
|
||||||
|
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
|
||||||
|
// #395 war genau das Ratespiel).
|
||||||
|
import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||||
|
// Plattform-Pfadregeln MIT ABSICHT: Diese Funktionen bauen auf
|
||||||
|
// LAUFZEIT-Wurzeln auf (die Ablage-Einstellung) — zur echten Laufzeit ist
|
||||||
|
// das immer Windows, in der Linux-CI testen sie mit POSIX-Ordnern und
|
||||||
|
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
|
||||||
|
// %ProgramFiles%) bleiben dagegen bei path.win32.
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||||
|
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||||
|
|
||||||
|
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||||
|
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||||
|
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||||
|
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||||
|
if (name.length === 0) return ''
|
||||||
|
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||||
|
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||||
|
name = `${name} (${jahr})`
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||||
|
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
||||||
|
export function zielordner(
|
||||||
|
basis: string,
|
||||||
|
titel: string,
|
||||||
|
jahr: number | string | null,
|
||||||
|
jobId: string,
|
||||||
|
existiert: (p: string) => boolean = existsSync,
|
||||||
|
): string {
|
||||||
|
const name = sichererName(titel, jahr)
|
||||||
|
if (name.length === 0) return join(basis, jobId)
|
||||||
|
const ziel = join(basis, name)
|
||||||
|
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||||
|
export function serienOrdner(basis: string, serie: string, staffel: number): string {
|
||||||
|
const name = sichererName(serie) || 'Unbekannte Serie'
|
||||||
|
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||||
|
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
|
||||||
|
*
|
||||||
|
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in
|
||||||
|
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
|
||||||
|
* passt eine Datei auf mehrere Episoden ähnlich gut → null. */
|
||||||
|
export function matcheEpisoden(
|
||||||
|
dateiSekunden: readonly number[],
|
||||||
|
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
|
||||||
|
toleranzProzent = 8.0,
|
||||||
|
toleranzMinS = 180,
|
||||||
|
): number[] | null {
|
||||||
|
if (dateiSekunden.length === 0 || episoden.length === 0) return null
|
||||||
|
|
||||||
|
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
|
||||||
|
|
||||||
|
if (dateiSekunden.length === episoden.length) {
|
||||||
|
const passt = dateiSekunden.every((dauer, i) => {
|
||||||
|
const epDauer = episoden[i][1]
|
||||||
|
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
|
||||||
|
})
|
||||||
|
if (passt) return episoden.map(([nr]) => nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zuordnung: number[] = []
|
||||||
|
let start = 0
|
||||||
|
for (const dauer of dateiSekunden) {
|
||||||
|
const kandidaten = episoden
|
||||||
|
.slice(start)
|
||||||
|
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
|
||||||
|
if (kandidaten.length === 0) return null
|
||||||
|
let gewaehlt = kandidaten[0]
|
||||||
|
if (kandidaten.length > 1) {
|
||||||
|
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
|
||||||
|
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
|
||||||
|
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
|
||||||
|
gewaehlt = sortiert[0]
|
||||||
|
}
|
||||||
|
zuordnung.push(gewaehlt[0])
|
||||||
|
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
|
||||||
|
}
|
||||||
|
return zuordnung
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||||
|
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
||||||
|
export function episodenUmbenennen(
|
||||||
|
ordner: string,
|
||||||
|
serie: string,
|
||||||
|
staffel: number,
|
||||||
|
zuordnung: readonly number[],
|
||||||
|
fs: {
|
||||||
|
listen: (o: string) => string[]
|
||||||
|
existiert: (p: string) => boolean
|
||||||
|
umbenennen: (von: string, nach: string) => void
|
||||||
|
} = {
|
||||||
|
listen: (o) => readdirSync(o),
|
||||||
|
existiert: existsSync,
|
||||||
|
umbenennen: renameSync,
|
||||||
|
},
|
||||||
|
): string[] {
|
||||||
|
const meldungen: string[] = []
|
||||||
|
const dateien = fs
|
||||||
|
.listen(ordner)
|
||||||
|
.filter((f) => f.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
|
if (dateien.length !== zuordnung.length) {
|
||||||
|
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||||
|
}
|
||||||
|
const serienName = sichererName(serie) || 'Serie'
|
||||||
|
for (let i = 0; i < dateien.length; i++) {
|
||||||
|
const alt = dateien[i]
|
||||||
|
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
|
||||||
|
if (alt === neu) continue
|
||||||
|
const ziel = join(ordner, neu)
|
||||||
|
if (fs.existiert(ziel)) {
|
||||||
|
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.umbenennen(join(ordner, alt), ziel)
|
||||||
|
meldungen.push(`${alt} → ${neu}`)
|
||||||
|
}
|
||||||
|
return meldungen
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage.
|
||||||
|
// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über
|
||||||
|
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
|
||||||
|
// hängen damit an der Prozess-Leine (R6).
|
||||||
|
//
|
||||||
|
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
|
||||||
|
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
|
||||||
|
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||||
|
// Einstellung im UI (W-5) eine Stimme.
|
||||||
|
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
|
import { basename, join } from 'node:path'
|
||||||
|
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||||
|
import { baueNfo } from '../ablage/nfo'
|
||||||
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
|
import { zielordner } from '../ablage/struktur'
|
||||||
|
import { komprimieren } from '../komprimieren/handbrake'
|
||||||
|
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||||
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
|
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
|
||||||
|
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||||
|
import { isoSichern } from '../rip/iso'
|
||||||
|
import { rippen } from '../rip/makemkv'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
interface LaufenderVorgang {
|
||||||
|
abbruch: AbortController
|
||||||
|
status: RipStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineUmgebung {
|
||||||
|
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
|
||||||
|
makemkvcon: () => string
|
||||||
|
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
|
||||||
|
handbrake: () => string
|
||||||
|
/** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */
|
||||||
|
flac: () => string
|
||||||
|
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
||||||
|
audioToc?: (id: string) => Spur[]
|
||||||
|
/** Rohe Audio-Sektoren lesen (W-7). */
|
||||||
|
audioSpurLesen?: (
|
||||||
|
id: string,
|
||||||
|
start: number,
|
||||||
|
sektoren: number,
|
||||||
|
schreiben: (daten: Buffer) => void,
|
||||||
|
fortschritt?: (anteil: number) => void,
|
||||||
|
) => number
|
||||||
|
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||||
|
geraet: (id: string) => GeraeteInfo | null
|
||||||
|
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||||
|
ablage: () => string
|
||||||
|
einstellungen: () => Einstellungen
|
||||||
|
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ —
|
||||||
|
* greift nur, wenn der Nutzer nichts eingestellt hat. */
|
||||||
|
presetEmpfehlung?: (discTyp: string) => string
|
||||||
|
melden: (status: RipStatus) => void
|
||||||
|
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||||
|
zuordnung?: (id: string) => Zuordnung | null
|
||||||
|
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||||
|
fingerprint?: (id: string) => string | null
|
||||||
|
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||||
|
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||||
|
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||||
|
jetzt?: () => Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Pipeline {
|
||||||
|
private laufend = new Map<string, LaufenderVorgang>()
|
||||||
|
|
||||||
|
constructor(private readonly umgebung: PipelineUmgebung) {}
|
||||||
|
|
||||||
|
laeuft(id: string): boolean {
|
||||||
|
return this.laufend.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private melden(status: RipStatus): void {
|
||||||
|
const eintrag = this.laufend.get(status.id)
|
||||||
|
if (eintrag !== undefined) eintrag.status = status
|
||||||
|
this.umgebung.melden(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fehler(id: string, text: string): RipStatus {
|
||||||
|
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet die Kette für die ganze Disc ('all'). */
|
||||||
|
async starten(id: string): Promise<void> {
|
||||||
|
if (this.laufend.has(id)) {
|
||||||
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const programm = this.umgebung.makemkvcon()
|
||||||
|
if (programm.length === 0) {
|
||||||
|
this.umgebung.melden(
|
||||||
|
this.fehler(
|
||||||
|
id,
|
||||||
|
'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = this.umgebung.geraet(id)
|
||||||
|
if (geraet === null) {
|
||||||
|
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
|
||||||
|
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
|
||||||
|
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
|
||||||
|
// nie zu „es geht nicht" werden.
|
||||||
|
if (geraet.status === 'empty') {
|
||||||
|
this.umgebung.melden(
|
||||||
|
this.fehler(
|
||||||
|
id,
|
||||||
|
`Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abbruch = new AbortController()
|
||||||
|
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||||
|
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||||
|
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||||
|
|
||||||
|
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||||
|
if (geraet.typ === 'cd') {
|
||||||
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||||
|
this.melden(this.laufend.get(id)!.status)
|
||||||
|
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (geraet.typ === 'unknown') {
|
||||||
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
||||||
|
this.melden(this.laufend.get(id)!.status)
|
||||||
|
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||||
|
|
||||||
|
const start: RipStatus = {
|
||||||
|
id,
|
||||||
|
phase: 'rippt',
|
||||||
|
prozent: -1,
|
||||||
|
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||||
|
dateien: [],
|
||||||
|
lesefehler: false,
|
||||||
|
}
|
||||||
|
this.laufend.set(id, { abbruch, status: start })
|
||||||
|
this.melden(start)
|
||||||
|
|
||||||
|
const rip = await rippen({
|
||||||
|
programm,
|
||||||
|
geraetepfad: geraet.pfad,
|
||||||
|
zielOrdner: rohOrdner,
|
||||||
|
titel: 'all',
|
||||||
|
abbruch: abbruch.signal,
|
||||||
|
aufFortschritt: (prozent) => {
|
||||||
|
const alt = this.laufend.get(id)?.status
|
||||||
|
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||||
|
},
|
||||||
|
aufMeldung: (_code, text) => {
|
||||||
|
const alt = this.laufend.get(id)?.status
|
||||||
|
if (alt !== undefined) this.melden({ ...alt, text })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rip.status === 'cancelled') {
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (rip.status === 'error') {
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesefehlerHinweis = rip.lesefehler
|
||||||
|
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||||
|
const einstellungen = this.umgebung.einstellungen()
|
||||||
|
const handbrake = this.umgebung.handbrake()
|
||||||
|
const soll = komprimierenFuer(geraet.typ, einstellungen)
|
||||||
|
|
||||||
|
if (!soll || handbrake.length === 0) {
|
||||||
|
const grund = !soll
|
||||||
|
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
|
||||||
|
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'fertig',
|
||||||
|
prozent: 100,
|
||||||
|
text: `Fertig. ${grund}${lesefehlerHinweis}`,
|
||||||
|
dateien: rip.dateien,
|
||||||
|
lesefehler: rip.lesefehler,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
|
||||||
|
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||||
|
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||||
|
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||||
|
const fertigOrdner =
|
||||||
|
zuordnung !== null && zuordnung.titel.length > 0
|
||||||
|
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||||
|
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||||
|
mkdirSync(fertigOrdner, { recursive: true })
|
||||||
|
|
||||||
|
const fertigeDateien: string[] = []
|
||||||
|
for (let i = 0; i < rip.dateien.length; i++) {
|
||||||
|
const eingabe = rip.dateien[i]
|
||||||
|
const ausgabe = join(fertigOrdner, basename(eingabe))
|
||||||
|
const alt = this.laufend.get(id)?.status
|
||||||
|
if (alt !== undefined) {
|
||||||
|
this.melden({
|
||||||
|
...alt,
|
||||||
|
phase: 'komprimiert',
|
||||||
|
prozent: -1,
|
||||||
|
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const encode = await komprimieren({
|
||||||
|
programm: handbrake,
|
||||||
|
eingabe,
|
||||||
|
ausgabe,
|
||||||
|
preset,
|
||||||
|
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||||
|
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||||
|
abbruch: abbruch.signal,
|
||||||
|
aufFortschritt: (prozent) => {
|
||||||
|
const stand = this.laufend.get(id)?.status
|
||||||
|
if (stand !== undefined) {
|
||||||
|
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (encode.status === 'cancelled') {
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'abgebrochen',
|
||||||
|
prozent: -1,
|
||||||
|
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||||
|
dateien: rip.dateien,
|
||||||
|
lesefehler: rip.lesefehler,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (encode.status === 'error') {
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'fehler',
|
||||||
|
prozent: -1,
|
||||||
|
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
|
||||||
|
dateien: rip.dateien,
|
||||||
|
lesefehler: rip.lesefehler,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertigeDateien.push(encode.ausgabe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||||
|
const alt = this.laufend.get(id)?.status
|
||||||
|
if (alt !== undefined) {
|
||||||
|
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
|
||||||
|
writeFileSync(
|
||||||
|
join(fertigOrdner, nfoName),
|
||||||
|
baueNfo(
|
||||||
|
{
|
||||||
|
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
|
||||||
|
beschreibung: zuordnung?.meta.beschreibung,
|
||||||
|
genres: zuordnung?.meta.genres,
|
||||||
|
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||||
|
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||||
|
},
|
||||||
|
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||||
|
zuordnung?.jahr ?? null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||||
|
// wird GESAGT (R4).
|
||||||
|
const stand = this.laufend.get(id)?.status
|
||||||
|
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||||
|
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = String(einstellungen['mediaServer'] ?? '')
|
||||||
|
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||||
|
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||||
|
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||||
|
|
||||||
|
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||||
|
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||||
|
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||||
|
let groesse = 0
|
||||||
|
for (const datei of fertigeDateien) {
|
||||||
|
try {
|
||||||
|
groesse += statSync(datei).size
|
||||||
|
} catch {
|
||||||
|
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
|
||||||
|
// am Eintrag selbst.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.umgebung.bibliothekEintragen({
|
||||||
|
fingerprint,
|
||||||
|
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||||
|
jahr: zuordnung?.jahr ?? null,
|
||||||
|
typ: geraet.typ,
|
||||||
|
groesseBytes: groesse,
|
||||||
|
ablageort: fertigOrdner,
|
||||||
|
datum: startZeit.toISOString(),
|
||||||
|
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||||
|
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'fertig',
|
||||||
|
prozent: 100,
|
||||||
|
text:
|
||||||
|
`Fertig — komprimiert nach ${fertigOrdner}.` +
|
||||||
|
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||||
|
lesefehlerHinweis,
|
||||||
|
dateien: fertigeDateien,
|
||||||
|
lesefehler: rip.lesefehler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
abbrechen(id: string): void {
|
||||||
|
this.laufend.get(id)?.abbruch.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ──
|
||||||
|
private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise<void> {
|
||||||
|
const fertigMachen = (status: RipStatus): void => {
|
||||||
|
this.laufend.delete(id)
|
||||||
|
this.melden(status)
|
||||||
|
}
|
||||||
|
const flac = this.umgebung.flac()
|
||||||
|
if (flac.length === 0) {
|
||||||
|
fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) {
|
||||||
|
fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let spuren: Spur[]
|
||||||
|
try {
|
||||||
|
spuren = this.umgebung.audioToc(id).filter((s) => s.audio)
|
||||||
|
} catch (fehler) {
|
||||||
|
fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (spuren.length === 0) {
|
||||||
|
fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne
|
||||||
|
// Treffer heißen die Dateien „Track 01" — das ist keine Ausrede,
|
||||||
|
// sondern die Wahrheit über eine Disc, die niemand kennt.
|
||||||
|
const kennung = kennungAusSpuren(spuren)
|
||||||
|
const antwort = await abfragen(kennung)
|
||||||
|
const tags = tagsJeSpur(antwort)
|
||||||
|
const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit)
|
||||||
|
const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName)
|
||||||
|
mkdirSync(ziel, { recursive: true })
|
||||||
|
|
||||||
|
const dateien: string[] = []
|
||||||
|
for (let i = 0; i < spuren.length; i++) {
|
||||||
|
if (abbruch.signal.aborted) {
|
||||||
|
fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const spur = spuren[i]
|
||||||
|
const spurTags = tags.get(spur.nr) ?? {}
|
||||||
|
const name = dateiname(spur.nr, spurTags['TITLE'] ?? '')
|
||||||
|
const zielDatei = join(ziel, `${name}.flac`)
|
||||||
|
const stand = this.laufend.get(id)?.status
|
||||||
|
if (stand !== undefined) {
|
||||||
|
this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang
|
||||||
|
// hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht.
|
||||||
|
const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-'))
|
||||||
|
try {
|
||||||
|
const wav = join(arbeit, 'spur.wav')
|
||||||
|
const strom = createWriteStream(wav)
|
||||||
|
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
||||||
|
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
|
||||||
|
const s = this.laufend.get(id)?.status
|
||||||
|
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
||||||
|
})
|
||||||
|
await new Promise<void>((fertig, kaputt) => {
|
||||||
|
strom.end(() => fertig())
|
||||||
|
strom.on('error', kaputt)
|
||||||
|
})
|
||||||
|
const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags))
|
||||||
|
if (flacErgebnis.code !== 0) {
|
||||||
|
fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`))
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
rmSync(arbeit, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
dateien.push(zielDatei)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
|
||||||
|
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||||
|
let groesse = 0
|
||||||
|
for (const datei of dateien) {
|
||||||
|
try {
|
||||||
|
groesse += statSync(datei).size
|
||||||
|
} catch {
|
||||||
|
// Anzeige-Komfort — siehe oben.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.umgebung.bibliothekEintragen({
|
||||||
|
fingerprint,
|
||||||
|
titel: ordnerName,
|
||||||
|
jahr: null,
|
||||||
|
typ: 'cd',
|
||||||
|
groesseBytes: groesse,
|
||||||
|
ablageort: ziel,
|
||||||
|
datum: startZeit.toISOString(),
|
||||||
|
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||||
|
posterPfad: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fertigMachen({
|
||||||
|
id,
|
||||||
|
phase: 'fertig',
|
||||||
|
prozent: 100,
|
||||||
|
text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''),
|
||||||
|
dateien,
|
||||||
|
lesefehler: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ─────────────────
|
||||||
|
private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise<void> {
|
||||||
|
const ziel = join(this.umgebung.ablage(), 'ISO')
|
||||||
|
mkdirSync(ziel, { recursive: true })
|
||||||
|
const zielDatei = join(ziel, `${vorgangsName}.iso`)
|
||||||
|
const ergebnis = await isoSichern(
|
||||||
|
geraet.pfad,
|
||||||
|
zielDatei,
|
||||||
|
geraet.groesseBytes,
|
||||||
|
(prozent) => {
|
||||||
|
const stand = this.laufend.get(id)?.status
|
||||||
|
if (stand !== undefined) this.melden({ ...stand, prozent })
|
||||||
|
},
|
||||||
|
abbruch.signal,
|
||||||
|
)
|
||||||
|
this.laufend.delete(id)
|
||||||
|
if (ergebnis.status === 'success') {
|
||||||
|
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||||
|
if (fingerprint !== null) {
|
||||||
|
this.umgebung.bibliothekEintragen?.({
|
||||||
|
fingerprint,
|
||||||
|
titel: vorgangsName,
|
||||||
|
jahr: null,
|
||||||
|
typ: 'iso',
|
||||||
|
groesseBytes: ergebnis.bytes,
|
||||||
|
ablageort: zielDatei,
|
||||||
|
datum: startZeit.toISOString(),
|
||||||
|
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||||
|
posterPfad: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false })
|
||||||
|
} else if (ergebnis.status === 'cancelled') {
|
||||||
|
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||||
|
} else {
|
||||||
|
this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||||
|
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
|
||||||
|
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
|
||||||
|
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
|
||||||
|
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
|
||||||
|
copyFileSync(eingabe, ausgabe)
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
|
||||||
|
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
|
||||||
|
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
||||||
|
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
||||||
|
//
|
||||||
|
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
||||||
|
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
||||||
|
// Beweis beweise/sqlite-main.js.
|
||||||
|
import type { MessagePortMain } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
istFensterNachricht,
|
||||||
|
istHauptNachricht,
|
||||||
|
type DbStatus,
|
||||||
|
type KernNachricht,
|
||||||
|
type MetaVorschlag,
|
||||||
|
} from '../gemeinsam/nachrichten'
|
||||||
|
import { Pipeline } from './ablauf/pipeline'
|
||||||
|
import type { LaufwerkApi } from './laufwerk/codes'
|
||||||
|
import {
|
||||||
|
audioSpurLesen,
|
||||||
|
audioToc,
|
||||||
|
auswerfenMitGrund,
|
||||||
|
geraeteInfo,
|
||||||
|
kennung,
|
||||||
|
listOptischeLaufwerke,
|
||||||
|
} from './laufwerk/disc'
|
||||||
|
import { DiscWache } from './laufwerk/wache'
|
||||||
|
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||||
|
import { OmdbClient } from './metadaten/omdb'
|
||||||
|
import { TmdbClient } from './metadaten/tmdb'
|
||||||
|
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||||
|
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
|
import { Datenbank } from './speicher/db'
|
||||||
|
import { finden } from './werkzeuge/katalog'
|
||||||
|
|
||||||
|
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||||
|
// nicht — er ist ein reiner Node-Prozess).
|
||||||
|
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg.startsWith('--datenbank=')) {
|
||||||
|
const pfad = arg.slice('--datenbank='.length)
|
||||||
|
if (pfad.length > 0) return pfad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: Datenbank | null = null
|
||||||
|
let dbStatus: DbStatus
|
||||||
|
|
||||||
|
const pfad = datenbankPfadAusArgv(process.argv)
|
||||||
|
if (pfad === null) {
|
||||||
|
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
db = new Datenbank(pfad)
|
||||||
|
// Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis.
|
||||||
|
db.einstellungSchreiben('letzter_start', new Date().toISOString())
|
||||||
|
if (db.einstellungLesen('letzter_start') === null) {
|
||||||
|
dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' }
|
||||||
|
} else {
|
||||||
|
dbStatus = { ok: true, pfad }
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt.
|
||||||
|
// Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu
|
||||||
|
// »es geht nicht« werden (§ 6.2, sinngemäß).
|
||||||
|
dbStatus = { ok: false, fehler: String(fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fensterPort: MessagePortMain | null = null
|
||||||
|
|
||||||
|
function anFenster(nachricht: KernNachricht): void {
|
||||||
|
fensterPort?.postMessage(nachricht)
|
||||||
|
}
|
||||||
|
|
||||||
|
function anHauptUndFenster(nachricht: KernNachricht): void {
|
||||||
|
process.parentPort.postMessage(nachricht)
|
||||||
|
anFenster(nachricht)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
||||||
|
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||||
|
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||||
|
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||||
|
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||||
|
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||||
|
let laufwerkApi: LaufwerkApi | null = null
|
||||||
|
let laufwerkFehler: string | null = null
|
||||||
|
let wache: DiscWache | null = null
|
||||||
|
|
||||||
|
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||||
|
const discZuordnungen = new Map<string, Zuordnung>()
|
||||||
|
const discFingerprints = new Map<string, string>()
|
||||||
|
|
||||||
|
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const gefunden = discTitel(geraetepfad)
|
||||||
|
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||||
|
discFingerprints.set(id, fingerprint)
|
||||||
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
|
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||||
|
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||||
|
discZuordnungen.set(id, zuordnung)
|
||||||
|
anHauptUndFenster({
|
||||||
|
art: 'disc-info',
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
titel: zuordnung.titel,
|
||||||
|
jahr: zuordnung.jahr,
|
||||||
|
confidence: zuordnung.confidence,
|
||||||
|
quelle: zuordnung.meta.quelle,
|
||||||
|
posterPfad: zuordnung.meta.posterPfad,
|
||||||
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
|
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||||
|
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||||
|
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||||
|
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||||
|
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||||
|
function jahrAus(datum: string | undefined): number | null {
|
||||||
|
if (datum === undefined || datum.length < 4) return null
|
||||||
|
const jahr = Number(datum.slice(0, 4))
|
||||||
|
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function metadatenSuchen(id: string, text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
|
if (!tmdb.verfuegbar) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
|
||||||
|
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||||
|
const vorschlaege: MetaVorschlag[] = [
|
||||||
|
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||||
|
typ: 'movie' as const,
|
||||||
|
tmdbId: f.id,
|
||||||
|
titel: f.title ?? '',
|
||||||
|
jahr: jahrAus(f.release_date),
|
||||||
|
posterPfad: f.poster_path ?? '',
|
||||||
|
})),
|
||||||
|
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||||
|
typ: 'tv' as const,
|
||||||
|
tmdbId: s.id,
|
||||||
|
titel: s.name ?? '',
|
||||||
|
jahr: jahrAus(s.first_air_date),
|
||||||
|
posterPfad: s.poster_path ?? '',
|
||||||
|
})),
|
||||||
|
].filter((v) => v.titel.length > 0)
|
||||||
|
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
|
||||||
|
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
|
const details =
|
||||||
|
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||||
|
const zuordnung: Zuordnung = {
|
||||||
|
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||||
|
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||||
|
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||||
|
confidence: 1,
|
||||||
|
meta: {
|
||||||
|
typ: vorschlag.typ,
|
||||||
|
id: vorschlag.tmdbId,
|
||||||
|
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||||
|
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||||
|
beschreibung: details?.overview ?? '',
|
||||||
|
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||||
|
backdropPfad: details?.backdrop_path ?? '',
|
||||||
|
laufzeitMinuten: details?.runtime ?? 0,
|
||||||
|
genres: (details?.genres ?? []).map((g) => g.name),
|
||||||
|
quelle: 'tmdb',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
discZuordnungen.set(id, zuordnung)
|
||||||
|
anHauptUndFenster({
|
||||||
|
art: 'disc-info',
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
titel: zuordnung.titel,
|
||||||
|
jahr: zuordnung.jahr,
|
||||||
|
confidence: 1,
|
||||||
|
quelle: 'deine Wahl',
|
||||||
|
posterPfad: zuordnung.meta.posterPfad,
|
||||||
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
|
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const laufwerkeBereit: Promise<void> = (async () => {
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||||
|
}
|
||||||
|
const { win32Api } = await import('./laufwerk/win32')
|
||||||
|
laufwerkApi = win32Api()
|
||||||
|
wache = new DiscWache(
|
||||||
|
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||||
|
(meldung) => {
|
||||||
|
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||||
|
for (const ereignis of meldung.ereignisse) {
|
||||||
|
if (ereignis.art === 'disc-eingelegt') {
|
||||||
|
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
|
||||||
|
} else if (ereignis.art === 'disc-entnommen') {
|
||||||
|
discZuordnungen.delete(ereignis.geraet.id)
|
||||||
|
discFingerprints.delete(ereignis.geraet.id)
|
||||||
|
anHauptUndFenster({
|
||||||
|
art: 'disc-info',
|
||||||
|
info: {
|
||||||
|
id: ereignis.geraet.id,
|
||||||
|
titel: '',
|
||||||
|
jahr: null,
|
||||||
|
confidence: 0,
|
||||||
|
quelle: '',
|
||||||
|
posterPfad: '',
|
||||||
|
backdropPfad: '',
|
||||||
|
beschreibung: '',
|
||||||
|
schonGerippt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(fehler) => {
|
||||||
|
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||||
|
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||||
|
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
wache.starten()
|
||||||
|
} catch (fehler) {
|
||||||
|
laufwerkFehler = String(fehler)
|
||||||
|
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||||
|
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||||
|
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
||||||
|
function ablageOrdner(): string {
|
||||||
|
const eingestellt = db?.einstellungLesen('ablage')
|
||||||
|
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||||
|
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
|
||||||
|
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
|
||||||
|
const PIPELINE_EINSTELLUNGEN = [
|
||||||
|
'transcodeEnabled',
|
||||||
|
'transcodePreset',
|
||||||
|
'transcodePresetDvd',
|
||||||
|
'transcodePresetBluray',
|
||||||
|
'transcodePresetUhd',
|
||||||
|
'audioSprachen',
|
||||||
|
'untertitelSprachen',
|
||||||
|
'mediaServer',
|
||||||
|
'mediaServerUrl',
|
||||||
|
'mediaServerApiKey',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function einstellungenLaden(): Record<string, string> {
|
||||||
|
const werte: Record<string, string> = {}
|
||||||
|
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||||
|
const wert = db?.einstellungLesen(schluessel)
|
||||||
|
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||||
|
}
|
||||||
|
return werte
|
||||||
|
}
|
||||||
|
|
||||||
|
function geraetVon(id: string): string | null {
|
||||||
|
if (laufwerkApi === null) return null
|
||||||
|
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ripVerwaltung = new Pipeline({
|
||||||
|
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||||
|
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||||
|
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
|
||||||
|
audioToc: (id) => {
|
||||||
|
const geraet = geraetVon(id)
|
||||||
|
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||||
|
return audioToc(laufwerkApi, geraet)
|
||||||
|
},
|
||||||
|
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
|
||||||
|
const geraet = geraetVon(id)
|
||||||
|
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||||
|
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
||||||
|
},
|
||||||
|
geraet: (id) => {
|
||||||
|
if (laufwerkApi === null) return null
|
||||||
|
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
||||||
|
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
||||||
|
},
|
||||||
|
ablage: ablageOrdner,
|
||||||
|
einstellungen: einstellungenLaden,
|
||||||
|
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||||
|
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||||
|
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||||
|
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||||
|
bibliothekEintragen: (eintrag) => {
|
||||||
|
try {
|
||||||
|
db?.bibliothekEintragen(eintrag)
|
||||||
|
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
||||||
|
// gesagt.
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
||||||
|
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
||||||
|
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||||
|
'ablage',
|
||||||
|
'transcodeEnabled',
|
||||||
|
'transcodePreset',
|
||||||
|
'transcodePresetDvd',
|
||||||
|
'transcodePresetBluray',
|
||||||
|
'transcodePresetUhd',
|
||||||
|
'audioSprachen',
|
||||||
|
'untertitelSprachen',
|
||||||
|
'mediaServer',
|
||||||
|
'mediaServerUrl',
|
||||||
|
'mediaServerApiKey',
|
||||||
|
'tmdbApiKey',
|
||||||
|
'omdbApiKey',
|
||||||
|
'makemkvAppKey',
|
||||||
|
'updateUrl',
|
||||||
|
'setup.done',
|
||||||
|
'autoAuswurf',
|
||||||
|
'werkzeug.makemkv',
|
||||||
|
'werkzeug.handbrake',
|
||||||
|
'werkzeug.flac',
|
||||||
|
])
|
||||||
|
|
||||||
|
function einstellungenSenden(): void {
|
||||||
|
const werte: Record<string, string> = { ablage: ablageOrdner() }
|
||||||
|
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||||
|
const wert = db?.einstellungLesen(schluessel)
|
||||||
|
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||||
|
}
|
||||||
|
// AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine
|
||||||
|
// Einstellungs-Quelle bleibt trotzdem der Kern.
|
||||||
|
anHauptUndFenster({ art: 'einstellungen', werte })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||||
|
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
|
||||||
|
|
||||||
|
async function schluesselLauf(): Promise<void> {
|
||||||
|
if (process.platform !== 'win32') return
|
||||||
|
try {
|
||||||
|
const { schluesselKette } = await import('./werkzeuge/schluessel')
|
||||||
|
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||||
|
schluesselStand = await schluesselKette(
|
||||||
|
makemkvcon,
|
||||||
|
db?.einstellungLesen('makemkvAppKey') ?? '',
|
||||||
|
new Date().toISOString(),
|
||||||
|
)
|
||||||
|
anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
|
||||||
|
console.error(`[kern] Schlüsselkette: ${String(fehler)}`)
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||||
|
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||||
|
|
||||||
|
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||||
|
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||||
|
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||||
|
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||||
|
let beschaffungLaeuft = false
|
||||||
|
let installerAntwort: ((fehler: string | null) => void) | null = null
|
||||||
|
|
||||||
|
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||||
|
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makemkvUpdateLauf(): Promise<void> {
|
||||||
|
if (beschaffungLaeuft) {
|
||||||
|
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beschaffungLaeuft = true
|
||||||
|
try {
|
||||||
|
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||||
|
'./werkzeuge/beschaffen'
|
||||||
|
)
|
||||||
|
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||||
|
const neueste = await makemkvNeuesteVersion()
|
||||||
|
if (neueste.length === 0) {
|
||||||
|
beschaffungMelden(
|
||||||
|
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||||
|
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const installiert = schluesselStand?.version ?? ''
|
||||||
|
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||||
|
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||||
|
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||||
|
// Download derselben Fassung würde nichts lösen.
|
||||||
|
beschaffungMelden(
|
||||||
|
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||||
|
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||||
|
'bis dahin rippt der Beta-Modus weiter.',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||||
|
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||||
|
// erstmals eine).
|
||||||
|
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||||
|
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||||
|
|
||||||
|
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
|
||||||
|
beschaffungMelden(text, anteil, true),
|
||||||
|
)
|
||||||
|
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||||
|
|
||||||
|
const startFehler = await new Promise<string | null>((fertig) => {
|
||||||
|
installerAntwort = fertig
|
||||||
|
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||||
|
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||||
|
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||||
|
})
|
||||||
|
installerAntwort = null
|
||||||
|
if (startFehler !== null && startFehler.length > 0) {
|
||||||
|
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beschaffungMelden(
|
||||||
|
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||||
|
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||||
|
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||||
|
let runden = 0
|
||||||
|
const zeiger = setInterval(() => {
|
||||||
|
runden += 1
|
||||||
|
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||||
|
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||||
|
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||||
|
clearInterval(zeiger)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||||
|
void schluesselLauf().then(() => werkzeugeSenden())
|
||||||
|
} else if (runden >= 80) {
|
||||||
|
clearInterval(zeiger)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
beschaffungMelden(
|
||||||
|
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
zeiger.unref()
|
||||||
|
} catch (fehler) {
|
||||||
|
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||||
|
beschaffungLaeuft = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||||
|
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||||
|
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||||
|
|
||||||
|
async function werkzeugeSenden(): Promise<void> {
|
||||||
|
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||||
|
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||||
|
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
|
||||||
|
const { encoderMessen } = await import('./komprimieren/encoder')
|
||||||
|
const gemessen = await encoderMessen(handbrake)
|
||||||
|
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||||
|
anFenster({
|
||||||
|
art: 'werkzeuge',
|
||||||
|
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auswerfen(id: string): Promise<void> {
|
||||||
|
await laufwerkeBereit
|
||||||
|
if (ripVerwaltung.laeuft(id)) {
|
||||||
|
anFenster({
|
||||||
|
art: 'auswurf-ergebnis',
|
||||||
|
id,
|
||||||
|
grund: 'abgelehnt',
|
||||||
|
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (laufwerkApi === null) {
|
||||||
|
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const geraete = listOptischeLaufwerke(laufwerkApi)
|
||||||
|
const geraet = geraete.find((g) => kennung(g) === id)
|
||||||
|
if (geraet === undefined) {
|
||||||
|
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
|
||||||
|
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||||
|
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||||
|
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||||
|
wache?.runde()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.parentPort.on('message', (ereignis) => {
|
||||||
|
const nachricht: unknown = ereignis.data
|
||||||
|
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
|
||||||
|
installerAntwort?.(nachricht.fehler ?? '')
|
||||||
|
installerAntwort = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||||
|
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
||||||
|
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
||||||
|
fensterPort?.close()
|
||||||
|
fensterPort = ereignis.ports[0] ?? null
|
||||||
|
if (fensterPort === null) return
|
||||||
|
fensterPort.on('message', (portEreignis) => {
|
||||||
|
const frage: unknown = portEreignis.data
|
||||||
|
if (!istFensterNachricht(frage)) return
|
||||||
|
if (frage.art === 'ping') {
|
||||||
|
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||||
|
} else if (frage.art === 'auswerfen') {
|
||||||
|
void auswerfen(frage.id)
|
||||||
|
} else if (frage.art === 'rip-start') {
|
||||||
|
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||||
|
} else if (frage.art === 'rip-abbruch') {
|
||||||
|
ripVerwaltung.abbrechen(frage.id)
|
||||||
|
} else if (frage.art === 'metadaten-suchen') {
|
||||||
|
void metadatenSuchen(frage.id, frage.text)
|
||||||
|
} else if (frage.art === 'zuordnung-setzen') {
|
||||||
|
void zuordnungSetzen(frage.id, frage.vorschlag)
|
||||||
|
} else if (frage.art === 'einstellung-setzen') {
|
||||||
|
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
|
||||||
|
} else if (db === null) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
|
||||||
|
} else {
|
||||||
|
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
||||||
|
einstellungenSenden()
|
||||||
|
}
|
||||||
|
} else if (frage.art === 'einstellungen-laden') {
|
||||||
|
einstellungenSenden()
|
||||||
|
} else if (frage.art === 'werkzeuge-laden') {
|
||||||
|
void werkzeugeSenden()
|
||||||
|
} else if (frage.art === 'bibliothek-laden') {
|
||||||
|
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||||
|
} else if (frage.art === 'makemkv-update') {
|
||||||
|
void makemkvUpdateLauf()
|
||||||
|
} else if (frage.art === 'schluessel-pruefen') {
|
||||||
|
void schluesselLauf().then(() => werkzeugeSenden())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fensterPort.start()
|
||||||
|
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
|
||||||
|
anFenster(bereitNachricht())
|
||||||
|
void laufwerkeBereit.then(() => {
|
||||||
|
if (laufwerkFehler !== null) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
|
||||||
|
}
|
||||||
|
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
|
||||||
|
if (schluesselStand !== null) {
|
||||||
|
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function bereitNachricht(): KernNachricht {
|
||||||
|
return {
|
||||||
|
art: 'kern-bereit',
|
||||||
|
pid: process.pid,
|
||||||
|
nodeVersion: process.versions.node,
|
||||||
|
datenbank: dbStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.parentPort.postMessage(bereitNachricht())
|
||||||
|
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
|
||||||
|
einstellungenSenden()
|
||||||
|
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
||||||
|
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||||
|
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||||
|
void schluesselLauf()
|
||||||
|
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
||||||
|
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
||||||
|
void werkzeugeSenden()
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
|
||||||
|
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
|
||||||
|
//
|
||||||
|
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
|
||||||
|
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
|
||||||
|
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
|
||||||
|
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
||||||
|
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
||||||
|
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
|
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
|
||||||
|
|
||||||
|
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
|
||||||
|
export function parseEncoderListe(hilfeText: string): string[] {
|
||||||
|
const encoder: string[] = []
|
||||||
|
let imBlock = false
|
||||||
|
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
|
||||||
|
const inhalt = zeile.trim()
|
||||||
|
if (!imBlock) {
|
||||||
|
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inhalt.length === 0) continue
|
||||||
|
if (inhalt.startsWith('-')) break
|
||||||
|
encoder.push(inhalt.split(/\s+/)[0])
|
||||||
|
}
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
|
||||||
|
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['nvenc', 'nvenc'],
|
||||||
|
['qsv', 'qsv'],
|
||||||
|
['vce', 'vce'],
|
||||||
|
['vaapi', 'vaapi'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
|
||||||
|
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
|
||||||
|
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
|
||||||
|
const gefunden: string[] = []
|
||||||
|
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
|
||||||
|
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
|
||||||
|
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
|
||||||
|
for (const [kennzeichen, backend] of HW_FAMILIEN) {
|
||||||
|
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
|
||||||
|
if (treffer.length === 0) continue
|
||||||
|
gefunden.push(backend)
|
||||||
|
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
|
||||||
|
}
|
||||||
|
return gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
|
||||||
|
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
|
||||||
|
export function parsePresetListe(text: string): string[] {
|
||||||
|
const presets: string[] = []
|
||||||
|
for (const zeile of (text ?? '').split(/\r?\n/)) {
|
||||||
|
if (zeile.trim().length === 0) continue
|
||||||
|
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
|
||||||
|
if (einrueckung === 4) presets.push(zeile.trim())
|
||||||
|
}
|
||||||
|
return presets
|
||||||
|
}
|
||||||
|
|
||||||
|
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
|
kind.on('error', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig('')
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig(textVon(Buffer.concat(stuecke)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncoderAuskunft {
|
||||||
|
encoder: string[]
|
||||||
|
backends: string[]
|
||||||
|
presets: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
|
||||||
|
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
|
||||||
|
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
|
||||||
|
const [hilfe, presetListe] = await Promise.all([
|
||||||
|
lauf(programm, ['--help'], 30_000),
|
||||||
|
lauf(programm, ['--preset-list'], 30_000),
|
||||||
|
])
|
||||||
|
const encoder = parseEncoderListe(hilfe)
|
||||||
|
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// HandBrakeCLI ansprechen — Kommandobau, Lauf und die Auswertung mit ALLEN
|
||||||
|
// bezahlten Fallen aus dem Docker-Zweig (ripping.py, jeweils am echten
|
||||||
|
// HandBrake gemessen):
|
||||||
|
//
|
||||||
|
// - `--aencoder`, NICHT `--audio-codec`: Den zweiten Schalter gibt es
|
||||||
|
// nicht, und ein unbekannter Schalter ist für HandBrake KEIN Fehler —
|
||||||
|
// Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip (30.08.2026).
|
||||||
|
// - `--format` aus der Endung erzwingen: Der Container kommt sonst aus
|
||||||
|
// dem PRESET — ein MP4-Preset schreibt title.mp4 neben das verlangte
|
||||||
|
// .mkv und meldet Erfolg (29.08.2026).
|
||||||
|
// - Fortschritt NUR aus der `Encoding: task N of M, x %`-Zeile — der
|
||||||
|
// Scan-Durchlauf läuft vorher bis 100 % und ließ die Anzeige bei 99 %
|
||||||
|
// kleben, während der Encode bei 1 % war (25.07.2026).
|
||||||
|
// - Die letzten 12 Ausgabezeilen aufheben: Bei Rückgabewert 0 sind sie
|
||||||
|
// im Fehlerfall die einzige Auskunft.
|
||||||
|
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||||
|
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
import { FORMATE } from './presets'
|
||||||
|
|
||||||
|
const { basename, dirname, extname, join } = pfad
|
||||||
|
|
||||||
|
export interface HandbrakeArgsOptionen {
|
||||||
|
eingabe: string
|
||||||
|
ausgabe: string
|
||||||
|
preset: string
|
||||||
|
audioSprachen?: readonly string[]
|
||||||
|
untertitelSprachen?: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||||
|
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||||
|
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||||
|
if (format !== undefined) args.push('--format', format)
|
||||||
|
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
||||||
|
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
||||||
|
args.push('--first-audio')
|
||||||
|
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
|
||||||
|
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
||||||
|
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
|
||||||
|
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||||
|
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||||
|
args.push('--all-subtitles')
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatstrings aus dem HandBrakeCLI-Binary gelesen (Regel D):
|
||||||
|
// Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
|
||||||
|
// Encoding: task %d of %d, %.2f %% → Encode
|
||||||
|
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||||
|
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
|
||||||
|
|
||||||
|
/** Encode-Fortschritt 0–100 oder -1 („keine Angabe" — ein echtes 0 % muss
|
||||||
|
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
|
||||||
|
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
|
||||||
|
export function progressAusZeile(zeile: string): number {
|
||||||
|
const treffer = HB_ENCODE.exec(zeile.trim())
|
||||||
|
if (treffer === null) return -1
|
||||||
|
const aktuell = Number(treffer[1])
|
||||||
|
const gesamt = Number(treffer[2])
|
||||||
|
const prozent = Number(treffer[3])
|
||||||
|
if (gesamt < 1 || aktuell < 1) return -1
|
||||||
|
return Math.floor(((aktuell - 1) * 100 + prozent) / gesamt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Invalid preset X" → X (Wortlaut aus dem echten Lauf, 26.07.2026). */
|
||||||
|
export function unbekanntesPreset(zeile: string): string {
|
||||||
|
const text = (zeile ?? '').trim()
|
||||||
|
const kopf = 'Invalid preset '
|
||||||
|
return text.startsWith(kopf) ? text.slice(kopf.length).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „unknown option (--x)" → --x. Rückgabewert bleibt dabei 0! */
|
||||||
|
export function unbekannterSchalter(zeile: string): string {
|
||||||
|
const treffer = /unknown option \(([^)]*)\)/.exec(zeile ?? '')
|
||||||
|
return treffer === null ? '' : treffer[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HB_ZEILEN_PUFFER = 12
|
||||||
|
export const HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
/** HandBrakes letzte Worte als Anhang für eine Fehlermeldung (pur). */
|
||||||
|
export function hbSchluss(zeilen: readonly string[]): string {
|
||||||
|
const sauber = zeilen.map((z) => z.trim()).filter((z) => z.length > 0)
|
||||||
|
if (sauber.length === 0) return ''
|
||||||
|
return ' — HandBrake sagte zuletzt: ' + sauber.slice(-HB_ZEILEN_MELDUNG).join(' | ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dieselbe Datei mit anderer Endung im selben Ordner — oder ''. */
|
||||||
|
export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean = existsSync): string {
|
||||||
|
const ordner = dirname(erwartet)
|
||||||
|
const stamm = basename(erwartet, extname(erwartet))
|
||||||
|
for (const endung of Object.keys(FORMATE)) {
|
||||||
|
const kandidat = join(ordner, stamm + endung)
|
||||||
|
if (kandidat !== erwartet && existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
|
||||||
|
export class HbAuswertung {
|
||||||
|
prozent = -1
|
||||||
|
falschesPreset = ''
|
||||||
|
falscherSchalter = ''
|
||||||
|
letzteZeilen: string[] = []
|
||||||
|
|
||||||
|
verarbeite(zeile: string): { prozent?: number } {
|
||||||
|
if (zeile.trim().length > 0) {
|
||||||
|
this.letzteZeilen.push(zeile)
|
||||||
|
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
|
||||||
|
}
|
||||||
|
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
|
||||||
|
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
|
||||||
|
const p = progressAusZeile(zeile)
|
||||||
|
if (p >= 0 && p !== this.prozent) {
|
||||||
|
this.prozent = p
|
||||||
|
return { prozent: p }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Urteil NACH Prozessende — in der bewiesenen Reihenfolge:
|
||||||
|
* falscher Schalter (kommt mit Code 0!) → Erfolg → Datei daneben →
|
||||||
|
* Erfolg ohne Datei → falsches Preset → Code mit letzten Worten. */
|
||||||
|
urteil(
|
||||||
|
returncode: number | null,
|
||||||
|
ausgabe: string,
|
||||||
|
existiert: (p: string) => boolean = existsSync,
|
||||||
|
): { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'error'; fehler: string } {
|
||||||
|
if (this.falscherSchalter.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler:
|
||||||
|
`Rippy hat HandBrake den Schalter „${this.falscherSchalter}" übergeben, den diese ` +
|
||||||
|
'HandBrake-Fassung nicht kennt. Das ist ein Fehler in Rippy, keine Einstellung — bitte melden.' +
|
||||||
|
hbSchluss(this.letzteZeilen),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (returncode === 0 && existiert(ausgabe)) {
|
||||||
|
return { status: 'success', ausgabe }
|
||||||
|
}
|
||||||
|
if (returncode === 0) {
|
||||||
|
const daneben = dateiDaneben(ausgabe, existiert)
|
||||||
|
if (daneben.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
ausgabe: daneben,
|
||||||
|
hinweis: `HandBrake hat ${basename(daneben)} geschrieben statt ${basename(ausgabe)} (der Container kommt aus dem Preset).`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler:
|
||||||
|
`HandBrake meldet Erfolg, aber es ist keine Datei entstanden (Zielordner: ${dirname(ausgabe)}).` +
|
||||||
|
hbSchluss(this.letzteZeilen),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.falschesPreset.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler:
|
||||||
|
`Dieses HandBrake kennt das Preset „${this.falschesPreset}" nicht. Preset-Namen unterscheiden ` +
|
||||||
|
'sich zwischen HandBrake-Versionen — in den Einstellungen eines aus der Liste wählen, die von ' +
|
||||||
|
'diesem HandBrake selbst kommt.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler: `HandBrake endete mit Code ${returncode ?? '?'}` + hbSchluss(this.letzteZeilen),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HandBrake-Ausgabe in Zeilen: CR UND LF trennen (HandBrake schreibt den
|
||||||
|
* Fortschritt mit \r). Je Zeile einzeln dekodiert — HandBrake schreibt
|
||||||
|
* denselben Pfad mal UTF-8, mal CP850 in einen Strom; ein `ü` (CP850
|
||||||
|
* 0x81) tötete so die Python-Fassung. textVon ersetzt statt zu sterben. */
|
||||||
|
export class HbZeilenLeser {
|
||||||
|
private rest: Buffer = Buffer.alloc(0)
|
||||||
|
|
||||||
|
hinzu(stueck: Buffer): string[] {
|
||||||
|
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
|
||||||
|
const zeilen: string[] = []
|
||||||
|
for (;;) {
|
||||||
|
let trenner = -1
|
||||||
|
for (let i = 0; i < this.rest.length; i++) {
|
||||||
|
if (this.rest[i] === 0x0a || this.rest[i] === 0x0d) {
|
||||||
|
trenner = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trenner < 0) break
|
||||||
|
if (trenner > 0) zeilen.push(textVon(this.rest.subarray(0, trenner)))
|
||||||
|
// \r\n als EIN Trenner behandeln.
|
||||||
|
const naechstes = trenner + 1
|
||||||
|
const doppelt =
|
||||||
|
this.rest[trenner] === 0x0d && naechstes < this.rest.length && this.rest[naechstes] === 0x0a
|
||||||
|
this.rest = this.rest.subarray(doppelt ? naechstes + 1 : naechstes)
|
||||||
|
}
|
||||||
|
return zeilen
|
||||||
|
}
|
||||||
|
|
||||||
|
schluss(): string[] {
|
||||||
|
if (this.rest.length === 0) return []
|
||||||
|
const letzte = textVon(this.rest)
|
||||||
|
this.rest = Buffer.alloc(0)
|
||||||
|
return letzte.trim().length > 0 ? [letzte] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HbErgebnis =
|
||||||
|
| { status: 'success'; ausgabe: string; hinweis?: string }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string }
|
||||||
|
|
||||||
|
export interface HbLaufOptionen {
|
||||||
|
programm: string
|
||||||
|
eingabe: string
|
||||||
|
ausgabe: string
|
||||||
|
preset: string
|
||||||
|
audioSprachen?: readonly string[]
|
||||||
|
untertitelSprachen?: readonly string[]
|
||||||
|
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||||
|
extraArgs?: readonly string[]
|
||||||
|
aufFortschritt?: (prozent: number) => void
|
||||||
|
abbruch?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
const args = buildHandbrakeArgs(optionen)
|
||||||
|
args.push(...(optionen.extraArgs ?? []))
|
||||||
|
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
|
||||||
|
const auswertung = new HbAuswertung()
|
||||||
|
const leser = new HbZeilenLeser()
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
const abbrechen = (): void => {
|
||||||
|
abgebrochen = true
|
||||||
|
kind.kill()
|
||||||
|
}
|
||||||
|
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||||
|
|
||||||
|
const zeile = (z: string): void => {
|
||||||
|
const ergebnis = auswertung.verarbeite(z)
|
||||||
|
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||||
|
}
|
||||||
|
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
|
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
|
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
fertig({ status: 'error', fehler: String(fehler) })
|
||||||
|
})
|
||||||
|
|
||||||
|
kind.on('close', (code) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
leser.schluss().forEach(zeile)
|
||||||
|
if (abgebrochen) {
|
||||||
|
fertig({ status: 'cancelled' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig(auswertung.urteil(code, optionen.ausgabe))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Preset-Wahl je Disc-Typ (KONZEPT § 6.10) — Regeln aus ripping.py, dort
|
||||||
|
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
||||||
|
// 4K auf 1080p herunter und DVDs hinauf.
|
||||||
|
|
||||||
|
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
|
|
||||||
|
export { DEFAULT_HB_PRESET, presetEmpfehlung }
|
||||||
|
|
||||||
|
/** Einstellungs-Schlüssel je Disc-Typ. */
|
||||||
|
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
||||||
|
dvd: 'transcodePresetDvd',
|
||||||
|
bluray: 'transcodePresetBluray',
|
||||||
|
uhd: 'transcodePresetUhd',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
||||||
|
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
|
||||||
|
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
|
||||||
|
export const PRESET_KEINE = 'keine'
|
||||||
|
|
||||||
|
export type Einstellungen = Record<string, string | boolean | undefined>
|
||||||
|
|
||||||
|
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
|
||||||
|
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
|
||||||
|
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||||
|
if (schluessel.length > 0) {
|
||||||
|
const wert = String(einstellungen[schluessel] ?? '').trim()
|
||||||
|
if (wert === PRESET_KEINE) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
||||||
|
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
|
||||||
|
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
|
||||||
|
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
|
||||||
|
* HandBrake `--preset keine` und scheiterte. */
|
||||||
|
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
||||||
|
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||||
|
if (schluessel.length > 0) {
|
||||||
|
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
||||||
|
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
||||||
|
}
|
||||||
|
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
|
||||||
|
if (allgemein.length > 0) return allgemein
|
||||||
|
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
||||||
|
* ein „DEU" aus einem Eingabefeld soll die Auswahl nicht still lahmlegen. */
|
||||||
|
export function sprachliste(wert: string | readonly string[] | null | undefined): string[] {
|
||||||
|
if (wert === null || wert === undefined) return []
|
||||||
|
const teile = typeof wert === 'string' ? wert.split(',') : [...wert]
|
||||||
|
const sauber: string[] = []
|
||||||
|
for (const teil of teile) {
|
||||||
|
const code = String(teil).trim().toLowerCase()
|
||||||
|
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
|
||||||
|
}
|
||||||
|
return sauber
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Endung → HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||||
|
* gegengeprüft (`--help`, Abschnitt `-f, --format`). */
|
||||||
|
export const FORMATE: Record<string, string> = {
|
||||||
|
'.mkv': 'av_mkv',
|
||||||
|
'.mp4': 'av_mp4',
|
||||||
|
'.m4v': 'av_mp4',
|
||||||
|
'.mov': 'av_mov',
|
||||||
|
'.webm': 'av_webm',
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
|
||||||
|
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
|
||||||
|
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
|
||||||
|
// allein in win32.ts (Wächter R1).
|
||||||
|
//
|
||||||
|
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
|
||||||
|
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
|
||||||
|
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
|
||||||
|
// Microsoft-Dokumentation genannten Zahlen gegen.
|
||||||
|
//
|
||||||
|
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
|
||||||
|
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
|
||||||
|
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
|
||||||
|
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
|
||||||
|
// dort an echter Hardware bewiesen).
|
||||||
|
|
||||||
|
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
|
||||||
|
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
|
||||||
|
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_DEVICE_CD_ROM = 0x02
|
||||||
|
const FILE_DEVICE_DISK = 0x07
|
||||||
|
const FILE_DEVICE_MASS_STORAGE = 0x2d
|
||||||
|
|
||||||
|
const METHOD_BUFFERED = 0
|
||||||
|
const METHOD_OUT_DIRECT = 2
|
||||||
|
|
||||||
|
const FILE_ANY_ACCESS = 0
|
||||||
|
const FILE_READ_ACCESS = 0x0001
|
||||||
|
|
||||||
|
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||||
|
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
|
||||||
|
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
|
||||||
|
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
|
||||||
|
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
|
||||||
|
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||||
|
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||||
|
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
|
||||||
|
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
|
||||||
|
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
|
||||||
|
export const CDROM_TOC_BYTES = 4 + 100 * 8
|
||||||
|
|
||||||
|
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
|
||||||
|
export const SDD_VENDOR_ID_OFFSET = 12
|
||||||
|
export const SDD_PRODUCT_ID_OFFSET = 16
|
||||||
|
export const SDD_PRODUCT_REVISION_OFFSET = 20
|
||||||
|
export const SDD_SERIAL_NUMBER_OFFSET = 24
|
||||||
|
|
||||||
|
export const CDROM_DISK_AUDIO_TRACK = 0x01
|
||||||
|
export const CDROM_DISK_DATA_TRACK = 0x02
|
||||||
|
|
||||||
|
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
|
||||||
|
export const ERROR_INVALID_FUNCTION = 1
|
||||||
|
export const ERROR_FILE_NOT_FOUND = 2
|
||||||
|
export const ERROR_ACCESS_DENIED = 5
|
||||||
|
export const ERROR_NOT_READY = 21
|
||||||
|
export const ERROR_NOT_SUPPORTED = 50
|
||||||
|
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
|
||||||
|
|
||||||
|
export const DRIVE_CDROM = 5
|
||||||
|
|
||||||
|
export const GENERIC_READ = 0x8000_0000
|
||||||
|
export const FILE_SHARE_READ = 0x01
|
||||||
|
export const FILE_SHARE_WRITE = 0x02
|
||||||
|
export const OPEN_EXISTING = 3
|
||||||
|
|
||||||
|
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
|
||||||
|
export class Win32Fehler extends Error {
|
||||||
|
constructor(
|
||||||
|
was: string,
|
||||||
|
readonly code: number,
|
||||||
|
) {
|
||||||
|
super(`${was} scheiterte (Win32-Fehler ${code})`)
|
||||||
|
this.name = 'Win32Fehler'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
|
||||||
|
export interface LaufwerkApi {
|
||||||
|
laufwerksbuchstaben(): string[]
|
||||||
|
laufwerkstyp(buchstabe: string): number
|
||||||
|
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
||||||
|
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
||||||
|
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
||||||
|
oeffnen(geraetepfad: string, zugriff: number): unknown
|
||||||
|
schliessen(handle: unknown): void
|
||||||
|
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
|
||||||
|
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
|
||||||
|
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
|
||||||
|
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
|
||||||
|
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
|
||||||
|
// (windows.py, an echter Hardware gemessen 28.08.2026):
|
||||||
|
//
|
||||||
|
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
|
||||||
|
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
|
||||||
|
// Auswurf mit Erfolg und tut nichts (R3).
|
||||||
|
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
|
||||||
|
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
|
||||||
|
import {
|
||||||
|
CDROM_DISK_AUDIO_TRACK,
|
||||||
|
CDROM_DISK_DATA_TRACK,
|
||||||
|
ERROR_ACCESS_DENIED,
|
||||||
|
ERROR_FILE_NOT_FOUND,
|
||||||
|
ERROR_INVALID_FUNCTION,
|
||||||
|
ERROR_NOT_READY,
|
||||||
|
ERROR_NOT_SUPPORTED,
|
||||||
|
ERROR_NO_MEDIA_IN_DRIVE,
|
||||||
|
CDROM_TOC_BYTES,
|
||||||
|
DRIVE_CDROM,
|
||||||
|
GENERIC_READ,
|
||||||
|
IOCTL_CDROM_DISK_TYPE,
|
||||||
|
IOCTL_CDROM_RAW_READ,
|
||||||
|
IOCTL_CDROM_READ_TOC,
|
||||||
|
IOCTL_DISK_GET_LENGTH_INFO,
|
||||||
|
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||||
|
IOCTL_STORAGE_EJECT_MEDIA,
|
||||||
|
IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||||
|
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||||
|
SDD_PRODUCT_ID_OFFSET,
|
||||||
|
SDD_PRODUCT_REVISION_OFFSET,
|
||||||
|
SDD_SERIAL_NUMBER_OFFSET,
|
||||||
|
SDD_VENDOR_ID_OFFSET,
|
||||||
|
Win32Fehler,
|
||||||
|
type LaufwerkApi,
|
||||||
|
} from './codes'
|
||||||
|
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
|
||||||
|
import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||||
|
|
||||||
|
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
|
||||||
|
|
||||||
|
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
|
||||||
|
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
|
||||||
|
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
|
||||||
|
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
|
||||||
|
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
|
||||||
|
export const UHD_MIN_BYTES = 55 * 1024 ** 3
|
||||||
|
|
||||||
|
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
|
||||||
|
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
|
||||||
|
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
|
||||||
|
if (spurFlags & CDROM_DISK_DATA_TRACK) {
|
||||||
|
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
|
||||||
|
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „D:" oder „D" oder „D:\" → \\.\D: — der Gerätepfad hat KEINEN
|
||||||
|
* abschließenden Backslash. Mit einem öffnet CreateFileW das
|
||||||
|
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
|
||||||
|
* ERROR_INVALID_FUNCTION — das sieht aus wie „kann das Laufwerk nicht". */
|
||||||
|
export function geraetepfad(buchstabe: string): string {
|
||||||
|
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
|
||||||
|
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
|
||||||
|
return '\\\\.\\' + kern[0].toUpperCase() + ':'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
|
||||||
|
export function kennung(geraet: string): string {
|
||||||
|
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
|
||||||
|
return api
|
||||||
|
.laufwerksbuchstaben()
|
||||||
|
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
|
||||||
|
.map(geraetepfad)
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zustand ─────────────────────────────────────────────────────────────
|
||||||
|
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
|
||||||
|
try {
|
||||||
|
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
|
||||||
|
return true
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
|
||||||
|
const handle = api.oeffnen(geraet, zugriff)
|
||||||
|
try {
|
||||||
|
return tun(handle)
|
||||||
|
} finally {
|
||||||
|
api.schliessen(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
|
||||||
|
export function spurFlags(api: LaufwerkApi, geraet: string): number {
|
||||||
|
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||||
|
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
|
||||||
|
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
|
||||||
|
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||||
|
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
|
||||||
|
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verriegeln ──────────────────────────────────────────────────────────
|
||||||
|
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
|
||||||
|
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
|
||||||
|
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
|
||||||
|
mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||||
|
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auswerfen ───────────────────────────────────────────────────────────
|
||||||
|
export type { AuswurfGrund }
|
||||||
|
|
||||||
|
const AUSWURF_NACHSEHEN_RUNDEN = 5
|
||||||
|
|
||||||
|
/** Entriegeln → auswerfen → NACHSEHEN (R3). Wirft NIE; gibt den Grund
|
||||||
|
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
|
||||||
|
* prüfbar ist. */
|
||||||
|
export async function auswerfenMitGrund(
|
||||||
|
api: LaufwerkApi,
|
||||||
|
geraet: string,
|
||||||
|
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
|
||||||
|
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
|
||||||
|
let handle: unknown
|
||||||
|
try {
|
||||||
|
handle = api.oeffnen(geraet, GENERIC_READ)
|
||||||
|
} catch (fehler) {
|
||||||
|
return { grund: 'kein-zugriff', fehler: String(fehler) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
|
||||||
|
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
|
||||||
|
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
|
||||||
|
let entriegelnFehler: string | null = null
|
||||||
|
try {
|
||||||
|
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
|
||||||
|
} catch (fehler) {
|
||||||
|
entriegelnFehler = String(fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
|
||||||
|
} catch (fehler) {
|
||||||
|
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
|
||||||
|
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
|
||||||
|
try {
|
||||||
|
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
|
||||||
|
} catch (fehler) {
|
||||||
|
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||||
|
}
|
||||||
|
await warten(1000)
|
||||||
|
}
|
||||||
|
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
api.schliessen(handle)
|
||||||
|
} catch {
|
||||||
|
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
|
||||||
|
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ─────────────────────────────
|
||||||
|
/** Das Inhaltsverzeichnis einer Audio-CD — leere Liste, wenn keine drin.
|
||||||
|
* Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */
|
||||||
|
export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
|
||||||
|
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||||
|
const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES)
|
||||||
|
return tocZerlegen(roh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
||||||
|
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
||||||
|
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
||||||
|
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
|
||||||
|
export function audioSpurLesen(
|
||||||
|
api: LaufwerkApi,
|
||||||
|
geraet: string,
|
||||||
|
start: number,
|
||||||
|
sektoren: number,
|
||||||
|
schreiben: (daten: Buffer) => void,
|
||||||
|
fortschritt?: (anteil: number) => void,
|
||||||
|
): number {
|
||||||
|
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||||
|
const teile = bloecke(start, sektoren)
|
||||||
|
let gelesen = 0
|
||||||
|
for (let i = 0; i < teile.length; i++) {
|
||||||
|
const [von, anzahl] = teile[i]
|
||||||
|
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
||||||
|
schreiben(daten)
|
||||||
|
gelesen += daten.length
|
||||||
|
fortschritt?.((i + 1) / teile.length)
|
||||||
|
}
|
||||||
|
return gelesen
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||||
|
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
|
||||||
|
const ZUGRIFFS_GRUENDE: Record<number, string> = {
|
||||||
|
[ERROR_INVALID_FUNCTION]:
|
||||||
|
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
|
||||||
|
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
|
||||||
|
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
|
||||||
|
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
|
||||||
|
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zugriffsGrund(fehler: unknown): string {
|
||||||
|
const code = fehler instanceof Win32Fehler ? fehler.code : null
|
||||||
|
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
|
||||||
|
if (code !== null) {
|
||||||
|
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
|
||||||
|
}
|
||||||
|
return 'Das Laufwerk antwortet nicht.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Geräteangaben ───────────────────────────────────────────────────────
|
||||||
|
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
|
||||||
|
* Offset 0 heißt „gibt es nicht" (dokumentiertes Verhalten des
|
||||||
|
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
|
||||||
|
export function textBei(puffer: Buffer, offsetPos: number): string {
|
||||||
|
if (puffer.length < offsetPos + 4) return ''
|
||||||
|
const offset = puffer.readUInt32LE(offsetPos)
|
||||||
|
if (offset === 0 || offset >= puffer.length) return ''
|
||||||
|
let ende = puffer.indexOf(0, offset)
|
||||||
|
if (ende < 0) ende = puffer.length
|
||||||
|
return puffer.toString('latin1', offset, ende).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Geraeteangaben {
|
||||||
|
hersteller: string
|
||||||
|
modell: string
|
||||||
|
seriennummer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht — ein Laufwerk
|
||||||
|
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
|
||||||
|
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
|
||||||
|
* wenn das Medium gestört ist (rc11-Befund). */
|
||||||
|
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||||
|
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
|
||||||
|
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
|
||||||
|
try {
|
||||||
|
return mitHandle(api, geraet, 0, (h) => {
|
||||||
|
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
|
||||||
|
return {
|
||||||
|
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
|
||||||
|
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
.join(' '),
|
||||||
|
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return leer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
|
||||||
|
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
|
||||||
|
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
|
||||||
|
// rippt (Muster aus windows.py).
|
||||||
|
const angabenSpeicher = new Map<string, Geraeteangaben>()
|
||||||
|
|
||||||
|
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||||
|
let angaben = angabenSpeicher.get(geraet)
|
||||||
|
if (angaben === undefined) {
|
||||||
|
angaben = geraeteangaben(api, geraet)
|
||||||
|
angabenSpeicher.set(geraet, angaben)
|
||||||
|
}
|
||||||
|
return angaben
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
|
||||||
|
export function angabenSpeicherLeeren(): void {
|
||||||
|
angabenSpeicher.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
|
||||||
|
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
|
||||||
|
let status: LaufwerkStatus = 'unknown'
|
||||||
|
let typ: DiscTyp = 'unknown'
|
||||||
|
let groesse = 0
|
||||||
|
let grund = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
|
||||||
|
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
|
||||||
|
if (da) {
|
||||||
|
status = 'ready'
|
||||||
|
try {
|
||||||
|
groesse = discGroesseBytes(api, geraet)
|
||||||
|
typ = classify(spurFlags(api, geraet), groesse)
|
||||||
|
} catch (fehler) {
|
||||||
|
typ = 'unknown'
|
||||||
|
grund = zugriffsGrund(fehler)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = 'empty'
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
status = 'unknown'
|
||||||
|
grund = zugriffsGrund(fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buchstabe = kennung(geraet)
|
||||||
|
const angaben = angabenGemerkt(api, geraet)
|
||||||
|
return {
|
||||||
|
id: buchstabe,
|
||||||
|
name: `Laufwerk ${buchstabe}:`,
|
||||||
|
typ,
|
||||||
|
pfad: geraet,
|
||||||
|
status,
|
||||||
|
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
|
||||||
|
seriennummer: angaben.seriennummer,
|
||||||
|
groesseBytes: groesse,
|
||||||
|
grund,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
|
||||||
|
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
|
||||||
|
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
|
||||||
|
// bleibt dieser Takt als Rückfallebene bestehen.
|
||||||
|
//
|
||||||
|
// Regeln, die hier verdrahtet sind:
|
||||||
|
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
|
||||||
|
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
|
||||||
|
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
|
||||||
|
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export type { WacheEreignis }
|
||||||
|
|
||||||
|
/** Pure Funktion: Zustandsvergleich → Ereignisse (Muster aus
|
||||||
|
* bus/waechter.py `laufwerks_unterschiede`). */
|
||||||
|
export function laufwerksUnterschiede(
|
||||||
|
vorher: ReadonlyMap<string, GeraeteInfo>,
|
||||||
|
jetzt: ReadonlyMap<string, GeraeteInfo>,
|
||||||
|
): WacheEreignis[] {
|
||||||
|
const ereignisse: WacheEreignis[] = []
|
||||||
|
for (const [id, stand] of jetzt) {
|
||||||
|
const alt = vorher.get(id)
|
||||||
|
if (alt === undefined) {
|
||||||
|
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
|
||||||
|
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const vorherBereit = alt.status === 'ready'
|
||||||
|
const jetztBereit = stand.status === 'ready'
|
||||||
|
if (!vorherBereit && jetztBereit) {
|
||||||
|
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||||
|
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
|
||||||
|
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
|
||||||
|
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
|
||||||
|
// Disc (R2); der Grund steht im Geräte-Eintrag.
|
||||||
|
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of vorher.keys()) {
|
||||||
|
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
|
||||||
|
}
|
||||||
|
return ereignisse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WACHE_TAKT_MS = 3000
|
||||||
|
|
||||||
|
export interface WacheMeldung {
|
||||||
|
laufwerke: GeraeteInfo[]
|
||||||
|
ereignisse: WacheEreignis[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscWache {
|
||||||
|
private stand = new Map<string, GeraeteInfo>()
|
||||||
|
private zeiger: NodeJS.Timeout | null = null
|
||||||
|
private laeuft = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
|
||||||
|
private readonly lesen: () => GeraeteInfo[],
|
||||||
|
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
|
||||||
|
private readonly melden: (meldung: WacheMeldung) => void,
|
||||||
|
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
|
||||||
|
private readonly fehlerMelden: (fehler: unknown) => void,
|
||||||
|
private readonly taktMs: number = WACHE_TAKT_MS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
starten(): void {
|
||||||
|
if (this.laeuft) return
|
||||||
|
this.laeuft = true
|
||||||
|
this.runde()
|
||||||
|
}
|
||||||
|
|
||||||
|
stoppen(): void {
|
||||||
|
this.laeuft = false
|
||||||
|
if (this.zeiger !== null) {
|
||||||
|
clearTimeout(this.zeiger)
|
||||||
|
this.zeiger = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
|
||||||
|
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||||
|
runde(): void {
|
||||||
|
if (!this.laeuft) return
|
||||||
|
try {
|
||||||
|
const liste = this.lesen()
|
||||||
|
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||||
|
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
|
||||||
|
const geaendert =
|
||||||
|
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
|
||||||
|
this.stand = jetzt
|
||||||
|
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
|
||||||
|
} catch (fehler) {
|
||||||
|
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
|
||||||
|
this.fehlerMelden(fehler)
|
||||||
|
}
|
||||||
|
if (this.laeuft) {
|
||||||
|
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
|
||||||
|
// Der Takt darf einen Programm-Exit nicht aufhalten.
|
||||||
|
this.zeiger.unref?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aktuellerStand(): GeraeteInfo[] {
|
||||||
|
return [...this.stand.values()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
|
||||||
|
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
|
||||||
|
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
|
||||||
|
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
|
||||||
|
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
|
||||||
|
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
|
||||||
|
// beweise/laufwerk.js, 30.08.2026).
|
||||||
|
import koffi from 'koffi'
|
||||||
|
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
|
||||||
|
|
||||||
|
export type { LaufwerkApi }
|
||||||
|
|
||||||
|
let echteApi: LaufwerkApi | null = null
|
||||||
|
|
||||||
|
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
|
||||||
|
export function win32Api(): LaufwerkApi {
|
||||||
|
if (echteApi !== null) return echteApi
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll')
|
||||||
|
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()')
|
||||||
|
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)')
|
||||||
|
const CreateFileW = kernel32.func(
|
||||||
|
'void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)',
|
||||||
|
)
|
||||||
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
const DeviceIoControl = kernel32.func(
|
||||||
|
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
||||||
|
)
|
||||||
|
|
||||||
|
echteApi = {
|
||||||
|
laufwerksbuchstaben(): string[] {
|
||||||
|
const maske = GetLogicalDrives() as number
|
||||||
|
const buchstaben: string[] = []
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
|
||||||
|
}
|
||||||
|
return buchstaben
|
||||||
|
},
|
||||||
|
|
||||||
|
laufwerkstyp(buchstabe: string): number {
|
||||||
|
return GetDriveTypeW(`${buchstabe}:\\`) as number
|
||||||
|
},
|
||||||
|
|
||||||
|
oeffnen(geraetepfad: string, zugriff: number): unknown {
|
||||||
|
const handle = CreateFileW(
|
||||||
|
geraetepfad,
|
||||||
|
zugriff,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||||
|
null,
|
||||||
|
OPEN_EXISTING,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
|
||||||
|
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
|
||||||
|
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
|
||||||
|
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
|
||||||
|
throw new Win32Fehler('CreateFileW', GetLastError() as number)
|
||||||
|
}
|
||||||
|
return handle
|
||||||
|
},
|
||||||
|
|
||||||
|
schliessen(handle: unknown): void {
|
||||||
|
CloseHandle(handle)
|
||||||
|
},
|
||||||
|
|
||||||
|
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
||||||
|
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
||||||
|
const zurueck = [0]
|
||||||
|
const erfolg = DeviceIoControl(
|
||||||
|
handle,
|
||||||
|
code,
|
||||||
|
eingabe ?? null,
|
||||||
|
eingabe?.length ?? 0,
|
||||||
|
aus,
|
||||||
|
ausgabeBytes,
|
||||||
|
zurueck,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
if (!erfolg) {
|
||||||
|
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
|
||||||
|
}
|
||||||
|
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return echteApi
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
|
||||||
|
// aus prescan.py, dort an echten Discs gemessen:
|
||||||
|
//
|
||||||
|
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
|
||||||
|
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
|
||||||
|
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
|
||||||
|
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
|
||||||
|
//
|
||||||
|
// KEIN makemkvcon hier: Der Zweig kostete 20–120 s je eingelegter Disc
|
||||||
|
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
|
||||||
|
// sehr sehr lange").
|
||||||
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
|
||||||
|
// Plattform-Pfade: titelAusBdmt läuft auf einer LAUFZEIT-Wurzel (G:\ im
|
||||||
|
// Betrieb, ein tmp-Ordner im Linux-CI-Test) — dieselbe Regel wie in
|
||||||
|
// struktur.ts, am 30.08.2026 zum dritten Mal gelernt.
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
|
||||||
|
export function normalizeDiscLabel(label: string): string {
|
||||||
|
if (label.length === 0) return ''
|
||||||
|
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
|
||||||
|
if (bereinigt === bereinigt.toUpperCase()) {
|
||||||
|
return bereinigt
|
||||||
|
.toLowerCase()
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
return bereinigt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
|
||||||
|
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
|
||||||
|
export function parseUdfDstring(daten: Buffer): string {
|
||||||
|
if (daten.length === 0) return ''
|
||||||
|
const laenge = daten[daten.length - 1]
|
||||||
|
if (laenge < 2 || laenge > daten.length - 1) return ''
|
||||||
|
const compId = daten[0]
|
||||||
|
const text = daten.subarray(1, laenge)
|
||||||
|
if (compId === 8) return text.toString('latin1').trim()
|
||||||
|
if (compId === 16) {
|
||||||
|
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
|
||||||
|
const getauscht = Buffer.alloc(text.length - (text.length % 2))
|
||||||
|
for (let i = 0; i + 1 < text.length; i += 2) {
|
||||||
|
getauscht[i] = text[i + 1]
|
||||||
|
getauscht[i + 1] = text[i]
|
||||||
|
}
|
||||||
|
return getauscht.toString('utf16le').trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEKTOR = 2048
|
||||||
|
|
||||||
|
function lesenBei(fd: number, position: number, laenge: number): Buffer {
|
||||||
|
const puffer = Buffer.alloc(laenge)
|
||||||
|
const gelesen = readSync(fd, puffer, 0, laenge, position)
|
||||||
|
return puffer.subarray(0, gelesen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-9660-Volume-Label: Sektor 16, Bytes 40–71 (DVDs). '' wenn keins. */
|
||||||
|
export function isoVolumeLabel(geraetepfad: string): string {
|
||||||
|
let fd: number
|
||||||
|
try {
|
||||||
|
fd = openSync(geraetepfad, 'r')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
|
||||||
|
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
|
||||||
|
return pvd.subarray(40, 72).toString('latin1').trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
} finally {
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256 →
|
||||||
|
* Main VDS → PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
|
||||||
|
export function udfVolumeLabel(geraetepfad: string): string {
|
||||||
|
let fd: number
|
||||||
|
try {
|
||||||
|
fd = openSync(geraetepfad, 'r')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
|
||||||
|
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
|
||||||
|
const vdsOrt = avdp.readUInt32LE(20)
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
|
||||||
|
if (block.length < 56) return ''
|
||||||
|
const tagId = block.readUInt16LE(0)
|
||||||
|
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
|
||||||
|
if (tagId === 8) return '' // Terminating Descriptor
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
} finally {
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
||||||
|
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||||
|
export function titelAusBdmt(wurzel: string): string {
|
||||||
|
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
|
if (!existsSync(metaOrdner)) return ''
|
||||||
|
let kandidaten: string[]
|
||||||
|
try {
|
||||||
|
kandidaten = readdirSync(metaOrdner).sort()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const bdmt =
|
||||||
|
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||||
|
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||||
|
if (bdmt === undefined) return ''
|
||||||
|
let inhalt: string
|
||||||
|
try {
|
||||||
|
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||||
|
return treffer === null ? '' : treffer[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||||
|
export function discWurzel(geraetepfad: string): string {
|
||||||
|
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||||
|
const wurzel = buchstabe + ':\\'
|
||||||
|
return existsSync(wurzel) ? wurzel : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscTitel {
|
||||||
|
titel: string
|
||||||
|
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||||
|
quelle: 'bdmt' | 'label' | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
|
||||||
|
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
||||||
|
export function discTitel(geraetepfad: string): DiscTitel {
|
||||||
|
const wurzel = discWurzel(geraetepfad)
|
||||||
|
if (wurzel.length > 0) {
|
||||||
|
const bdmt = titelAusBdmt(wurzel)
|
||||||
|
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||||
|
}
|
||||||
|
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||||
|
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||||
|
return { titel: '', quelle: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||||
|
* Duplikat-Erkennung in der Bibliothek. */
|
||||||
|
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
|
||||||
|
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
|
||||||
|
return `${label}|${groesseBytes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||||
|
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||||
|
* Klammern, dann hintere Worte strippen. */
|
||||||
|
export function titelKandidaten(titel: string): string[] {
|
||||||
|
const kandidaten: string[] = []
|
||||||
|
const merke = (t: string): void => {
|
||||||
|
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
|
||||||
|
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||||
|
}
|
||||||
|
merke(titel)
|
||||||
|
if (titel.includes(':')) {
|
||||||
|
merke(titel.split(':', 1)[0])
|
||||||
|
merke(titel.replaceAll(':', ''))
|
||||||
|
}
|
||||||
|
merke(titel.replace(/\([^)]*\)/g, ''))
|
||||||
|
let worte = titel.split(/\s+/).filter(Boolean)
|
||||||
|
while (worte.length > 1 && kandidaten.length < 6) {
|
||||||
|
worte = worte.slice(0, -1)
|
||||||
|
merke(worte.join(' '))
|
||||||
|
}
|
||||||
|
return kandidaten
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3,
|
||||||
|
// Vorlage musicbrainz_cd.py — Rechnung laut
|
||||||
|
// musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte
|
||||||
|
// AC/DC-„Back in Black"-Antwort bewiesen).
|
||||||
|
//
|
||||||
|
// DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das
|
||||||
|
// Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die
|
||||||
|
// MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann
|
||||||
|
// einfach „unbekannte Disc".
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd'
|
||||||
|
|
||||||
|
const DIENST = 'https://musicbrainz.org/ws/2/discid/'
|
||||||
|
// MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie
|
||||||
|
// wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie).
|
||||||
|
const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' }
|
||||||
|
/** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */
|
||||||
|
export const PLAETZE = 99
|
||||||
|
|
||||||
|
/** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */
|
||||||
|
export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string {
|
||||||
|
if (spurVersaetze.length === 0) return ''
|
||||||
|
const letzteSpur = letzte ?? erste + spurVersaetze.length - 1
|
||||||
|
const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0')
|
||||||
|
const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0')
|
||||||
|
const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)]
|
||||||
|
for (let platz = 0; platz < PLAETZE; platz++) {
|
||||||
|
teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0))
|
||||||
|
}
|
||||||
|
const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64')
|
||||||
|
// MusicBrainz' eigenes Alphabet: + / = werden zu . _ -
|
||||||
|
return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */
|
||||||
|
export function kennungAusSpuren(spuren: readonly Spur[]): string {
|
||||||
|
if (spuren.length === 0) return ''
|
||||||
|
const geordnet = [...spuren].sort((a, b) => a.nr - b.nr)
|
||||||
|
const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES)
|
||||||
|
const letzte = geordnet[geordnet.length - 1]
|
||||||
|
const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES
|
||||||
|
return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicBrainzAntwort {
|
||||||
|
releases?: Array<{
|
||||||
|
title?: string
|
||||||
|
date?: string
|
||||||
|
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
|
||||||
|
media?: Array<{
|
||||||
|
tracks?: Array<{
|
||||||
|
position?: number | string
|
||||||
|
title?: string
|
||||||
|
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachschlagen — null bei unbekannter Disc, keinem Netz oder Fremdem:
|
||||||
|
* alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */
|
||||||
|
export async function abfragen(
|
||||||
|
kennung: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<MusicBrainzAntwort | null> {
|
||||||
|
if (kennung.length === 0) return null
|
||||||
|
try {
|
||||||
|
const antwort = await holen(
|
||||||
|
`${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`,
|
||||||
|
{ headers: KOPF, signal: AbortSignal.timeout(15_000) },
|
||||||
|
)
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
return (await antwort.json()) as MusicBrainzAntwort
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string {
|
||||||
|
// „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " +
|
||||||
|
// „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte.
|
||||||
|
return (eintrag ?? [])
|
||||||
|
.map((s) => (s.name ?? '') + (s.joinphrase ?? ''))
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort → {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc —
|
||||||
|
* bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */
|
||||||
|
export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map<number, Record<string, string>> {
|
||||||
|
const tags = new Map<number, Record<string, string>>()
|
||||||
|
const release = antwort?.releases?.[0]
|
||||||
|
if (release === undefined) return tags
|
||||||
|
const album = release.title ?? ''
|
||||||
|
const jahr = (release.date ?? '').slice(0, 4)
|
||||||
|
const albumKuenstler = kuenstler(release['artist-credit'])
|
||||||
|
for (const medium of release.media ?? []) {
|
||||||
|
for (const spur of medium.tracks ?? []) {
|
||||||
|
const nr = Number(spur.position ?? 0)
|
||||||
|
if (!Number.isInteger(nr) || nr <= 0) continue
|
||||||
|
tags.set(nr, {
|
||||||
|
TITLE: spur.title ?? '',
|
||||||
|
ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler,
|
||||||
|
ALBUM: album,
|
||||||
|
ALBUMARTIST: albumKuenstler,
|
||||||
|
DATE: jahr,
|
||||||
|
TRACKNUMBER: String(nr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */
|
||||||
|
export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string {
|
||||||
|
const tags = tagsJeSpur(antwort)
|
||||||
|
if (tags.size === 0) return ''
|
||||||
|
const erste = tags.get(Math.min(...tags.keys()))!
|
||||||
|
const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || ''
|
||||||
|
const album = erste['ALBUM'] || ''
|
||||||
|
const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer
|
||||||
|
return saubern(roh)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// OMDb-Client — eigene Datenbasis, findet oft, was TMDb nicht exakt
|
||||||
|
// trifft. Nur Englisch; der deutsche Datensatz wird danach über
|
||||||
|
// TMDb /find (IMDb-ID) nachgeladen (zuordnung.ts). Ohne Key überspringt
|
||||||
|
// er sich selbst — kein Fehler, keine Behauptung.
|
||||||
|
|
||||||
|
export interface OmdbTreffer {
|
||||||
|
type: 'movie' | 'tv'
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
year: number | null
|
||||||
|
overview: string
|
||||||
|
poster_path: string
|
||||||
|
runtime: number
|
||||||
|
genres: string[]
|
||||||
|
source: 'omdb'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OmdbClient {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly holen: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get verfuegbar(): boolean {
|
||||||
|
return this.apiKey.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(titel: string): Promise<OmdbTreffer | null> {
|
||||||
|
if (!this.verfuegbar) return null
|
||||||
|
try {
|
||||||
|
const url = new URL('https://www.omdbapi.com/')
|
||||||
|
url.searchParams.set('apikey', this.apiKey)
|
||||||
|
url.searchParams.set('t', titel)
|
||||||
|
url.searchParams.set('r', 'json')
|
||||||
|
const antwort = await this.holen(url.toString(), { signal: AbortSignal.timeout(15_000) })
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
const daten = (await antwort.json()) as Record<string, string>
|
||||||
|
if (daten['Response'] !== 'True') return null
|
||||||
|
const jahr = /(\d{4})/.exec(daten['Year'] ?? '')
|
||||||
|
const laufzeit = /(\d+)/.exec(daten['Runtime'] ?? '')
|
||||||
|
return {
|
||||||
|
type: daten['Type'] === 'series' ? 'tv' : 'movie',
|
||||||
|
id: daten['imdbID'] ?? '',
|
||||||
|
title: daten['Title'] ?? titel,
|
||||||
|
year: jahr === null ? null : Number(jahr[1]),
|
||||||
|
overview: daten['Plot'] ?? '',
|
||||||
|
poster_path: daten['Poster'] !== 'N/A' ? (daten['Poster'] ?? '') : '',
|
||||||
|
runtime: laufzeit === null ? 0 : Number(laufzeit[1]),
|
||||||
|
genres: (daten['Genre'] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter((g) => g.length > 0),
|
||||||
|
source: 'omdb',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
|
||||||
|
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
||||||
|
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
||||||
|
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
||||||
|
|
||||||
|
export interface FilmTreffer {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
name?: string
|
||||||
|
poster_path?: string
|
||||||
|
release_date?: string
|
||||||
|
first_air_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilmDetails {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
name?: string
|
||||||
|
release_date?: string
|
||||||
|
first_air_date?: string
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
backdrop_path?: string
|
||||||
|
runtime?: number
|
||||||
|
genres?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istV4Token(key: string): boolean {
|
||||||
|
return key.startsWith('eyJ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASIS = 'https://api.themoviedb.org/3'
|
||||||
|
|
||||||
|
export class TmdbClient {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly holen: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get verfuegbar(): boolean {
|
||||||
|
return this.apiKey.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
|
||||||
|
if (!this.verfuegbar) return null
|
||||||
|
const url = new URL(`${BASIS}/${weg}`)
|
||||||
|
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
|
||||||
|
const kopf: Record<string, string> = { Accept: 'application/json' }
|
||||||
|
if (istV4Token(this.apiKey)) {
|
||||||
|
kopf['Authorization'] = `Bearer ${this.apiKey}`
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('api_key', this.apiKey)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const antwort = await this.holen(url.toString(), {
|
||||||
|
headers: kopf,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
return await antwort.json()
|
||||||
|
} catch {
|
||||||
|
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
|
||||||
|
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
|
||||||
|
const daten = (await this.anfrage('search/movie', {
|
||||||
|
query: titel,
|
||||||
|
language: 'de-DE',
|
||||||
|
include_adult: 'false',
|
||||||
|
})) as { results?: FilmTreffer[] } | null
|
||||||
|
return daten?.results ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
|
||||||
|
const daten = (await this.anfrage('search/tv', {
|
||||||
|
query: titel,
|
||||||
|
language: 'de-DE',
|
||||||
|
include_adult: 'false',
|
||||||
|
})) as { results?: FilmTreffer[] } | null
|
||||||
|
return daten?.results ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieDetails(id: number): Promise<FilmDetails | null> {
|
||||||
|
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||||
|
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||||
|
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
|
||||||
|
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
|
||||||
|
episodes?: Array<{ episode_number: number; runtime?: number }>
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IMDb-ID → deutscher Datensatz (für OMDb-Treffer, die nur Englisch
|
||||||
|
* können — Commander-Wunsch 24.07.). */
|
||||||
|
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
|
||||||
|
const daten = (await this.anfrage(`find/${imdbId}`, {
|
||||||
|
external_source: 'imdb_id',
|
||||||
|
language: 'de-DE',
|
||||||
|
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
|
||||||
|
const film = daten?.movie_results?.[0]
|
||||||
|
if (film !== undefined) {
|
||||||
|
return {
|
||||||
|
title: film.title,
|
||||||
|
overview: film.overview,
|
||||||
|
poster_path: film.poster_path,
|
||||||
|
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
|
||||||
|
type: 'movie',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serie = daten?.tv_results?.[0]
|
||||||
|
if (serie !== undefined) {
|
||||||
|
return {
|
||||||
|
title: serie.name,
|
||||||
|
overview: serie.overview,
|
||||||
|
poster_path: serie.poster_path,
|
||||||
|
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
|
||||||
|
type: 'tv',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
|
||||||
|
// prescan.py mit ihren gemessenen Begründungen:
|
||||||
|
//
|
||||||
|
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
|
||||||
|
// 0,90 wörtlicher TMDb-Serientreffer
|
||||||
|
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
|
||||||
|
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
|
||||||
|
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
|
||||||
|
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
|
||||||
|
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
|
||||||
|
// 0,30 unbekannt
|
||||||
|
//
|
||||||
|
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||||
|
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
||||||
|
import { titelKandidaten } from './discmerkmale'
|
||||||
|
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||||
|
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||||
|
|
||||||
|
export const WENIGE_TREFFER = 3
|
||||||
|
|
||||||
|
export interface MetaTreffer {
|
||||||
|
typ: 'movie' | 'tv' | 'unknown'
|
||||||
|
id: number | string | null
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
beschreibung: string
|
||||||
|
posterPfad: string
|
||||||
|
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
|
||||||
|
backdropPfad: string
|
||||||
|
laufzeitMinuten: number
|
||||||
|
genres: string[]
|
||||||
|
quelle: 'tmdb' | 'omdb' | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Zuordnung {
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
confidence: number
|
||||||
|
meta: MetaTreffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function jahrAus(datum: string | undefined): number | null {
|
||||||
|
if (datum === undefined || datum.length < 4) return null
|
||||||
|
const jahr = Number(datum.slice(0, 4))
|
||||||
|
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
||||||
|
return {
|
||||||
|
typ,
|
||||||
|
id,
|
||||||
|
titel: (typ === 'movie' ? details.title : details.name) ?? '',
|
||||||
|
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
|
||||||
|
beschreibung: details.overview ?? '',
|
||||||
|
posterPfad: details.poster_path ?? '',
|
||||||
|
backdropPfad: details.backdrop_path ?? '',
|
||||||
|
laufzeitMinuten: details.runtime ?? 0,
|
||||||
|
genres: (details.genres ?? []).map((g) => g.name),
|
||||||
|
quelle: 'tmdb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
||||||
|
return {
|
||||||
|
typ: treffer.type,
|
||||||
|
id: treffer.id,
|
||||||
|
titel: treffer.title,
|
||||||
|
jahr: treffer.year,
|
||||||
|
beschreibung: treffer.overview,
|
||||||
|
posterPfad: treffer.poster_path,
|
||||||
|
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
|
||||||
|
backdropPfad: '',
|
||||||
|
laufzeitMinuten: treffer.runtime,
|
||||||
|
genres: treffer.genres,
|
||||||
|
quelle: 'omdb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||||
|
titel,
|
||||||
|
jahr: null,
|
||||||
|
confidence: 0.3,
|
||||||
|
meta: {
|
||||||
|
typ: 'unknown',
|
||||||
|
id: null,
|
||||||
|
titel,
|
||||||
|
jahr: null,
|
||||||
|
beschreibung: '',
|
||||||
|
posterPfad: '',
|
||||||
|
backdropPfad: '',
|
||||||
|
laufzeitMinuten: 0,
|
||||||
|
genres: [],
|
||||||
|
quelle: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||||
|
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||||
|
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||||
|
export function wortUeberlappung(a: string, b: string): number {
|
||||||
|
const worte = (t: string): Set<string> =>
|
||||||
|
new Set(
|
||||||
|
t
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter((w) => w.length > 0),
|
||||||
|
)
|
||||||
|
const wa = worte(a)
|
||||||
|
const wb = worte(b)
|
||||||
|
if (wa.size === 0 || wb.size === 0) return 0
|
||||||
|
let gemeinsam = 0
|
||||||
|
for (const w of wa) if (wb.has(w)) gemeinsam += 1
|
||||||
|
return gemeinsam / (wa.size + wb.size - gemeinsam)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||||
|
*
|
||||||
|
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
||||||
|
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
||||||
|
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
||||||
|
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
||||||
|
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
||||||
|
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
||||||
|
export async function zuordnen(
|
||||||
|
discTitel: string,
|
||||||
|
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||||
|
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||||
|
): Promise<Zuordnung> {
|
||||||
|
const titel = discTitel.trim()
|
||||||
|
if (titel.length === 0) return UNBEKANNT('')
|
||||||
|
|
||||||
|
const kandidaten = titelKandidaten(titel)
|
||||||
|
if (kandidaten.length === 0) kandidaten.push(titel)
|
||||||
|
|
||||||
|
let ersteTreffer: FilmTreffer[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < kandidaten.length; i++) {
|
||||||
|
const kandidat = kandidaten[i]
|
||||||
|
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
||||||
|
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
||||||
|
|
||||||
|
// Wörtlicher Filmtreffer (0,95)
|
||||||
|
for (const film of filme) {
|
||||||
|
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||||
|
const details = await tmdb.movieDetails(film.id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', film.id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||||
|
// Wörtlicher Serientreffer (0,90)
|
||||||
|
for (const serie of serien) {
|
||||||
|
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||||
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
|
||||||
|
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
||||||
|
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
||||||
|
if (i === 0) {
|
||||||
|
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||||
|
const details = await tmdb.movieDetails(filme[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', filme[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
|
||||||
|
const details = await tmdb.tvDetails(serien[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('tv', serien[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
|
||||||
|
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
|
||||||
|
if (omdb.verfuegbar) {
|
||||||
|
for (const kandidat of kandidaten) {
|
||||||
|
const treffer = await omdb.lookup(kandidat)
|
||||||
|
if (treffer !== null) {
|
||||||
|
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||||
|
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||||
|
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||||
|
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
|
||||||
|
const meta = ausOmdb(treffer)
|
||||||
|
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
|
||||||
|
const deutsch = await tmdb.findByImdb(meta.id)
|
||||||
|
if (deutsch !== null) {
|
||||||
|
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
|
||||||
|
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
|
||||||
|
meta.beschreibung = deutsch['overview']
|
||||||
|
}
|
||||||
|
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
|
||||||
|
meta.posterPfad = deutsch['poster_path']
|
||||||
|
}
|
||||||
|
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
|
||||||
|
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
|
||||||
|
if (ersteTreffer.length > 0) {
|
||||||
|
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNBEKANNT(titel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
|
||||||
|
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
|
||||||
|
return zuordnung.confidence >= schwelle
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
|
||||||
|
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
|
||||||
|
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
|
||||||
|
// liegt roh in 2352-Byte-Sektoren.
|
||||||
|
//
|
||||||
|
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
|
||||||
|
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
|
||||||
|
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
|
||||||
|
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
|
||||||
|
|
||||||
|
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
|
||||||
|
export const SEKTOR_BYTES = 2352
|
||||||
|
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
|
||||||
|
export const OFFSET_EINHEIT = 2048
|
||||||
|
export const FRAMES_JE_SEKUNDE = 75
|
||||||
|
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
|
||||||
|
export const VORLAUF_FRAMES = 150
|
||||||
|
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
|
||||||
|
export const BLOCK_SEKTOREN = 27
|
||||||
|
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
|
||||||
|
export const CDDA = 2
|
||||||
|
|
||||||
|
export interface Spur {
|
||||||
|
nr: number
|
||||||
|
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
|
||||||
|
start: number
|
||||||
|
sektoren: number
|
||||||
|
audio: boolean
|
||||||
|
dauer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSF-Adresse → logische Sektornummer (ECMA-130). Wer die 150 vergisst,
|
||||||
|
* liest jeden Track zwei Sekunden zu früh. */
|
||||||
|
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
|
||||||
|
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dauerText(sektoren: number): string {
|
||||||
|
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
|
||||||
|
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Bytes von IOCTL_CDROM_READ_TOC → Spurliste. CDROM_TOC (ntddcdrm.h):
|
||||||
|
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
|
||||||
|
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke —
|
||||||
|
* ohne ihn fehlt der letzten Spur die Länge). */
|
||||||
|
export function tocZerlegen(rohdaten: Buffer): Spur[] {
|
||||||
|
if (rohdaten.length < 12) return []
|
||||||
|
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
|
||||||
|
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
|
||||||
|
const adrControl = rohdaten[stelle + 1]
|
||||||
|
const nummer = rohdaten[stelle + 2]
|
||||||
|
const m = rohdaten[stelle + 5]
|
||||||
|
const s = rohdaten[stelle + 6]
|
||||||
|
const f = rohdaten[stelle + 7]
|
||||||
|
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
|
||||||
|
}
|
||||||
|
const spuren: Spur[] = []
|
||||||
|
for (let i = 0; i < eintraege.length; i++) {
|
||||||
|
const eintrag = eintraege[i]
|
||||||
|
if (eintrag.nr === 0xaa) continue
|
||||||
|
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
|
||||||
|
const laenge = eintraege[i + 1].lba - eintrag.lba
|
||||||
|
if (laenge <= 0) continue
|
||||||
|
spuren.push({
|
||||||
|
nr: eintrag.nr,
|
||||||
|
start: eintrag.lba,
|
||||||
|
sektoren: laenge,
|
||||||
|
audio: eintrag.audio,
|
||||||
|
dauer: dauerText(laenge),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return spuren
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Track in Lese-Blöcke zerlegen — am Stück scheitert an der
|
||||||
|
* 64-KB-Grenze vieler Treiber. */
|
||||||
|
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
|
||||||
|
if (sektoren <= 0 || jeBlock <= 0) return []
|
||||||
|
const teile: Array<[number, number]> = []
|
||||||
|
let gelesen = 0
|
||||||
|
while (gelesen < sektoren) {
|
||||||
|
const anzahl = Math.min(jeBlock, sektoren - gelesen)
|
||||||
|
teile.push([start + gelesen, anzahl])
|
||||||
|
gelesen += anzahl
|
||||||
|
}
|
||||||
|
return teile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der 44-Byte-WAV-Kopf — CD-Audio IST schon PCM im WAV-Format, es fehlt
|
||||||
|
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
|
||||||
|
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
|
||||||
|
const byteRate = (rate * kanaele * bits) / 8
|
||||||
|
const block = (kanaele * bits) / 8
|
||||||
|
const kopf = Buffer.alloc(44)
|
||||||
|
kopf.write('RIFF', 0, 'ascii')
|
||||||
|
kopf.writeUInt32LE(36 + datenBytes, 4)
|
||||||
|
kopf.write('WAVE', 8, 'ascii')
|
||||||
|
kopf.write('fmt ', 12, 'ascii')
|
||||||
|
kopf.writeUInt32LE(16, 16)
|
||||||
|
kopf.writeUInt16LE(1, 20)
|
||||||
|
kopf.writeUInt16LE(kanaele, 22)
|
||||||
|
kopf.writeUInt32LE(rate, 24)
|
||||||
|
kopf.writeUInt32LE(byteRate, 28)
|
||||||
|
kopf.writeUInt16LE(block, 32)
|
||||||
|
kopf.writeUInt16LE(bits, 34)
|
||||||
|
kopf.write('data', 36, 'ascii')
|
||||||
|
kopf.writeUInt32LE(datenBytes, 40)
|
||||||
|
return kopf
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
|
||||||
|
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
|
||||||
|
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
|
||||||
|
const eingabe = Buffer.alloc(16)
|
||||||
|
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
|
||||||
|
eingabe.writeUInt32LE(anzahl, 8)
|
||||||
|
eingabe.writeUInt32LE(CDDA, 12)
|
||||||
|
return eingabe
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS —
|
||||||
|
* „AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
|
||||||
|
export function sauberName(text: string): string {
|
||||||
|
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
|
||||||
|
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateiname(nr: number, titel = ''): string {
|
||||||
|
const sauber = sauberName(titel)
|
||||||
|
const nummer = String(nr).padStart(2, '0')
|
||||||
|
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
|
||||||
|
* den Fortschritt selbst), Tags einzeln. */
|
||||||
|
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
|
||||||
|
const befehl = ['-8', '--totally-silent', '-o', ziel]
|
||||||
|
for (const [name, wert] of Object.entries(tags)) {
|
||||||
|
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
|
||||||
|
}
|
||||||
|
befehl.push(wav)
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — die einzige echte Lücke
|
||||||
|
// gegenüber ARM: Wird eine Disc weder als Film noch als Musik erkannt,
|
||||||
|
// wird sie 1:1 gesichert statt abgelehnt. Sequenziell aus \\.\G: lesen;
|
||||||
|
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
|
||||||
|
// Bytes). Kein Fremdwerkzeug nötig.
|
||||||
|
//
|
||||||
|
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
|
||||||
|
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
|
||||||
|
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
|
||||||
|
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
|
||||||
|
// sequenziell ab 0.
|
||||||
|
import { open } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const BLOCK_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
export type IsoErgebnis =
|
||||||
|
| { status: 'success'; bytes: number }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string; bytes: number }
|
||||||
|
|
||||||
|
export async function isoSichern(
|
||||||
|
geraetepfad: string,
|
||||||
|
zielDatei: string,
|
||||||
|
erwarteteBytes: number,
|
||||||
|
aufFortschritt?: (prozent: number) => void,
|
||||||
|
abbruch?: AbortSignal,
|
||||||
|
): Promise<IsoErgebnis> {
|
||||||
|
if (erwarteteBytes <= 0) {
|
||||||
|
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
|
||||||
|
}
|
||||||
|
let quelle
|
||||||
|
try {
|
||||||
|
quelle = await open(geraetepfad, 'r')
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
let ziel
|
||||||
|
try {
|
||||||
|
ziel = await open(zielDatei, 'w')
|
||||||
|
} catch (fehler) {
|
||||||
|
await quelle.close()
|
||||||
|
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const puffer = Buffer.alloc(BLOCK_BYTES)
|
||||||
|
let gelesen = 0
|
||||||
|
let letzterProzent = -1
|
||||||
|
try {
|
||||||
|
while (gelesen < erwarteteBytes) {
|
||||||
|
if (abbruch?.aborted === true) return { status: 'cancelled' }
|
||||||
|
const rest = erwarteteBytes - gelesen
|
||||||
|
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
|
||||||
|
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
|
||||||
|
if (bytesRead <= 0) break
|
||||||
|
await ziel.write(puffer, 0, bytesRead)
|
||||||
|
gelesen += bytesRead
|
||||||
|
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
|
||||||
|
if (prozent !== letzterProzent) {
|
||||||
|
letzterProzent = prozent
|
||||||
|
aufFortschritt?.(prozent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
|
||||||
|
} finally {
|
||||||
|
await quelle.close()
|
||||||
|
await ziel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gelesen !== erwarteteBytes) {
|
||||||
|
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
|
||||||
|
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
|
||||||
|
bytes: gelesen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: 'success', bytes: gelesen }
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
|
||||||
|
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
|
||||||
|
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
|
||||||
|
//
|
||||||
|
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
|
||||||
|
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
|
||||||
|
// hing bzw. stürzte — Befund 23.07.)
|
||||||
|
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
|
||||||
|
// Kodierungen; „Das Öffnen" war die Folge von text=True.
|
||||||
|
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
|
||||||
|
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
||||||
|
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
||||||
|
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { mkdirSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
KRITISCHE_CODES,
|
||||||
|
KRITISCHE_TEXTE,
|
||||||
|
LESEFEHLER_MARKE,
|
||||||
|
parseMsg,
|
||||||
|
parseStreamInfo,
|
||||||
|
parseTitelInfo,
|
||||||
|
progressAusPrgv,
|
||||||
|
quelle,
|
||||||
|
sprachenZusammenfassen,
|
||||||
|
textVon,
|
||||||
|
type SprachEintrag,
|
||||||
|
type TitelInfo,
|
||||||
|
type TitelStreams,
|
||||||
|
} from './parser'
|
||||||
|
|
||||||
|
export function buildInfoArgs(geraetepfad: string): string[] {
|
||||||
|
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
||||||
|
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||||
|
export class RipAuswertung {
|
||||||
|
prozent = -1
|
||||||
|
letzteMeldung = ''
|
||||||
|
lesefehler = false
|
||||||
|
kritischeMeldungen: string[] = []
|
||||||
|
|
||||||
|
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||||
|
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||||
|
const p = progressAusPrgv(zeile)
|
||||||
|
if (p >= 0) {
|
||||||
|
if (p === this.prozent) return {}
|
||||||
|
this.prozent = p
|
||||||
|
return { prozent: p }
|
||||||
|
}
|
||||||
|
const meldung = parseMsg(zeile)
|
||||||
|
if (meldung === null) return {}
|
||||||
|
this.letzteMeldung = meldung.text
|
||||||
|
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||||
|
if (meldung.code in KRITISCHE_CODES) {
|
||||||
|
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||||
|
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||||
|
this.kritischeMeldungen.push(meldung.text)
|
||||||
|
}
|
||||||
|
return { meldung }
|
||||||
|
}
|
||||||
|
|
||||||
|
fehlerText(returncode: number | null, dateienDa: boolean): string {
|
||||||
|
return (
|
||||||
|
`makemkvcon endete mit Code ${returncode ?? '?'}` +
|
||||||
|
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
|
||||||
|
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
|
||||||
|
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
|
||||||
|
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
|
||||||
|
export class ZeilenLeser {
|
||||||
|
private rest: Buffer = Buffer.alloc(0)
|
||||||
|
|
||||||
|
hinzu(stueck: Buffer): string[] {
|
||||||
|
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
|
||||||
|
const zeilen: string[] = []
|
||||||
|
for (;;) {
|
||||||
|
const umbruch = this.rest.indexOf(0x0a)
|
||||||
|
if (umbruch < 0) break
|
||||||
|
let ende = umbruch
|
||||||
|
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
|
||||||
|
zeilen.push(textVon(this.rest.subarray(0, ende)))
|
||||||
|
this.rest = this.rest.subarray(umbruch + 1)
|
||||||
|
}
|
||||||
|
return zeilen
|
||||||
|
}
|
||||||
|
|
||||||
|
schluss(): string[] {
|
||||||
|
if (this.rest.length === 0) return []
|
||||||
|
const letzte = textVon(this.rest)
|
||||||
|
this.rest = Buffer.alloc(0)
|
||||||
|
return letzte.trim().length > 0 ? [letzte] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscInfo {
|
||||||
|
titel: TitelInfo[]
|
||||||
|
streams: Map<number, TitelStreams>
|
||||||
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
||||||
|
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
|
||||||
|
export function titelInfoLesen(
|
||||||
|
programm: string,
|
||||||
|
geraetepfad: string,
|
||||||
|
timeoutMs = 300_000,
|
||||||
|
): Promise<DiscInfo> {
|
||||||
|
return new Promise((fertig, scheitert) => {
|
||||||
|
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
|
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
|
const zeiger = setTimeout(() => {
|
||||||
|
kind.kill()
|
||||||
|
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||||
|
}, timeoutMs)
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
scheitert(fehler)
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||||
|
const titel = parseTitelInfo(ausgabe)
|
||||||
|
const streams = parseStreamInfo(ausgabe)
|
||||||
|
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RipErgebnis =
|
||||||
|
| { status: 'success'; dateien: string[]; lesefehler: boolean }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string; lesefehler: boolean }
|
||||||
|
|
||||||
|
export interface RipLaufOptionen {
|
||||||
|
programm: string
|
||||||
|
geraetepfad: string
|
||||||
|
zielOrdner: string
|
||||||
|
/** 'all' oder eine Titel-Nummer als Text. */
|
||||||
|
titel: string
|
||||||
|
aufFortschritt?: (prozent: number) => void
|
||||||
|
aufMeldung?: (code: number, text: string) => void
|
||||||
|
abbruch?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
|
||||||
|
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
|
||||||
|
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
mkdirSync(optionen.zielOrdner, { recursive: true })
|
||||||
|
|
||||||
|
const kind = spawn(
|
||||||
|
optionen.programm,
|
||||||
|
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
||||||
|
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const auswertung = new RipAuswertung()
|
||||||
|
const leser = new ZeilenLeser()
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
const abbrechen = (): void => {
|
||||||
|
abgebrochen = true
|
||||||
|
kind.kill()
|
||||||
|
}
|
||||||
|
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||||
|
|
||||||
|
const zeile = (z: string): void => {
|
||||||
|
const ergebnis = auswertung.verarbeite(z)
|
||||||
|
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||||
|
if (ergebnis.meldung !== undefined) {
|
||||||
|
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
|
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
|
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
kind.on('close', (code) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
leser.schluss().forEach(zeile)
|
||||||
|
if (abgebrochen) {
|
||||||
|
fertig({ status: 'cancelled' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dateien: string[] = []
|
||||||
|
try {
|
||||||
|
dateien = readdirSync(optionen.zielOrdner)
|
||||||
|
.filter((name) => name.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
|
.map((name) => join(optionen.zielOrdner, name))
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code === 0 && dateien.length > 0) {
|
||||||
|
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({
|
||||||
|
status: 'error',
|
||||||
|
fehler: auswertung.fehlerText(code, dateien.length > 0),
|
||||||
|
lesefehler: auswertung.lesefehler,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// makemkvcon-Ausgabe lesen — reine Textverarbeitung, vollständig testbar
|
||||||
|
// ohne Laufwerk (KONZEPT § 6.2). Format laut
|
||||||
|
// https://www.makemkv.com/developers/usage.txt (Robot-Mode -r); jede
|
||||||
|
// Eigenheit hier ist im Docker-Zweig an echten Discs GEMESSEN worden
|
||||||
|
// (ripping.py / makemkv_aufruf.py) und wandert mit ihren Testfällen mit.
|
||||||
|
|
||||||
|
// ── Quelle: Wie MakeMKV DIESES Laufwerk nennt ───────────────────────────
|
||||||
|
// Der 29.08.-Befund: `dev:\\.\G:` → MSG 2024 „Unknown device";
|
||||||
|
// `dev:G:` → 68 TINFO-Zeilen. CreateFileW braucht den Geräte-Namensraum,
|
||||||
|
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
|
||||||
|
// Pfades, nicht nach dem Rechner.
|
||||||
|
export function quelle(geraetepfad: string): string {
|
||||||
|
let pfad = (geraetepfad ?? '').trim()
|
||||||
|
if (pfad.length === 0) return ''
|
||||||
|
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
|
||||||
|
if (pfad.startsWith(vorsatz)) {
|
||||||
|
pfad = pfad.slice(vorsatz.length)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// „G:\" → „G:" — ohne Trenner ist es das, was MakeMKV selbst ausgibt.
|
||||||
|
if (pfad.length === 3 && pfad[1] === ':' && (pfad[2] === '\\' || pfad[2] === '/')) {
|
||||||
|
pfad = pfad.slice(0, 2)
|
||||||
|
}
|
||||||
|
return `dev:${pfad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kodierung: BINÄR lesen, dann bestimmen statt behaupten ──────────────
|
||||||
|
// makemkvcon mischt Kodierungen je nach Konsole (UTF-8 oder cp1252): Beim
|
||||||
|
// Commander wurde aus „Öffnen" ein „Öffnen". UTF-8 STRENG versuchen,
|
||||||
|
// sonst cp1252 — mit errors=replace ginge jeder Umlaut still unter.
|
||||||
|
export function textVon(roh: Buffer | string): string {
|
||||||
|
if (typeof roh === 'string') return roh
|
||||||
|
if (roh.length === 0) return ''
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(roh)
|
||||||
|
} catch {
|
||||||
|
// cp1252 kann jedes Byte abbilden — hier kann nichts mehr scheitern.
|
||||||
|
return new TextDecoder('windows-1252').decode(roh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MSG-Zeilen ──────────────────────────────────────────────────────────
|
||||||
|
// MSG:code,flags,count,"message",… — Feld 4 ist der fertige Klartext.
|
||||||
|
// Ein naives split(",") schnitt jede Meldung mit Komma ab (25.07.2026) —
|
||||||
|
// und MakeMKV schreibt solche laufend.
|
||||||
|
const MSG_ZEILE = /^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"/
|
||||||
|
|
||||||
|
export function parseMsg(zeile: string): { code: number; text: string } | null {
|
||||||
|
const treffer = MSG_ZEILE.exec(zeile.trim())
|
||||||
|
if (treffer === null) return null
|
||||||
|
return { code: Number(treffer[1]), text: treffer[2].replaceAll('\\"', '"') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PRGV: Gesamtfortschritt ─────────────────────────────────────────────
|
||||||
|
// PRGV:current,total,max — total/max ist der Gesamtfortschritt.
|
||||||
|
// -1 heißt „keine Angabe": Ein echtes 0 % muss von „nichts" unterscheidbar
|
||||||
|
// bleiben, sonst friert der Anfang jedes Rips optisch ein.
|
||||||
|
export function progressAusPrgv(zeile: string): number {
|
||||||
|
const treffer = /^PRGV:(\d+),(\d+),(\d+)/.exec(zeile.trim())
|
||||||
|
if (treffer === null) return -1
|
||||||
|
const gesamt = Number(treffer[2])
|
||||||
|
const maximum = Number(treffer[3])
|
||||||
|
if (maximum <= 0) return -1
|
||||||
|
return Math.min(100, Math.floor((gesamt * 100) / maximum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
|
||||||
|
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
|
||||||
|
// ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||||
|
export interface TitelInfo {
|
||||||
|
nr: number
|
||||||
|
dauerS: number
|
||||||
|
groesseBytes: number
|
||||||
|
kapitel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TINFO_ZEILE = /^TINFO:(\d+),(\d+),\d+,"(.*)"\s*$/
|
||||||
|
|
||||||
|
export function parseTitelInfo(ausgabe: string): TitelInfo[] {
|
||||||
|
const titel = new Map<number, TitelInfo>()
|
||||||
|
for (const roh of ausgabe.split(/\r?\n/)) {
|
||||||
|
const treffer = TINFO_ZEILE.exec(roh.trim())
|
||||||
|
if (treffer === null) continue
|
||||||
|
const nr = Number(treffer[1])
|
||||||
|
const attr = Number(treffer[2])
|
||||||
|
const wert = treffer[3]
|
||||||
|
let eintrag = titel.get(nr)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 }
|
||||||
|
titel.set(nr, eintrag)
|
||||||
|
}
|
||||||
|
if (attr === 9) {
|
||||||
|
const teile = wert.split(':')
|
||||||
|
if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) {
|
||||||
|
eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2])
|
||||||
|
}
|
||||||
|
} else if (attr === 11 && /^\d+$/.test(wert)) {
|
||||||
|
eintrag.groesseBytes = Number(wert)
|
||||||
|
} else if (attr === 8 && /^\d+$/.test(wert)) {
|
||||||
|
eintrag.kapitel = Number(wert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SINFO: Tonspuren und Untertitel ─────────────────────────────────────
|
||||||
|
// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen:
|
||||||
|
// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname,
|
||||||
|
// 6 = Codec, 14 = Kanäle, 30 = Beschreibung.
|
||||||
|
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
|
||||||
|
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
|
||||||
|
// jede Disc für englisch.
|
||||||
|
export interface StreamInfo {
|
||||||
|
nr: number
|
||||||
|
typ: string
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
codec: string
|
||||||
|
kanaele: string
|
||||||
|
beschreibung: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitelStreams {
|
||||||
|
audio: StreamInfo[]
|
||||||
|
untertitel: StreamInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
|
||||||
|
|
||||||
|
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
||||||
|
const titel = new Map<number, Map<number, StreamInfo>>()
|
||||||
|
for (const roh of (ausgabe ?? '').split(/\r?\n/)) {
|
||||||
|
const treffer = SINFO_ZEILE.exec(roh.trim())
|
||||||
|
if (treffer === null) continue
|
||||||
|
const tNr = Number(treffer[1])
|
||||||
|
const sNr = Number(treffer[2])
|
||||||
|
const attr = Number(treffer[3])
|
||||||
|
const wert = treffer[4]
|
||||||
|
let streams = titel.get(tNr)
|
||||||
|
if (streams === undefined) {
|
||||||
|
streams = new Map()
|
||||||
|
titel.set(tNr, streams)
|
||||||
|
}
|
||||||
|
let eintrag = streams.get(sNr)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
|
||||||
|
streams.set(sNr, eintrag)
|
||||||
|
}
|
||||||
|
if (attr === 1) eintrag.typ = wert
|
||||||
|
else if (attr === 3) eintrag.lang = wert
|
||||||
|
else if (attr === 4) eintrag.sprache = wert
|
||||||
|
else if (attr === 6) eintrag.codec = wert
|
||||||
|
else if (attr === 14) eintrag.kanaele = wert
|
||||||
|
else if (attr === 30) eintrag.beschreibung = wert
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = new Map<number, TitelStreams>()
|
||||||
|
for (const [tNr, streams] of titel) {
|
||||||
|
const audio: StreamInfo[] = []
|
||||||
|
const untertitel: StreamInfo[] = []
|
||||||
|
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
|
||||||
|
const s = streams.get(sNr)!
|
||||||
|
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
|
||||||
|
if (s.typ === 'Audio') audio.push(s)
|
||||||
|
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
|
||||||
|
}
|
||||||
|
ergebnis.set(tNr, { audio, untertitel })
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SprachEintrag {
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
spuren: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||||
|
* Reihenfolge stabil (wie auf der Disc). */
|
||||||
|
export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>): {
|
||||||
|
audio: SprachEintrag[]
|
||||||
|
untertitel: SprachEintrag[]
|
||||||
|
} {
|
||||||
|
const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => {
|
||||||
|
const gefunden = new Map<string, SprachEintrag>()
|
||||||
|
for (const streams of streamsJeTitel.values()) {
|
||||||
|
for (const s of streams[schluessel]) {
|
||||||
|
if (s.lang.length === 0) continue
|
||||||
|
let eintrag = gefunden.get(s.lang)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 }
|
||||||
|
gefunden.set(s.lang, eintrag)
|
||||||
|
}
|
||||||
|
eintrag.spuren += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...gefunden.values()]
|
||||||
|
}
|
||||||
|
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
|
||||||
|
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
|
||||||
|
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
|
||||||
|
* vermeidet „Play All"-Titel, die länger als der Film sind. */
|
||||||
|
export function laengsterTitel(dauern: Map<number, number>, sollMinuten = 0): number | null {
|
||||||
|
if (dauern.size === 0) return null
|
||||||
|
const zielS = sollMinuten * 60
|
||||||
|
if (zielS > 0) {
|
||||||
|
let bester: number | null = null
|
||||||
|
let abstand = Number.POSITIVE_INFINITY
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
const d = Math.abs(dauer - zielS)
|
||||||
|
if (d < abstand) {
|
||||||
|
abstand = d
|
||||||
|
bester = nr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bester !== null && abstand <= 20 * 60) return bester
|
||||||
|
}
|
||||||
|
let laengster: number | null = null
|
||||||
|
let max = -1
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
if (dauer > max) {
|
||||||
|
max = dauer
|
||||||
|
laengster = nr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laengster
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der
|
||||||
|
* bekannten Episoden-Laufzeiten (Minuten) liegt. */
|
||||||
|
export function episodenTitel(dauern: Map<number, number>, episodenMinuten: number[]): number[] {
|
||||||
|
if (dauern.size === 0 || episodenMinuten.length === 0) return []
|
||||||
|
const laufzeitenS = episodenMinuten.map((m) => m * 60)
|
||||||
|
const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length
|
||||||
|
const gefunden: number[] = []
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr)
|
||||||
|
}
|
||||||
|
return gefunden.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fehler-Erkennung ────────────────────────────────────────────────────
|
||||||
|
// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht
|
||||||
|
// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da
|
||||||
|
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||||
|
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
||||||
|
|
||||||
|
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||||
|
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||||
|
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
||||||
|
export const KRITISCHE_CODES: Record<number, string> = {
|
||||||
|
2024: 'MakeMKV kennt dieses Laufwerk nicht',
|
||||||
|
5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus',
|
||||||
|
5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen',
|
||||||
|
5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren',
|
||||||
|
// Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt
|
||||||
|
// gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere
|
||||||
|
// DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2,
|
||||||
|
// GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt,
|
||||||
|
// und diese Ebene blieb hängen.
|
||||||
|
5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten',
|
||||||
|
5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */
|
||||||
|
export const KEY_ABGELEHNT = [5020, 5021]
|
||||||
|
|
||||||
|
// Text-Rückfall für englische Installationen — die NUMMERN sind die
|
||||||
|
// verlässliche Quelle (beim Commander meldet MakeMKV deutsch).
|
||||||
|
export const KRITISCHE_TEXTE = [
|
||||||
|
'volume key is unknown',
|
||||||
|
'evaluation period has expired',
|
||||||
|
'registration key',
|
||||||
|
'Too old version',
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
|
||||||
|
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
|
||||||
|
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
|
||||||
|
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
|
||||||
|
// einer Konfigurationsdatei, die niemand schrieb.
|
||||||
|
//
|
||||||
|
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
|
||||||
|
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
|
||||||
|
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
export class Datenbank {
|
||||||
|
readonly pfad: string
|
||||||
|
private db: DatabaseSync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet (oder erzeugt) die Datenbank und legt das Schema an.
|
||||||
|
* Wirft bei nicht anlegbarem Pfad — der Aufrufer meldet den Fehler
|
||||||
|
* LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten.
|
||||||
|
*/
|
||||||
|
constructor(pfad: string) {
|
||||||
|
this.pfad = pfad
|
||||||
|
if (pfad !== ':memory:') {
|
||||||
|
mkdirSync(dirname(pfad), { recursive: true })
|
||||||
|
}
|
||||||
|
this.db = new DatabaseSync(pfad)
|
||||||
|
this.db.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS bibliothek (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
jahr INTEGER,
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
groesse_bytes INTEGER NOT NULL,
|
||||||
|
ablageort TEXT NOT NULL,
|
||||||
|
datum TEXT NOT NULL,
|
||||||
|
dauer_s INTEGER NOT NULL,
|
||||||
|
poster_pfad TEXT NOT NULL DEFAULT ''
|
||||||
|
);`,
|
||||||
|
)
|
||||||
|
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
||||||
|
// Poster-Spalte — nachrüsten, ohne Einträge anzufassen.
|
||||||
|
const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }>
|
||||||
|
if (!spalten.some((s) => s.name === 'poster_pfad')) {
|
||||||
|
this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
this.db
|
||||||
|
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
|
||||||
|
.run(String(SCHEMA_VERSION))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */
|
||||||
|
einstellungLesen(schluessel: string): string | null {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`)
|
||||||
|
.get(schluessel) as { wert: string } | undefined
|
||||||
|
return zeile === undefined ? null : zeile.wert
|
||||||
|
}
|
||||||
|
|
||||||
|
einstellungSchreiben(schluessel: string, wert: string): void {
|
||||||
|
this.db
|
||||||
|
.prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`)
|
||||||
|
.run(schluessel, wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaVersion(): number {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`)
|
||||||
|
.get() as { wert: string } | undefined
|
||||||
|
return zeile === undefined ? 0 : Number(zeile.wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am
|
||||||
|
// Disc-Fingerabdruck ────────────────────────────────────────────────
|
||||||
|
bibliothekEintragen(eintrag: BibliothekEintrag): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO bibliothek
|
||||||
|
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
eintrag.fingerprint,
|
||||||
|
eintrag.titel,
|
||||||
|
eintrag.jahr,
|
||||||
|
eintrag.typ,
|
||||||
|
eintrag.groesseBytes,
|
||||||
|
eintrag.ablageort,
|
||||||
|
eintrag.datum,
|
||||||
|
eintrag.dauerS,
|
||||||
|
eintrag.posterPfad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
bibliothekListe(): BibliothekEintrag[] {
|
||||||
|
const zeilen = this.db
|
||||||
|
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
|
||||||
|
.all() as Array<Record<string, unknown>>
|
||||||
|
return zeilen.map((z) => ({
|
||||||
|
fingerprint: String(z.fingerprint),
|
||||||
|
titel: String(z.titel),
|
||||||
|
jahr: z.jahr === null ? null : Number(z.jahr),
|
||||||
|
typ: String(z.typ),
|
||||||
|
groesseBytes: Number(z.groesse_bytes),
|
||||||
|
ablageort: String(z.ablageort),
|
||||||
|
datum: String(z.datum),
|
||||||
|
dauerS: Number(z.dauer_s),
|
||||||
|
posterPfad: String(z.poster_pfad ?? ''),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum des früheren Rips dieser Disc — oder null (neu). */
|
||||||
|
bibliothekSchonGerippt(fingerprint: string): string | null {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`)
|
||||||
|
.get(fingerprint) as { datum: string } | undefined
|
||||||
|
return zeile === undefined ? null : zeile.datum
|
||||||
|
}
|
||||||
|
|
||||||
|
schliessen(): void {
|
||||||
|
this.db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage
|
||||||
|
// tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am
|
||||||
|
// 31.08.2026 nachgemessen):
|
||||||
|
//
|
||||||
|
// Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht)
|
||||||
|
// Hersteller-Forum HTTP 200, 0,2 s → 1.18.4
|
||||||
|
// Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2
|
||||||
|
//
|
||||||
|
// Daraus folgen die drei Regeln dieser Datei:
|
||||||
|
// 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich.
|
||||||
|
// Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die
|
||||||
|
// HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus,
|
||||||
|
// würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste
|
||||||
|
// Fassung" verkauft.
|
||||||
|
// 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht
|
||||||
|
// (Get-AuthenticodeSignature → NotSigned, an der echten Datei
|
||||||
|
// gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft
|
||||||
|
// inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026
|
||||||
|
// an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!).
|
||||||
|
// Das ersetzt keine Signatur, fängt aber ab, was wirklich droht:
|
||||||
|
// Fehlerseite, halber Download, falsche Version.
|
||||||
|
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
||||||
|
// `.neu`, geprüft, erst dann getauscht.
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
import { werkzeugOrdner } from './katalog'
|
||||||
|
|
||||||
|
export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/'
|
||||||
|
export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3'
|
||||||
|
// Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das
|
||||||
|
// Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py).
|
||||||
|
const ARCHIV_STAND = '2025'
|
||||||
|
const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe'
|
||||||
|
|
||||||
|
const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g
|
||||||
|
|
||||||
|
// Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und
|
||||||
|
// begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine
|
||||||
|
// Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin.
|
||||||
|
const VERSIONS_TIMEOUT_MS = 8_000
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 600_000
|
||||||
|
const VERSUCHE = 2
|
||||||
|
const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000
|
||||||
|
|
||||||
|
// Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern
|
||||||
|
// eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde.
|
||||||
|
const MINDESTGROESSE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
const MAKEMKV_FIRMA = 'GuinpinSoft'
|
||||||
|
const MAKEMKV_PRODUKT = 'MakeMKV'
|
||||||
|
|
||||||
|
export class BeschaffungsFehler extends Error {}
|
||||||
|
|
||||||
|
/** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH
|
||||||
|
* verglichen — als Zeichenkette wäre „1.9.2" größer als „1.18.4". */
|
||||||
|
export function hoechsteVersion(text: string): string {
|
||||||
|
const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1])
|
||||||
|
if (funde.length === 0) return ''
|
||||||
|
return funde.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */
|
||||||
|
export function istNeuer(neu: string, alt: string): boolean {
|
||||||
|
const teile = (v: string): number[] =>
|
||||||
|
(v ?? '')
|
||||||
|
.split('.')
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map(Number)
|
||||||
|
const a = teile(neu)
|
||||||
|
const b = teile(alt)
|
||||||
|
if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
const x = a[i] ?? 0
|
||||||
|
const y = b[i] ?? 0
|
||||||
|
if (x !== y) return x > y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionsQuelle {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
/** Diese Quelle IST die Wahrheit — wer sie erreicht, sucht nicht weiter.
|
||||||
|
* Gilt nur für den Hersteller selbst. */
|
||||||
|
massgeblich: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionsquellen(): VersionsQuelle[] {
|
||||||
|
return [
|
||||||
|
{ name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true },
|
||||||
|
{ name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false },
|
||||||
|
{
|
||||||
|
name: 'Archiv der Hersteller-Seite',
|
||||||
|
url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`,
|
||||||
|
massgeblich: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seiteLaden(url: string, holen: typeof fetch): Promise<string> {
|
||||||
|
let letzter: unknown = null
|
||||||
|
for (let versuch = 0; versuch < VERSUCHE; versuch++) {
|
||||||
|
try {
|
||||||
|
const antwort = await holen(url, {
|
||||||
|
signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`)
|
||||||
|
return await antwort.text()
|
||||||
|
} catch (fehler) {
|
||||||
|
letzter = fehler
|
||||||
|
if (versuch + 1 < VERSUCHE) {
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw letzter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */
|
||||||
|
export async function makemkvNeuesteVersion(
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
quellen: VersionsQuelle[] = versionsquellen(),
|
||||||
|
): Promise<string> {
|
||||||
|
const gefunden: string[] = []
|
||||||
|
for (const quelle of quellen) {
|
||||||
|
let text: string
|
||||||
|
try {
|
||||||
|
text = await seiteLaden(quelle.url, holen)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const version = hoechsteVersion(text)
|
||||||
|
if (version.length === 0) continue
|
||||||
|
if (quelle.massgeblich) return version
|
||||||
|
gefunden.push(version)
|
||||||
|
}
|
||||||
|
if (gefunden.length === 0) return ''
|
||||||
|
return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis
|
||||||
|
* (wer sie einträgt, hat sich etwas gedacht) → Hersteller → Archiv. */
|
||||||
|
export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> {
|
||||||
|
const herstellerUrl = MAKEMKV_DATEI.replace('%s', version)
|
||||||
|
const quellen: Array<[string, string]> = []
|
||||||
|
if (basis.trim().length > 0) {
|
||||||
|
quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`])
|
||||||
|
}
|
||||||
|
quellen.push(['Hersteller', herstellerUrl])
|
||||||
|
// `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen).
|
||||||
|
quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`])
|
||||||
|
return quellen
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Versionsangaben {
|
||||||
|
firma: string
|
||||||
|
produkt: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Versions-Ressource einer EXE — über PowerShells FileVersionInfo,
|
||||||
|
* damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */
|
||||||
|
export function versionsangaben(dateiPfad: string): Versionsangaben {
|
||||||
|
const leer = { firma: '', produkt: '', version: '' }
|
||||||
|
if (process.platform !== 'win32') return leer
|
||||||
|
// ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein
|
||||||
|
// wörtliches '…'-Literal nicht.
|
||||||
|
const skript =
|
||||||
|
`$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` +
|
||||||
|
'@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress'
|
||||||
|
const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], {
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 20_000,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (lauf.status !== 0) return leer
|
||||||
|
try {
|
||||||
|
const roh: unknown = JSON.parse(lauf.stdout ?? '')
|
||||||
|
if (typeof roh !== 'object' || roh === null) return leer
|
||||||
|
const angaben = roh as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '',
|
||||||
|
produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '',
|
||||||
|
version: typeof angaben['version'] === 'string' ? angaben['version'] : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return leer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */
|
||||||
|
export function dateiPruefen(
|
||||||
|
dateiPfad: string,
|
||||||
|
version: string,
|
||||||
|
angaben: (p: string) => Versionsangaben = versionsangaben,
|
||||||
|
plattform: NodeJS.Platform = process.platform,
|
||||||
|
): { ok: boolean; grund: string } {
|
||||||
|
// Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte
|
||||||
|
// Fehlerseite tut das nicht.
|
||||||
|
const kopf = Buffer.alloc(2)
|
||||||
|
try {
|
||||||
|
const datei = openSync(dateiPfad, 'r')
|
||||||
|
try {
|
||||||
|
readSync(datei, kopf, 0, 2, 0)
|
||||||
|
} finally {
|
||||||
|
closeSync(datei)
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` }
|
||||||
|
}
|
||||||
|
if (kopf.toString('latin1') !== 'MZ') {
|
||||||
|
return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' }
|
||||||
|
}
|
||||||
|
if (plattform !== 'win32') {
|
||||||
|
// Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen,
|
||||||
|
// mehr ist dort nicht zu holen (CI läuft auf Linux).
|
||||||
|
return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' }
|
||||||
|
}
|
||||||
|
const info = angaben(dateiPfad)
|
||||||
|
if (!info.firma.includes(MAKEMKV_FIRMA)) {
|
||||||
|
return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` }
|
||||||
|
}
|
||||||
|
if (!info.produkt.includes(MAKEMKV_PRODUKT)) {
|
||||||
|
return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` }
|
||||||
|
}
|
||||||
|
if (version.length > 0 && !info.version.includes(version)) {
|
||||||
|
return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` }
|
||||||
|
}
|
||||||
|
return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateiLaden(
|
||||||
|
url: string,
|
||||||
|
ziel: string,
|
||||||
|
melden: (text: string, anteil: number | null) => void,
|
||||||
|
holen: typeof fetch,
|
||||||
|
): Promise<void> {
|
||||||
|
const antwort = await holen(url, {
|
||||||
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`)
|
||||||
|
if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt')
|
||||||
|
const erwartet = Number(antwort.headers.get('content-length') ?? '0')
|
||||||
|
const schreiber = createWriteStream(ziel)
|
||||||
|
const leser = antwort.body.getReader()
|
||||||
|
let geladen = 0
|
||||||
|
let letzteStufe = -1
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await leser.read()
|
||||||
|
if (done) break
|
||||||
|
geladen += value.byteLength
|
||||||
|
await new Promise<void>((weiter, kaputt) => {
|
||||||
|
schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter()))
|
||||||
|
})
|
||||||
|
if (erwartet > 0) {
|
||||||
|
const anteil = Math.min(1, geladen / erwartet)
|
||||||
|
// Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen
|
||||||
|
// wären sonst hundert Zeilen.
|
||||||
|
const stufe = Math.floor(anteil * 10)
|
||||||
|
if (stufe !== letzteStufe) {
|
||||||
|
letzteStufe = stufe
|
||||||
|
melden(
|
||||||
|
`${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`,
|
||||||
|
anteil * 0.85,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((weiter) => schreiber.end(weiter))
|
||||||
|
}
|
||||||
|
const groesse = statSync(ziel).size
|
||||||
|
if (groesse < MINDESTGROESSE_BYTES) {
|
||||||
|
throw new BeschaffungsFehler(
|
||||||
|
`Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeschaffungErgebnis {
|
||||||
|
pfad: string
|
||||||
|
quelle: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
||||||
|
* Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** — es lädt die
|
||||||
|
* offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */
|
||||||
|
export async function makemkvInstallerHolen(
|
||||||
|
version: string,
|
||||||
|
melden: (text: string, anteil: number | null) => void,
|
||||||
|
basis = '',
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
||||||
|
pruefen: typeof dateiPruefen = dateiPruefen,
|
||||||
|
): Promise<BeschaffungErgebnis> {
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
||||||
|
const vorlaeufig = `${ziel}.neu`
|
||||||
|
|
||||||
|
const fehlschlaege: string[] = []
|
||||||
|
for (const [name, url] of makemkvQuellen(version, basis)) {
|
||||||
|
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
||||||
|
try {
|
||||||
|
await dateiLaden(url, vorlaeufig, melden, holen)
|
||||||
|
const pruefung = pruefen(vorlaeufig, version)
|
||||||
|
if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund)
|
||||||
|
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||||||
|
// Datei nicht ersetzen.
|
||||||
|
renameSync(vorlaeufig, ziel)
|
||||||
|
return { pfad: ziel, quelle: name, version }
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
||||||
|
melden(`${name} hat nicht geliefert.`, null)
|
||||||
|
try {
|
||||||
|
rmSync(vorlaeufig, { force: true })
|
||||||
|
} catch {
|
||||||
|
// Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BeschaffungsFehler(
|
||||||
|
`Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` +
|
||||||
|
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9).
|
||||||
|
// Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am
|
||||||
|
// Commander-PC gemessen:
|
||||||
|
//
|
||||||
|
// 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den.
|
||||||
|
// 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) —
|
||||||
|
// VOR dem System, damit eine gepflegte Fassung eine alte Installation
|
||||||
|
// schlägt.
|
||||||
|
// 3. PATH — der „selbst eingetragen"-Fall.
|
||||||
|
// 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall.
|
||||||
|
// 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER
|
||||||
|
// (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den
|
||||||
|
// Programmordner.
|
||||||
|
//
|
||||||
|
// ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern
|
||||||
|
// PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte
|
||||||
|
// Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen
|
||||||
|
// (katalog.py, 28.08.2026).
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
// Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade,
|
||||||
|
// und die CI-Tests laufen auf Linux — dort würde das plattformabhängige
|
||||||
|
// join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226).
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
|
||||||
|
const { delimiter, join } = pfad
|
||||||
|
|
||||||
|
export interface Werkzeug {
|
||||||
|
titel: string
|
||||||
|
zweck: string
|
||||||
|
programme: readonly string[]
|
||||||
|
orte: readonly string[]
|
||||||
|
/** Teilstring des DisplayName im Uninstall-Zweig der Registry. */
|
||||||
|
anzeige: string
|
||||||
|
pflicht: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WERKZEUGE: Record<string, Werkzeug> = {
|
||||||
|
makemkv: {
|
||||||
|
titel: 'MakeMKV',
|
||||||
|
zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.',
|
||||||
|
programme: ['makemkvcon64.exe', 'makemkvcon.exe'],
|
||||||
|
orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'],
|
||||||
|
anzeige: 'MakeMKV',
|
||||||
|
pflicht: true,
|
||||||
|
},
|
||||||
|
handbrake: {
|
||||||
|
titel: 'HandBrakeCLI',
|
||||||
|
zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.',
|
||||||
|
programme: ['HandBrakeCLI.exe'],
|
||||||
|
orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'],
|
||||||
|
anzeige: 'HandBrake',
|
||||||
|
pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression
|
||||||
|
},
|
||||||
|
flac: {
|
||||||
|
titel: 'FLAC',
|
||||||
|
zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.',
|
||||||
|
programme: ['flac.exe'],
|
||||||
|
orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'],
|
||||||
|
anzeige: 'FLAC',
|
||||||
|
pflicht: false, // ohne FLAC läuft alles außer Audio-CDs
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */
|
||||||
|
export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.'
|
||||||
|
return join(wurzel, 'Rippy', 'tools')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** %ProgramFiles%\X → C:\Program Files\X — Groß/Klein wird IGNORIERT.
|
||||||
|
* Bleibt ein % stehen, ist der Kandidat unbrauchbar → ''. */
|
||||||
|
export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string {
|
||||||
|
let ergebnis = pfad
|
||||||
|
for (const [name, wert] of Object.entries(umgebung)) {
|
||||||
|
if (wert === undefined) continue
|
||||||
|
const marke = `%${name}%`.toUpperCase()
|
||||||
|
for (;;) {
|
||||||
|
const stelle = ergebnis.toUpperCase().indexOf(marke)
|
||||||
|
if (stelle < 0) break
|
||||||
|
ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis.includes('%') ? '' : ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */
|
||||||
|
export function kandidaten(
|
||||||
|
name: string,
|
||||||
|
eigenerOrdner: string,
|
||||||
|
umgebung: NodeJS.ProcessEnv,
|
||||||
|
): string[] {
|
||||||
|
const werkzeug = WERKZEUGE[name]
|
||||||
|
const pfade: string[] = []
|
||||||
|
// Das MITGELIEFERTE Werkzeug (§ 9) — im installierten Rippy zeigt
|
||||||
|
// RIPPY_VENDOR auf resources\vendor (der Haupt setzt es beim Kern-Start).
|
||||||
|
const vendor = umgebung['RIPPY_VENDOR'] ?? ''
|
||||||
|
if (vendor.length > 0) {
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(vendor, name, programm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(eigenerOrdner, name, programm))
|
||||||
|
pfade.push(join(eigenerOrdner, programm))
|
||||||
|
}
|
||||||
|
for (const ort of werkzeug.orte) {
|
||||||
|
const entfaltet = entfalten(ort, umgebung)
|
||||||
|
if (entfaltet.length === 0) continue
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(entfaltet, programm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pfade
|
||||||
|
}
|
||||||
|
|
||||||
|
function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string {
|
||||||
|
const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? ''
|
||||||
|
for (const ordner of pfadVar.split(delimiter)) {
|
||||||
|
if (ordner.length === 0) continue
|
||||||
|
for (const programm of programme) {
|
||||||
|
const kandidat = join(ordner, programm)
|
||||||
|
if (existsSync(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist
|
||||||
|
* InstallLocation leer — DisplayIcon zeigt auf eine Datei im
|
||||||
|
* Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit
|
||||||
|
* kein zweiter koffi-Ort entsteht (R1). */
|
||||||
|
export function ausUninstallRegistry(
|
||||||
|
anzeige: string,
|
||||||
|
programme: readonly string[],
|
||||||
|
ausfuehren: (wurzel: string) => string = regQuery,
|
||||||
|
): string {
|
||||||
|
const wurzeln = [
|
||||||
|
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
]
|
||||||
|
for (const wurzel of wurzeln) {
|
||||||
|
const ausgabe = ausfuehren(wurzel)
|
||||||
|
// reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT.
|
||||||
|
let passt = false
|
||||||
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
|
if (zeile.startsWith('HK')) {
|
||||||
|
passt = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile)
|
||||||
|
if (wert === null) continue
|
||||||
|
if (wert[1] === 'DisplayName') {
|
||||||
|
passt = wert[2].includes(anzeige)
|
||||||
|
} else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) {
|
||||||
|
const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '')
|
||||||
|
for (const programm of programme) {
|
||||||
|
const kandidat = join(ordner, programm)
|
||||||
|
if (existsSync(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function regQuery(wurzel: string): string {
|
||||||
|
const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
return ergebnis.stdout ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Pfad zum Werkzeug — oder '' wenn es nicht da ist. Prüf-Funktionen
|
||||||
|
* einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */
|
||||||
|
export function finden(
|
||||||
|
name: string,
|
||||||
|
eingestellt = '',
|
||||||
|
eigenerOrdner: string = werkzeugOrdner(),
|
||||||
|
umgebung: NodeJS.ProcessEnv = process.env,
|
||||||
|
existiert: (pfad: string) => boolean = existsSync,
|
||||||
|
registry: typeof ausUninstallRegistry = ausUninstallRegistry,
|
||||||
|
): string {
|
||||||
|
// 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird
|
||||||
|
// NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will
|
||||||
|
// genau den, und ein Tippfehler soll als Fehler sichtbar werden.
|
||||||
|
if (eingestellt.trim().length > 0) {
|
||||||
|
return existiert(eingestellt.trim()) ? eingestellt.trim() : ''
|
||||||
|
}
|
||||||
|
// 2 + 4. Eigener Ordner, dann bekannte Orte.
|
||||||
|
for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) {
|
||||||
|
if (existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
// 3. PATH.
|
||||||
|
const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung)
|
||||||
|
if (imPfad.length > 0) return imPfad
|
||||||
|
// 5. Registry-Rückfall.
|
||||||
|
return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Die MakeMKV-Schlüsselkette (KONZEPT § 9.1) — Vorlagen makemkv_key.py
|
||||||
|
// (Forum-Abruf) und makemkv_aufruf.py (Registry-Ablage + Prüfung, beides
|
||||||
|
// am Commander-PC gemessen):
|
||||||
|
//
|
||||||
|
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
||||||
|
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
||||||
|
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
||||||
|
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
||||||
|
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
||||||
|
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
||||||
|
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
||||||
|
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
||||||
|
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
||||||
|
|
||||||
|
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
||||||
|
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
|
||||||
|
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
|
const KEY_NAME = 'app_Key'
|
||||||
|
|
||||||
|
/** Den Beta-Key aus dem Forum-HTML ziehen (pur). */
|
||||||
|
export function extractKey(html: string): string {
|
||||||
|
const treffer = KEY_MUSTER.exec(html ?? '')
|
||||||
|
return treffer === null ? '' : treffer[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
||||||
|
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
||||||
|
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
|
||||||
|
export function extractGueltigBis(html: string): string {
|
||||||
|
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
|
||||||
|
html ?? '',
|
||||||
|
)
|
||||||
|
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumKey {
|
||||||
|
key: string
|
||||||
|
gueltigBis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der aktuelle Beta-Key aus dem Forum — 8-s-Zeitgrenze je Versuch: Das
|
||||||
|
* Forum fiel bei der Messung in zwei von drei Abrufen aus (§ 9). */
|
||||||
|
export async function betaKeyHolen(holen: typeof fetch = fetch): Promise<ForumKey | null> {
|
||||||
|
for (let versuch = 0; versuch < 3; versuch++) {
|
||||||
|
try {
|
||||||
|
const antwort = await holen(FORUM_URL, {
|
||||||
|
signal: AbortSignal.timeout(8_000),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) continue
|
||||||
|
const html = await antwort.text()
|
||||||
|
const key = extractKey(html)
|
||||||
|
if (key.length > 0) return { key, gueltigBis: extractGueltigBis(html) }
|
||||||
|
} catch {
|
||||||
|
// nächster Versuch — der Fehlschlag wird am Ende als null sichtbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registry-Ablage über reg.exe (kein zweiter koffi-Ort, R1) ───────────
|
||||||
|
// In dieser Registry steckt die Arbeit des Commanders (Laufwerksprofile,
|
||||||
|
// Fensterlage). Geschrieben wird GENAU app_Key und app_UpdateEnable —
|
||||||
|
// gelöscht wird nur app_Key, und nur nach Ablehnung.
|
||||||
|
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||||
|
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||||
|
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyAblegen(key: string): boolean {
|
||||||
|
if (key.trim().length === 0) return false
|
||||||
|
return reg(['add', REGISTRY_PFAD, '/v', KEY_NAME, '/t', 'REG_SZ', '/d', key.trim(), '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyZuruecknehmen(): boolean {
|
||||||
|
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
||||||
|
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
||||||
|
export function updateKanalSicherstellen(): boolean {
|
||||||
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', 'app_UpdateEnable'])
|
||||||
|
if (vorhanden.ok) return false
|
||||||
|
return reg(['add', REGISTRY_PFAD, '/v', 'app_UpdateEnable', '/t', 'REG_DWORD', '/d', '1', '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyUrteil {
|
||||||
|
ok: boolean
|
||||||
|
code: number | null
|
||||||
|
grund: string
|
||||||
|
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
||||||
|
* nicht erkennbar. */
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
||||||
|
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
||||||
|
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
|
||||||
|
*
|
||||||
|
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
||||||
|
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
||||||
|
* „Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
|
||||||
|
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
|
||||||
|
* den falschen. */
|
||||||
|
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
if (programm.length === 0) {
|
||||||
|
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
||||||
|
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
||||||
|
const codes: number[] = []
|
||||||
|
let version = ''
|
||||||
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
|
if (!zeile.startsWith('MSG:')) continue
|
||||||
|
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||||
|
if (!Number.isInteger(code)) continue
|
||||||
|
if (code === 1005) {
|
||||||
|
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
||||||
|
if (treffer !== null) version = treffer[1]
|
||||||
|
}
|
||||||
|
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
||||||
|
}
|
||||||
|
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
|
||||||
|
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
||||||
|
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
||||||
|
const code = codes.includes(5021) ? 5021 : codes[0]
|
||||||
|
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchluesselStand {
|
||||||
|
/** 'dauerlizenz' | 'forum' | 'keiner' */
|
||||||
|
quelle: string
|
||||||
|
angenommen: boolean | null
|
||||||
|
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
|
||||||
|
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
|
||||||
|
weiterBetrieb: boolean
|
||||||
|
gueltigBis: string
|
||||||
|
geholtAm: string
|
||||||
|
text: string
|
||||||
|
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — fürs Update-Urteil. */
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
||||||
|
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
|
||||||
|
export async function schluesselKette(
|
||||||
|
makemkvcon: string,
|
||||||
|
dauerlizenz: string,
|
||||||
|
jetztIso: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<SchluesselStand> {
|
||||||
|
updateKanalSicherstellen()
|
||||||
|
|
||||||
|
if (dauerlizenz.trim().length > 0) {
|
||||||
|
keyAblegen(dauerlizenz)
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
return {
|
||||||
|
quelle: 'dauerlizenz',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
weiterBetrieb: urteil.ok,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
||||||
|
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const forum = await betaKeyHolen(holen)
|
||||||
|
if (forum === null) {
|
||||||
|
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
||||||
|
// wird geprüft, damit der Stand ehrlich ist.
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
return {
|
||||||
|
quelle: 'keiner',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||||
|
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyAblegen(forum.key)
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.ok) {
|
||||||
|
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
||||||
|
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
||||||
|
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
|
||||||
|
keyZuruecknehmen()
|
||||||
|
}
|
||||||
|
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
|
||||||
|
return {
|
||||||
|
quelle: 'forum',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
|
||||||
|
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: forum.gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||||
|
: urteil.code === 5021
|
||||||
|
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
|
||||||
|
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
||||||
|
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||||
|
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
|
||||||
|
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { baueNfo } from '../src/kern/ablage/nfo'
|
||||||
|
import {
|
||||||
|
episodenUmbenennen,
|
||||||
|
matcheEpisoden,
|
||||||
|
serienOrdner,
|
||||||
|
sichererName,
|
||||||
|
zielordner,
|
||||||
|
} from '../src/kern/ablage/struktur'
|
||||||
|
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
|
||||||
|
|
||||||
|
describe('sichererName', () => {
|
||||||
|
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
|
||||||
|
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
|
||||||
|
'Der Herr der Ringe Die Gefährten (2001)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
|
||||||
|
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
|
||||||
|
})
|
||||||
|
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
|
||||||
|
expect(sichererName('...')).toBe('')
|
||||||
|
expect(sichererName('Film.', null)).toBe('Film')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zielordner', () => {
|
||||||
|
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
|
||||||
|
// Erwartungen über join() — die Funktionen arbeiten mit den
|
||||||
|
// Pfadregeln der LAUFZEIT-Plattform (Linux-CI testet mit POSIX).
|
||||||
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
|
||||||
|
join('Filme', 'Akira (1988)'),
|
||||||
|
)
|
||||||
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
|
||||||
|
join('Filme', 'Akira (1988) [job-1234]'),
|
||||||
|
)
|
||||||
|
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serienOrdner', () => {
|
||||||
|
it('Jellyfin-Schema Serie/Season NN', () => {
|
||||||
|
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matcheEpisoden', () => {
|
||||||
|
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1420, 1425, 1418],
|
||||||
|
[
|
||||||
|
[1, 1420],
|
||||||
|
[2, 1420],
|
||||||
|
[3, 1420],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1200, 2400],
|
||||||
|
[
|
||||||
|
[1, 1210],
|
||||||
|
[2, 2390],
|
||||||
|
[3, 3000],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1420, 1421],
|
||||||
|
[
|
||||||
|
[1, 1420],
|
||||||
|
[2, 1420],
|
||||||
|
[3, 1420],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
it('keine Daten heißt null', () => {
|
||||||
|
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
|
||||||
|
expect(matcheEpisoden([100], [])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('episodenUmbenennen', () => {
|
||||||
|
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
|
||||||
|
const umbenannt: Array<[string, string]> = []
|
||||||
|
const meldungen = episodenUmbenennen('S', 'Akte X', 1, [3, 4], {
|
||||||
|
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: (von, nach) => umbenannt.push([von, nach]),
|
||||||
|
})
|
||||||
|
expect(umbenannt).toEqual([
|
||||||
|
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
|
||||||
|
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
|
||||||
|
])
|
||||||
|
expect(meldungen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
it('Zahl passt nicht → gar nichts anfassen', () => {
|
||||||
|
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
|
||||||
|
listen: () => ['a.mkv', 'b.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: () => {
|
||||||
|
throw new Error('darf nicht')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(meldungen[0]).toContain('übersprungen')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('baueNfo', () => {
|
||||||
|
it('movie.nfo mit XML-Escaping', () => {
|
||||||
|
const nfo = baueNfo(
|
||||||
|
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
|
||||||
|
'Akira <Director\'s Cut>',
|
||||||
|
1988,
|
||||||
|
)
|
||||||
|
expect(nfo).toContain('<movie>')
|
||||||
|
expect(nfo).toContain('<title>Akira <Director\'s Cut></title>')
|
||||||
|
expect(nfo).toContain('<plot>Roboter & Menschen</plot>')
|
||||||
|
expect(nfo).toContain('<year>1988</year>')
|
||||||
|
expect(nfo).toContain('<runtime>124</runtime>')
|
||||||
|
expect(nfo).toContain('Source: Rippy')
|
||||||
|
})
|
||||||
|
it('Serie bekommt tvshow als Wurzel', () => {
|
||||||
|
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
|
||||||
|
// HandBrake gemessen (25./26.07.2026).
|
||||||
|
describe('Encoder- und Preset-Listen', () => {
|
||||||
|
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
|
||||||
|
const hilfe = [
|
||||||
|
' -e, --encoder <string> Select video encoder:',
|
||||||
|
' svt_av1',
|
||||||
|
' x264',
|
||||||
|
' x265',
|
||||||
|
' nvenc_h264',
|
||||||
|
' --encoder-preset <string>',
|
||||||
|
' ignored',
|
||||||
|
].join('\n')
|
||||||
|
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
|
||||||
|
})
|
||||||
|
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
|
||||||
|
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
|
||||||
|
'cpu-x264',
|
||||||
|
'cpu-x265',
|
||||||
|
'cpu-av1',
|
||||||
|
'vce',
|
||||||
|
'vce-av1',
|
||||||
|
])
|
||||||
|
expect(leiteBackendsAb([])).toEqual([])
|
||||||
|
})
|
||||||
|
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
|
||||||
|
const liste = [
|
||||||
|
'General/',
|
||||||
|
' Very Fast 2160p60 4K AV1',
|
||||||
|
' AV1 video (up to 2160p60) and AAC stereo audio.',
|
||||||
|
'Hardware/',
|
||||||
|
' H.265 VCN 2160p 4K',
|
||||||
|
'[10:26:05] qsv: not available on this system',
|
||||||
|
].join('\n')
|
||||||
|
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Tests der Audio-CD-Rechnungen und der MusicBrainz-Kennung — die
|
||||||
|
// Referenzzahlen sind die ECHTE AC/DC-„Back in Black"-Antwort vom
|
||||||
|
// 29.08.2026 (test_musicbrainz_cd.py: „abgefragt, nicht erinnert").
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
bloecke,
|
||||||
|
dateiname,
|
||||||
|
dauerText,
|
||||||
|
flacBefehl,
|
||||||
|
msfZuLba,
|
||||||
|
rawReadInfo,
|
||||||
|
sauberName,
|
||||||
|
tocZerlegen,
|
||||||
|
wavKopf,
|
||||||
|
OFFSET_EINHEIT,
|
||||||
|
SEKTOR_BYTES,
|
||||||
|
VORLAUF_FRAMES,
|
||||||
|
} from '../src/kern/rip/audiocd'
|
||||||
|
import { discId, kennungAusSpuren, tagsJeSpur, PLAETZE } from '../src/kern/metadaten/musicbrainz'
|
||||||
|
import { extractGueltigBis, extractKey } from '../src/kern/werkzeuge/schluessel'
|
||||||
|
|
||||||
|
const ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937, 133712, 151660, 170112]
|
||||||
|
const ACDC_LEAD_OUT = 189281
|
||||||
|
const ACDC_KENNUNG = '3KVSIWn_ewv9z0cCizmOJzDWtsQ-'
|
||||||
|
|
||||||
|
describe('MusicBrainz-DiscID', () => {
|
||||||
|
it('DIE Referenz: stimmt mit MusicBrainz überein', () => {
|
||||||
|
expect(discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10)).toBe(ACDC_KENNUNG)
|
||||||
|
})
|
||||||
|
it('der Vorlauf wird wieder HINZUgerechnet (LBA→MSF-Falle)', () => {
|
||||||
|
const spuren = ACDC_VERSAETZE.map((versatz, i) => ({
|
||||||
|
nr: i + 1,
|
||||||
|
start: versatz - VORLAUF_FRAMES,
|
||||||
|
sektoren: (i + 1 < ACDC_VERSAETZE.length ? ACDC_VERSAETZE[i + 1] : ACDC_LEAD_OUT) - versatz,
|
||||||
|
audio: true,
|
||||||
|
dauer: '',
|
||||||
|
}))
|
||||||
|
expect(kennungAusSpuren(spuren)).toBe(ACDC_KENNUNG)
|
||||||
|
})
|
||||||
|
it('MusicBrainz-Alphabet: kein + / =', () => {
|
||||||
|
const kennung = discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10)
|
||||||
|
expect(/[+/=]/.test(kennung)).toBe(false)
|
||||||
|
})
|
||||||
|
it('immer 99 Plätze — und jede Zahl zählt', () => {
|
||||||
|
expect(PLAETZE).toBe(99)
|
||||||
|
expect(discId([150, 20000], 40000, 1, 2)).not.toBe(discId([150, 20001], 40000, 1, 2))
|
||||||
|
expect(discId([], 1000)).toBe('')
|
||||||
|
expect(kennungAusSpuren([])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MSF/LBA und TOC', () => {
|
||||||
|
it('msfZuLba zieht die 150 Vorlauf-Frames ab', () => {
|
||||||
|
expect(msfZuLba(0, 2, 0)).toBe(0)
|
||||||
|
expect(msfZuLba(0, 2, 32)).toBe(32)
|
||||||
|
expect(msfZuLba(1, 0, 0)).toBe(75 * 60 - 150)
|
||||||
|
expect(dauerText(4500)).toBe('1:00')
|
||||||
|
expect(dauerText(75 * 61)).toBe('1:01')
|
||||||
|
})
|
||||||
|
it('tocZerlegen liest Spuren, erkennt Daten-Tracks, braucht den Lead-Out', () => {
|
||||||
|
// CDROM_TOC: 4 Byte Kopf + Einträge à 8 Byte (…, Adr/Control, Nr, …, 0,M,S,F)
|
||||||
|
const toc = Buffer.alloc(4 + 3 * 8)
|
||||||
|
const eintrag = (stelle: number, control: number, nr: number, m: number, s: number, f: number): void => {
|
||||||
|
toc[4 + stelle * 8 + 1] = control
|
||||||
|
toc[4 + stelle * 8 + 2] = nr
|
||||||
|
toc[4 + stelle * 8 + 5] = m
|
||||||
|
toc[4 + stelle * 8 + 6] = s
|
||||||
|
toc[4 + stelle * 8 + 7] = f
|
||||||
|
}
|
||||||
|
eintrag(0, 0x00, 1, 0, 2, 0) // Audio ab LBA 0
|
||||||
|
eintrag(1, 0x04, 2, 1, 2, 0) // DATEN-Track ab LBA 4500
|
||||||
|
eintrag(2, 0x00, 0xaa, 2, 2, 0) // Lead-Out bei LBA 9000
|
||||||
|
const spuren = tocZerlegen(toc)
|
||||||
|
expect(spuren).toHaveLength(2)
|
||||||
|
expect(spuren[0]).toMatchObject({ nr: 1, start: 0, sektoren: 4500, audio: true, dauer: '1:00' })
|
||||||
|
expect(spuren[1]).toMatchObject({ nr: 2, audio: false })
|
||||||
|
})
|
||||||
|
it('ohne Endmarke fehlt der letzten Spur die Länge — sie fällt weg', () => {
|
||||||
|
const toc = Buffer.alloc(4 + 8)
|
||||||
|
toc[4 + 2] = 1
|
||||||
|
toc[4 + 6] = 2
|
||||||
|
expect(tocZerlegen(toc)).toEqual([])
|
||||||
|
expect(tocZerlegen(Buffer.alloc(4))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Lesen und Kodieren', () => {
|
||||||
|
it('bloecke bleibt unter der 64-KB-Treibergrenze', () => {
|
||||||
|
const teile = bloecke(1000, 60)
|
||||||
|
expect(teile[0]).toEqual([1000, 27])
|
||||||
|
expect(teile[1]).toEqual([1027, 27])
|
||||||
|
expect(teile[2]).toEqual([1000 + 54, 6])
|
||||||
|
expect(27 * SEKTOR_BYTES).toBeLessThan(64 * 1024)
|
||||||
|
})
|
||||||
|
it('rawReadInfo: DiskOffset in 2048ern, obwohl der Sektor 2352 hat', () => {
|
||||||
|
const info = rawReadInfo(100, 27)
|
||||||
|
expect(info.readBigInt64LE(0)).toBe(100n * BigInt(OFFSET_EINHEIT))
|
||||||
|
expect(info.readUInt32LE(8)).toBe(27)
|
||||||
|
expect(info.readUInt32LE(12)).toBe(2) // CDDA
|
||||||
|
})
|
||||||
|
it('wavKopf: 44 Byte, RIFF-Größenfelder stimmen', () => {
|
||||||
|
const kopf = wavKopf(1000)
|
||||||
|
expect(kopf.length).toBe(44)
|
||||||
|
expect(kopf.toString('ascii', 0, 4)).toBe('RIFF')
|
||||||
|
expect(kopf.readUInt32LE(4)).toBe(1036)
|
||||||
|
expect(kopf.readUInt32LE(40)).toBe(1000)
|
||||||
|
expect(kopf.readUInt32LE(24)).toBe(44100)
|
||||||
|
})
|
||||||
|
it('sauberName: AC/DC macht keinen Unterordner auf', () => {
|
||||||
|
expect(sauberName('AC/DC - Back in Black')).toBe('ACDC - Back in Black')
|
||||||
|
expect(sauberName('Ende. ')).toBe('Ende')
|
||||||
|
})
|
||||||
|
it('dateiname mit und ohne Titel', () => {
|
||||||
|
expect(dateiname(1, 'Hells Bells')).toBe('01 - Hells Bells')
|
||||||
|
expect(dateiname(2)).toBe('Track 02')
|
||||||
|
})
|
||||||
|
it('flacBefehl trägt Tags einzeln', () => {
|
||||||
|
const befehl = flacBefehl('a.wav', 'b.flac', { TITLE: 'X', LEER: '' })
|
||||||
|
expect(befehl).toContain('--tag')
|
||||||
|
expect(befehl[befehl.indexOf('--tag') + 1]).toBe('TITLE=X')
|
||||||
|
expect(befehl.join(' ')).not.toContain('LEER=')
|
||||||
|
expect(befehl.at(-1)).toBe('a.wav')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tagsJeSpur', () => {
|
||||||
|
it('setzt Künstler mit Bindewörtern zusammen, nimmt nur das Jahr', () => {
|
||||||
|
const tags = tagsJeSpur({
|
||||||
|
releases: [
|
||||||
|
{
|
||||||
|
title: 'Back in Black',
|
||||||
|
date: '1980-07-25',
|
||||||
|
'artist-credit': [{ name: 'AC/DC' }],
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
tracks: [
|
||||||
|
{ position: 1, title: 'Hells Bells', 'artist-credit': [{ name: 'Simon', joinphrase: ' & ' }, { name: 'Garfunkel' }] },
|
||||||
|
{ position: '2', title: 'Shoot to Thrill' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(tags.get(1)?.['ARTIST']).toBe('Simon & Garfunkel')
|
||||||
|
expect(tags.get(2)?.['ARTIST']).toBe('AC/DC')
|
||||||
|
expect(tags.get(1)?.['DATE']).toBe('1980')
|
||||||
|
expect(tags.get(2)?.['TRACKNUMBER']).toBe('2')
|
||||||
|
expect(tagsJeSpur(null).size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Schlüsselkette (§ 9.1)', () => {
|
||||||
|
it('extractKey findet den Beta-Key im Forum-HTML', () => {
|
||||||
|
const html = '<code>T-' + 'x'.repeat(60) + '</code>'
|
||||||
|
expect(extractKey(html)).toBe('T-' + 'x'.repeat(60))
|
||||||
|
expect(extractKey('kein Key hier T-kurz')).toBe('')
|
||||||
|
})
|
||||||
|
it('extractGueltigBis liest das Ablaufdatum (best effort)', () => {
|
||||||
|
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
||||||
|
expect(extractGueltigBis('nichts')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Die MakeMKV-Beschaffungskette (§ 9) — Fixtures aus den Live-Messungen
|
||||||
|
// vom 31.08.2026: Hersteller-Seite HTTP 525, Forum 1.18.4 in 0,2 s,
|
||||||
|
// Archiv 1.18.2 in 7,8 s.
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
dateiPruefen,
|
||||||
|
hoechsteVersion,
|
||||||
|
istNeuer,
|
||||||
|
makemkvInstallerHolen,
|
||||||
|
makemkvNeuesteVersion,
|
||||||
|
makemkvQuellen,
|
||||||
|
versionsquellen,
|
||||||
|
} from '../src/kern/werkzeuge/beschaffen'
|
||||||
|
import { istFensterNachricht, istHauptNachricht, istKernNachricht } from '../src/gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
const aufraeumen: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const ordner of aufraeumen.splice(0).reverse()) {
|
||||||
|
rmSync(ordner, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function tempOrdner(): string {
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-beschaffung-'))
|
||||||
|
aufraeumen.push(ordner)
|
||||||
|
return ordner
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwort(body: string | Buffer, status = 200): Response {
|
||||||
|
return new Response(typeof body === 'string' ? body : new Uint8Array(body), { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hoechsteVersion', () => {
|
||||||
|
it('vergleicht numerisch — als Zeichenkette wäre 1.9.2 größer als 1.18.4', () => {
|
||||||
|
expect(hoechsteVersion('MakeMKV v1.9.2 … MakeMKV 1.18.4 … MakeMKV v1.18.2')).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
it('leerer Text → leere Antwort', () => {
|
||||||
|
expect(hoechsteVersion('')).toBe('')
|
||||||
|
expect(hoechsteVersion('nichts hier')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istNeuer', () => {
|
||||||
|
it('numerisch je Stelle', () => {
|
||||||
|
expect(istNeuer('1.18.4', '1.18.2')).toBe(true)
|
||||||
|
expect(istNeuer('1.9.2', '1.18.4')).toBe(false)
|
||||||
|
expect(istNeuer('1.18.4', '1.18.4')).toBe(false)
|
||||||
|
expect(istNeuer('1.18.4.1', '1.18.4')).toBe(true)
|
||||||
|
})
|
||||||
|
it('Unbekanntes ist nie neuer — außer gegenüber gar nichts', () => {
|
||||||
|
expect(istNeuer('', '1.0.0')).toBe(false)
|
||||||
|
expect(istNeuer('1.0.0', '')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Quellenketten', () => {
|
||||||
|
it('Versionsquellen: der Hersteller zuerst, und nur er ist maßgeblich', () => {
|
||||||
|
const quellen = versionsquellen()
|
||||||
|
expect(quellen).toHaveLength(3)
|
||||||
|
expect(quellen[0].name).toBe('Hersteller-Seite')
|
||||||
|
expect(quellen[0].massgeblich).toBe(true)
|
||||||
|
expect(quellen.slice(1).every((q) => !q.massgeblich)).toBe(true)
|
||||||
|
// Zeitstempel in der Vergangenheit — sonst holt das Archiv live nach.
|
||||||
|
expect(quellen[2].url).toContain('web.archive.org/web/2025/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Datei-Quellen: Hersteller → Archiv (mit id_ für die ROHE Datei)', () => {
|
||||||
|
const quellen = makemkvQuellen('1.18.4')
|
||||||
|
expect(quellen.map(([name]) => name)).toEqual(['Hersteller', 'Internet Archive'])
|
||||||
|
expect(quellen[0][1]).toBe('https://www.makemkv.com/download/Setup_MakeMKV_v1.18.4.exe')
|
||||||
|
expect(quellen[1][1]).toBe(
|
||||||
|
'https://web.archive.org/web/2025id_/https://www.makemkv.com/download/Setup_MakeMKV_v1.18.4.exe',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eine eigene Basis steht davor — wer sie einträgt, will sie', () => {
|
||||||
|
const quellen = makemkvQuellen('1.18.4', 'https://mein-server.example/mkv/')
|
||||||
|
expect(quellen[0]).toEqual(['eigene Quelle', 'https://mein-server.example/mkv/Setup_MakeMKV_v1.18.4.exe'])
|
||||||
|
expect(quellen).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makemkvNeuesteVersion', () => {
|
||||||
|
it('der Hersteller ist maßgeblich — antwortet er, wird nicht weitergefragt', async () => {
|
||||||
|
const gefragt: string[] = []
|
||||||
|
const holen = (async (url: string | URL | Request) => {
|
||||||
|
gefragt.push(String(url))
|
||||||
|
return antwort('<html>MakeMKV 1.19.0 is out</html>')
|
||||||
|
}) as typeof fetch
|
||||||
|
expect(await makemkvNeuesteVersion(holen)).toBe('1.19.0')
|
||||||
|
expect(gefragt).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Hersteller tot (525, wie gemessen) → höchste aus Forum und Archiv', async () => {
|
||||||
|
const holen = (async (url: string | URL | Request) => {
|
||||||
|
const wo = String(url)
|
||||||
|
if (wo.includes('www.makemkv.com')) return antwort('cloudflare', 525)
|
||||||
|
if (wo.includes('forum')) return antwort('MakeMKV v1.18.4 released')
|
||||||
|
return antwort('MakeMKV v1.18.2 … MakeMKV v1.18.1')
|
||||||
|
}) as typeof fetch
|
||||||
|
expect(await makemkvNeuesteVersion(holen)).toBe('1.18.4')
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
it('keine Quelle → leere Antwort statt Wurf', async () => {
|
||||||
|
expect(await makemkvNeuesteVersion(undefined, [])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dateiPruefen', () => {
|
||||||
|
it('ohne MZ-Kopf ist es kein Windows-Programm', () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const pfad = join(ordner, 'seite.exe')
|
||||||
|
writeFileSync(pfad, '<html>Fehlerseite</html>')
|
||||||
|
const urteil = dateiPruefen(pfad, '1.18.4')
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.grund).toContain('MZ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unter Windows zählt die Versions-Ressource (gemessene Felder)', () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const pfad = join(ordner, 'setup.exe')
|
||||||
|
writeFileSync(pfad, 'MZ\x90rest')
|
||||||
|
// Die am 31.08.2026 an der echten Datei gemessenen Werte:
|
||||||
|
const echt = { firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => echt, 'win32').ok).toBe(true)
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => ({ ...echt, firma: 'Boese GmbH' }), 'win32').ok).toBe(false)
|
||||||
|
expect(dateiPruefen(pfad, '1.19.0', () => echt, 'win32').ok).toBe(false)
|
||||||
|
// Auf Linux (CI) gibt es keine Versions-Ressourcen — MZ-Test muss reichen.
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => echt, 'linux').ok).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makemkvInstallerHolen', () => {
|
||||||
|
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
||||||
|
|
||||||
|
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async (url: string | URL | Request) =>
|
||||||
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
|
'1.18.4',
|
||||||
|
() => {},
|
||||||
|
'',
|
||||||
|
holen,
|
||||||
|
ordner,
|
||||||
|
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
||||||
|
)
|
||||||
|
expect(ergebnis.quelle).toBe('Internet Archive')
|
||||||
|
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
||||||
|
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
|
||||||
|
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Nachrichten-Schema der Update-Strecke', () => {
|
||||||
|
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
||||||
|
})
|
||||||
|
it('Haupt → Kern: installer-ergebnis', () => {
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: 'UAC abgelehnt' })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: 5 })).toBe(false)
|
||||||
|
})
|
||||||
|
it('Kern → Haupt/Fenster: beschaffung-status und installer-starten', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'beschaffung-status',
|
||||||
|
stand: { werkzeug: 'makemkv', laeuft: true, text: 'lädt', anteil: 0.5, fehler: false },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'installer-starten', pfad: 'C:\\x\\setup.exe' })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'installer-starten', pfad: '' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Die drei Funde aus dem ersten Commander-Test (30.08.2026, abends) —
|
||||||
|
// jeder als Test festgehalten, mit den LIVE gemessenen Fixtures.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { urteilAusAusgabe } from '../src/kern/werkzeuge/schluessel'
|
||||||
|
import { presetEmpfehlung, DEFAULT_HB_PRESET } from '../src/gemeinsam/preset-empfehlung'
|
||||||
|
import { presetFuer } from '../src/kern/komprimieren/presets'
|
||||||
|
|
||||||
|
describe('Fund 1 — „Key korrekt, aber als ungültig angezeigt"', () => {
|
||||||
|
// Die ECHTE makemkvcon-Ausgabe vom Commander-PC (30.08.2026): Der
|
||||||
|
// aktuelle Forum-Key ist in Ordnung, MakeMKV 1.18.4 ist ZU ALT für ihn
|
||||||
|
// — es meldet 5020 UND 5021 zusammen.
|
||||||
|
const ECHTE_AUSGABE = [
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"',
|
||||||
|
'MSG:5020,516,0,"Der hinterlegte Aktivierungsschlüssel ist ungültig. …","…"',
|
||||||
|
'MSG:5021,131332,1,"Diese Programmversion ist zu alt. Bitte laden Sie die aktuelle Version von http://www.makemkv.com/ herunter.","…","http://www.makemkv.com/"',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
it('5021 schlägt 5020: Die Ursache ist die Version, nicht der Schlüssel', () => {
|
||||||
|
const urteil = urteilAusAusgabe(ECHTE_AUSGABE)
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.code).toBe(5021)
|
||||||
|
expect(urteil.grund).toContain('zu alt')
|
||||||
|
expect(urteil.version).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne Key-Meldungen ist alles gut — und die Version kommt trotzdem mit', () => {
|
||||||
|
const urteil = urteilAusAusgabe(
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
|
||||||
|
)
|
||||||
|
expect(urteil.ok).toBe(true)
|
||||||
|
expect(urteil.version).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
|
||||||
|
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
|
||||||
|
expect(urteil.code).toBe(5020)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fund 2 — „Komprimieren auf der CPU statt der GPU"', () => {
|
||||||
|
// Die ECHTE Hardware-Preset-Liste des Commander-HandBrake (30.08.2026)
|
||||||
|
// plus die gemessenen Backends der RX 9070 XT.
|
||||||
|
const PRESETS = [
|
||||||
|
'Very Fast 1080p30',
|
||||||
|
'H.265 MKV 1080p30',
|
||||||
|
'AV1 QSV 2160p 4K',
|
||||||
|
'AV1 VCN 2160p 4K',
|
||||||
|
'H.265 NVENC 2160p 4K',
|
||||||
|
'H.265 NVENC 1080p',
|
||||||
|
'H.265 QSV 2160p 4K',
|
||||||
|
'H.265 QSV 1080p',
|
||||||
|
'H.265 VCN 2160p 4K',
|
||||||
|
'H.265 VCN 1080p',
|
||||||
|
]
|
||||||
|
const BACKENDS = ['cpu-x264', 'cpu-x265', 'cpu-av1', 'vce', 'vce-av1']
|
||||||
|
|
||||||
|
it('empfiehlt auf dem Commander-PC VCN, nicht CPU', () => {
|
||||||
|
expect(presetEmpfehlung('bluray', BACKENDS, PRESETS)).toBe('H.265 VCN 1080p')
|
||||||
|
expect(presetEmpfehlung('dvd', BACKENDS, PRESETS)).toBe('H.265 VCN 1080p')
|
||||||
|
expect(presetEmpfehlung('uhd', BACKENDS, PRESETS)).toBe('H.265 VCN 2160p 4K')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NVIDIA-Maschine bekäme NVENC, Intel QSV, reine CPU den Default', () => {
|
||||||
|
expect(presetEmpfehlung('bluray', ['cpu-x265', 'nvenc'], PRESETS)).toBe('H.265 NVENC 1080p')
|
||||||
|
expect(presetEmpfehlung('bluray', ['cpu-x265', 'qsv'], PRESETS)).toBe('H.265 QSV 1080p')
|
||||||
|
expect(presetEmpfehlung('bluray', ['cpu-x264', 'cpu-x265'], PRESETS)).toBe(DEFAULT_HB_PRESET)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empfohlen wird NUR, was die gemessene Liste hergibt', () => {
|
||||||
|
// vce da, aber keine VCN-Presets in der Liste → CPU-Default statt
|
||||||
|
// eines geratenen Namens (die „Invalid preset"-Falle).
|
||||||
|
expect(presetEmpfehlung('bluray', ['vce'], ['Very Fast 1080p30'])).toBe(DEFAULT_HB_PRESET)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('die Kette bleibt: eigene Wahl schlägt die Empfehlung', () => {
|
||||||
|
expect(presetFuer('bluray', { transcodePresetBluray: 'Fast 480p30' }, 'H.265 VCN 1080p')).toBe('Fast 480p30')
|
||||||
|
expect(presetFuer('bluray', {}, 'H.265 VCN 1080p')).toBe('H.265 VCN 1080p')
|
||||||
|
expect(presetFuer('bluray', {}, '')).toBe(DEFAULT_HB_PRESET)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Tests für kern/speicher/db.ts — laufen mit dem System-Node (node:sqlite
|
||||||
|
// gibt es dort wie in Electrons Node, KONZEPT-WINDOWS.md § 3.4).
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { Datenbank, SCHEMA_VERSION } from '../src/kern/speicher/db'
|
||||||
|
|
||||||
|
let aufraeumen: Array<() => void> = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// LIFO — zuletzt Registriertes zuerst: die Datenbank muss ZU sein, bevor
|
||||||
|
// ihr Ordner gelöscht wird (Windows verweigert das Löschen offener Dateien).
|
||||||
|
for (const weg of aufraeumen.splice(0).reverse()) weg()
|
||||||
|
})
|
||||||
|
|
||||||
|
function frischerPfad(): string {
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-db-test-'))
|
||||||
|
aufraeumen.push(() => rmSync(ordner, { recursive: true, force: true }))
|
||||||
|
return join(ordner, 'unterordner', 'rippy.db')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Datenbank', () => {
|
||||||
|
it('legt Datei samt fehlender Ordner an und meldet Schema-Version', () => {
|
||||||
|
const pfad = frischerPfad()
|
||||||
|
const db = new Datenbank(pfad)
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
expect(existsSync(pfad)).toBe(true)
|
||||||
|
expect(db.schemaVersion()).toBe(SCHEMA_VERSION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt und liest eine Einstellung', () => {
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
db.einstellungSchreiben('ablage', 'E:\\Rippy')
|
||||||
|
expect(db.einstellungLesen('ablage')).toBe('E:\\Rippy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('überschreibt eine vorhandene Einstellung', () => {
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
db.einstellungSchreiben('ablage', 'C:\\alt')
|
||||||
|
db.einstellungSchreiben('ablage', 'E:\\neu')
|
||||||
|
expect(db.einstellungLesen('ablage')).toBe('E:\\neu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unbekannter Schlüssel ist null, kein Fehler und kein Leerwert', () => {
|
||||||
|
// R2: „nicht gefunden" ist eine Aussage (null) — kein erfundener
|
||||||
|
// Leerstring, der als Wert gelesen würde.
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
expect(db.einstellungLesen('gibt-es-nicht')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bleibt über Schließen und Neuöffnen erhalten', () => {
|
||||||
|
const pfad = frischerPfad()
|
||||||
|
const erste = new Datenbank(pfad)
|
||||||
|
erste.einstellungSchreiben('letzter_start', '2026-08-30T12:00:00Z')
|
||||||
|
erste.schliessen()
|
||||||
|
const zweite = new Datenbank(pfad)
|
||||||
|
aufraeumen.push(() => zweite.schliessen())
|
||||||
|
expect(zweite.einstellungLesen('letzter_start')).toBe('2026-08-30T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wirft bei nicht anlegbarem Pfad, statt still weiterzumachen', () => {
|
||||||
|
// R4: Der Aufrufer (kern/index.ts) fängt das und MELDET es — aber die
|
||||||
|
// Datenbank selbst darf so einen Zustand nie verschlucken.
|
||||||
|
// NUL ist unter Windows als Ordnername unzulässig; unter Linux (CI)
|
||||||
|
// scheitert das Anlegen unterhalb einer Datei genauso zuverlässig.
|
||||||
|
const kaputt = join(process.platform === 'win32' ? 'C:\\NUL<>:' : '/dev/null/x', 'rippy.db')
|
||||||
|
expect(() => new Datenbank(kaputt)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Tests der HandBrake-Schicht — die Testfälle aus test_ripping/caps
|
||||||
|
// wandern sinngemäß mit. Der wichtigste ist eine Anti-Regression: Der
|
||||||
|
// alte Test FORDERTE den falschen Schalter ein (assert "--audio-codec"
|
||||||
|
// in cmd) und schrieb den Fehler damit fest, statt ihn zu finden.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildHandbrakeArgs,
|
||||||
|
dateiDaneben,
|
||||||
|
HbAuswertung,
|
||||||
|
hbSchluss,
|
||||||
|
HbZeilenLeser,
|
||||||
|
progressAusZeile,
|
||||||
|
unbekannterSchalter,
|
||||||
|
unbekanntesPreset,
|
||||||
|
} from '../src/kern/komprimieren/handbrake'
|
||||||
|
import {
|
||||||
|
komprimierenFuer,
|
||||||
|
presetFuer,
|
||||||
|
PRESET_KEINE,
|
||||||
|
sprachliste,
|
||||||
|
DEFAULT_HB_PRESET,
|
||||||
|
} from '../src/kern/komprimieren/presets'
|
||||||
|
|
||||||
|
describe('buildHandbrakeArgs', () => {
|
||||||
|
const args = buildHandbrakeArgs({
|
||||||
|
eingabe: 'E:\\roh\\film_t00.mkv',
|
||||||
|
ausgabe: 'E:\\fertig\\Film (2009).mkv',
|
||||||
|
preset: 'H.265 MKV 1080p30',
|
||||||
|
audioSprachen: ['deu', 'eng'],
|
||||||
|
untertitelSprachen: ['deu'],
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nutzt --aencoder und NIEMALS --audio-codec (der 16,5-GB-Fund)', () => {
|
||||||
|
// HandBrake meldet einen unbekannten Schalter mit Rückgabewert 0 —
|
||||||
|
// dieser Wächter passt auf, dass der Fehler nie zurückkommt.
|
||||||
|
expect(args).toContain('--aencoder')
|
||||||
|
expect(args.join(' ')).not.toContain('--audio-codec')
|
||||||
|
const stelle = args.indexOf('--aencoder')
|
||||||
|
expect(args[stelle + 1]).toBe('copy')
|
||||||
|
expect(args).toContain('--audio-fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erzwingt den Container aus der Endung (--format av_mkv)', () => {
|
||||||
|
const stelle = args.indexOf('--format')
|
||||||
|
expect(stelle).toBeGreaterThan(-1)
|
||||||
|
expect(args[stelle + 1]).toBe('av_mkv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Sprachlisten und --all-subtitles/--first-audio', () => {
|
||||||
|
expect(args[args.indexOf('--audio-lang-list') + 1]).toBe('deu,eng')
|
||||||
|
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu')
|
||||||
|
expect(args).toContain('--first-audio')
|
||||||
|
expect(args).toContain('--all-subtitles')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne Sprachwahl keine Listen-Schalter', () => {
|
||||||
|
const schlicht = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X' })
|
||||||
|
expect(schlicht).not.toContain('--audio-lang-list')
|
||||||
|
expect(schlicht).not.toContain('--subtitle-lang-list')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('progressAusZeile — nur die Encoding-Zeile zählt', () => {
|
||||||
|
it('liest den Encode-Fortschritt', () => {
|
||||||
|
expect(progressAusZeile('Encoding: task 1 of 1, 42.57 % (89.31 fps, avg 90.05 fps, ETA 00h12m34s)')).toBe(42)
|
||||||
|
})
|
||||||
|
it('der Scan-Durchlauf zählt NICHT (die 99-%-Falle)', () => {
|
||||||
|
expect(progressAusZeile('Scanning title 1 of 1, preview 7, 70.00 %')).toBe(-1)
|
||||||
|
expect(progressAusZeile('Encoding: task 1 of 1, Searching for start time, 12.00')).toBe(-1)
|
||||||
|
})
|
||||||
|
it('zwei Durchläufe werden zusammengerechnet', () => {
|
||||||
|
expect(progressAusZeile('Encoding: task 2 of 2, 50.00 %')).toBe(75)
|
||||||
|
})
|
||||||
|
it('ein echtes 0 % ist eine Angabe', () => {
|
||||||
|
expect(progressAusZeile('Encoding: task 1 of 1, 0.87 %')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fehler-Erkennung', () => {
|
||||||
|
it('unbekanntes Preset (Wortlaut aus dem echten Lauf)', () => {
|
||||||
|
expect(unbekanntesPreset('Invalid preset Gibt Es Nicht 999p')).toBe('Gibt Es Nicht 999p')
|
||||||
|
expect(unbekanntesPreset('Valid presets are:')).toBe('')
|
||||||
|
})
|
||||||
|
it('unbekannter Schalter (kommt mit Rückgabewert 0!)', () => {
|
||||||
|
expect(unbekannterSchalter('unknown option (--audio-codec)')).toBe('--audio-codec')
|
||||||
|
expect(unbekannterSchalter('Encoding: task 1 of 1, 1.00 %')).toBe('')
|
||||||
|
})
|
||||||
|
it('hbSchluss hebt die letzten Worte auf', () => {
|
||||||
|
expect(hbSchluss(['a', '', 'b', 'c', 'd', 'e'])).toBe(' — HandBrake sagte zuletzt: b | c | d | e')
|
||||||
|
expect(hbSchluss([])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HbAuswertung.urteil — die bewiesene Reihenfolge', () => {
|
||||||
|
it('falscher Schalter schlägt ALLES, auch Code 0', () => {
|
||||||
|
const a = new HbAuswertung()
|
||||||
|
a.verarbeite('unknown option (--audio-codec)')
|
||||||
|
a.verarbeite('HandBrake has exited.')
|
||||||
|
const urteil = a.urteil(0, 'x.mkv', () => true)
|
||||||
|
expect(urteil.status).toBe('error')
|
||||||
|
if (urteil.status === 'error') {
|
||||||
|
expect(urteil.fehler).toContain('--audio-codec')
|
||||||
|
expect(urteil.fehler).toContain('Fehler in Rippy')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Code 0 + Datei da = Erfolg', () => {
|
||||||
|
const urteil = new HbAuswertung().urteil(0, 'x.mkv', (p) => p === 'x.mkv')
|
||||||
|
expect(urteil).toEqual({ status: 'success', ausgabe: 'x.mkv' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Code 0, Datei fehlt, liegt daneben → Erfolg MIT Hinweis (Container aus Preset)', () => {
|
||||||
|
const urteil = new HbAuswertung().urteil(0, 'E:\\ziel\\film.mkv', (p) => p === 'E:\\ziel\\film.mp4')
|
||||||
|
expect(urteil.status).toBe('success')
|
||||||
|
if (urteil.status === 'success') {
|
||||||
|
expect(urteil.ausgabe).toBe('E:\\ziel\\film.mp4')
|
||||||
|
expect(urteil.hinweis).toContain('Container')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Code 0 ohne Datei: Ordner wird GENANNT, letzte Worte zitiert', () => {
|
||||||
|
const a = new HbAuswertung()
|
||||||
|
a.verarbeite('No title found.')
|
||||||
|
const urteil = a.urteil(0, 'E:\\ziel\\film.mkv', () => false)
|
||||||
|
expect(urteil.status).toBe('error')
|
||||||
|
if (urteil.status === 'error') {
|
||||||
|
expect(urteil.fehler).toContain('E:\\ziel')
|
||||||
|
expect(urteil.fehler).toContain('No title found.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unbekanntes Preset wird erklärt', () => {
|
||||||
|
const a = new HbAuswertung()
|
||||||
|
a.verarbeite('Invalid preset Uralt 480p')
|
||||||
|
const urteil = a.urteil(3, 'x.mkv', () => false)
|
||||||
|
expect(urteil.status).toBe('error')
|
||||||
|
if (urteil.status === 'error') expect(urteil.fehler).toContain('Uralt 480p')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dateiDaneben', () => {
|
||||||
|
it('findet die Datei mit anderer Endung im selben Ordner', () => {
|
||||||
|
expect(dateiDaneben('E:\\z\\film.mkv', (p) => p === 'E:\\z\\film.mp4')).toBe('E:\\z\\film.mp4')
|
||||||
|
expect(dateiDaneben('E:\\z\\film.mkv', () => false)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HbZeilenLeser — CR trennt den Fortschritt', () => {
|
||||||
|
it('CR, LF und CRLF trennen; halbe Zeilen bleiben liegen', () => {
|
||||||
|
const leser = new HbZeilenLeser()
|
||||||
|
expect(leser.hinzu(Buffer.from('Encoding: task 1 of 1, 1.00 %\rEncoding: task 1 of 1, 2.00'))).toEqual([
|
||||||
|
'Encoding: task 1 of 1, 1.00 %',
|
||||||
|
])
|
||||||
|
expect(leser.hinzu(Buffer.from(' %\r\nfertig\n'))).toEqual(['Encoding: task 1 of 1, 2.00 %', 'fertig'])
|
||||||
|
})
|
||||||
|
it('das ü aus zwei Kodierungen tötet den Leser NICHT (0x81-Falle)', () => {
|
||||||
|
const leser = new HbZeilenLeser()
|
||||||
|
const zeilen = leser.hinzu(
|
||||||
|
Buffer.concat([Buffer.from('pfad Gl'), Buffer.from([0x81]), Buffer.from('ck.mkv\n')]),
|
||||||
|
)
|
||||||
|
expect(zeilen).toHaveLength(1)
|
||||||
|
expect(zeilen[0]).toContain('pfad Gl')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('presets', () => {
|
||||||
|
it('komprimierenFuer: globaler Schalter schlägt alles; PRESET_KEINE je Typ', () => {
|
||||||
|
expect(komprimierenFuer('uhd', { transcodeEnabled: false })).toBe(false)
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: PRESET_KEINE })).toBe(false)
|
||||||
|
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
|
||||||
|
expect(komprimierenFuer('bluray', {})).toBe(true)
|
||||||
|
})
|
||||||
|
it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => {
|
||||||
|
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
|
||||||
|
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein')
|
||||||
|
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
|
||||||
|
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A')
|
||||||
|
})
|
||||||
|
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
|
||||||
|
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
|
||||||
|
expect(sprachliste(['ENG', 'eng'])).toEqual(['eng'])
|
||||||
|
expect(sprachliste(null)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Tests des Werkzeug-Katalogs — Suchreihenfolge und die %VAR%-Falle
|
||||||
|
// (Groß/Klein), beides ohne installierte Programme (Prüf-Funktionen
|
||||||
|
// werden eingespritzt, Muster aus test_katalog.py).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { entfalten, finden, kandidaten, WERKZEUGE } from '../src/kern/werkzeuge/katalog'
|
||||||
|
|
||||||
|
describe('entfalten — %VAR% ohne Rücksicht auf Schreibweise', () => {
|
||||||
|
it('die gemessene Falle: PROGRAMFILES(X86) trifft %ProgramFiles(x86)%', () => {
|
||||||
|
// Windows liefert Umgebungsnamen GROSS; das Muster schreibt sie
|
||||||
|
// gemischt. Der strikte Vergleich ließ die bekannten Orte auf einer
|
||||||
|
// echten Maschine NIE greifen (katalog.py, 28.08.2026).
|
||||||
|
expect(entfalten('%ProgramFiles(x86)%\\MakeMKV', { 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)' })).toBe(
|
||||||
|
'C:\\Program Files (x86)\\MakeMKV',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('bleibt ein % stehen, ist der Kandidat unbrauchbar', () => {
|
||||||
|
expect(entfalten('%GibtEsNicht%\\X', {})).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kandidaten — Reihenfolge: eigener Ordner vor System', () => {
|
||||||
|
it('eigener Ordner zuerst, dann bekannte Orte, 64 Bit zuerst', () => {
|
||||||
|
const liste = kandidaten('makemkv', 'C:\\R\\tools', {
|
||||||
|
'PROGRAMFILES(X86)': 'C:\\PF86',
|
||||||
|
PROGRAMFILES: 'C:\\PF',
|
||||||
|
})
|
||||||
|
expect(liste[0]).toBe('C:\\R\\tools\\makemkv\\makemkvcon64.exe')
|
||||||
|
expect(liste).toContain('C:\\PF86\\MakeMKV\\makemkvcon64.exe')
|
||||||
|
expect(liste.indexOf('C:\\R\\tools\\makemkvcon64.exe')).toBeLessThan(
|
||||||
|
liste.indexOf('C:\\PF86\\MakeMKV\\makemkvcon64.exe'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finden', () => {
|
||||||
|
const umgebung = { 'PROGRAMFILES(X86)': 'C:\\PF86', PATH: '' }
|
||||||
|
|
||||||
|
it('ein eingestellter Pfad gewinnt — und ein falscher wird NICHT ersetzt', () => {
|
||||||
|
const existiert = (p: string): boolean => p === 'D:\\eigen\\makemkvcon64.exe'
|
||||||
|
expect(finden('makemkv', 'D:\\eigen\\makemkvcon64.exe', 'C:\\R', umgebung, existiert, () => '')).toBe(
|
||||||
|
'D:\\eigen\\makemkvcon64.exe',
|
||||||
|
)
|
||||||
|
// Tippfehler soll als Fehler sichtbar werden, nicht still anderswo landen.
|
||||||
|
expect(finden('makemkv', 'D:\\tippfehler.exe', 'C:\\R', umgebung, existiert, () => '')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findet über die bekannten Orte', () => {
|
||||||
|
const existiert = (p: string): boolean => p === 'C:\\PF86\\MakeMKV\\makemkvcon64.exe'
|
||||||
|
expect(finden('makemkv', '', 'C:\\R', umgebung, existiert, () => '')).toBe(
|
||||||
|
'C:\\PF86\\MakeMKV\\makemkvcon64.exe',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fällt zuletzt auf die Registry zurück', () => {
|
||||||
|
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => 'C:\\Reg\\makemkvcon64.exe')).toBe(
|
||||||
|
'C:\\Reg\\makemkvcon64.exe',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nichts gefunden ist "", kein Raten', () => {
|
||||||
|
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => '')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WERKZEUGE — die Pflicht-Markierungen', () => {
|
||||||
|
it('nur MakeMKV ist Pflicht; ohne HandBrake/FLAC läuft Rippy eingeschränkt', () => {
|
||||||
|
expect(WERKZEUGE.makemkv.pflicht).toBe(true)
|
||||||
|
expect(WERKZEUGE.handbrake.pflicht).toBe(false)
|
||||||
|
expect(WERKZEUGE.flac.pflicht).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Tests der Laufwerks-Schicht — komplett ohne Windows und ohne koffi:
|
||||||
|
// disc.ts arbeitet gegen die LaufwerkApi-Schnittstelle, hier nachgebildet.
|
||||||
|
// Die Testfälle wandern sinngemäß aus dem bewiesenen Docker-Zweig mit
|
||||||
|
// (test_win_ioctl.py, test_windows.py, test_cdrom.py) — ein Treiber, der
|
||||||
|
// dieselben Fälle besteht, kennt dieselben Fallen (KONZEPT § 10).
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
CDROM_DISK_AUDIO_TRACK,
|
||||||
|
CDROM_DISK_DATA_TRACK,
|
||||||
|
ERROR_ACCESS_DENIED,
|
||||||
|
ERROR_INVALID_FUNCTION,
|
||||||
|
ERROR_NOT_READY,
|
||||||
|
IOCTL_CDROM_DISK_TYPE,
|
||||||
|
IOCTL_DISK_GET_LENGTH_INFO,
|
||||||
|
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||||
|
IOCTL_STORAGE_EJECT_MEDIA,
|
||||||
|
IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||||
|
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||||
|
DRIVE_CDROM,
|
||||||
|
Win32Fehler,
|
||||||
|
type LaufwerkApi,
|
||||||
|
} from '../src/kern/laufwerk/codes'
|
||||||
|
import {
|
||||||
|
angabenSpeicherLeeren,
|
||||||
|
auswerfenMitGrund,
|
||||||
|
classify,
|
||||||
|
geraeteInfo,
|
||||||
|
geraetepfad,
|
||||||
|
kennung,
|
||||||
|
listOptischeLaufwerke,
|
||||||
|
textBei,
|
||||||
|
BLURAY_MIN_BYTES,
|
||||||
|
UHD_MIN_BYTES,
|
||||||
|
} from '../src/kern/laufwerk/disc'
|
||||||
|
|
||||||
|
describe('Steuercodes — gegen die Microsoft-Dokumentation gerechnet', () => {
|
||||||
|
// Sollwerte aus der Doku (dieselbe Gegenprobe wie test_win_ioctl.py):
|
||||||
|
// ein Zahlendreher sähe sonst aus wie „das Laufwerk kann das nicht".
|
||||||
|
it.each([
|
||||||
|
['IOCTL_STORAGE_CHECK_VERIFY2', IOCTL_STORAGE_CHECK_VERIFY2, 0x002d0800],
|
||||||
|
['IOCTL_STORAGE_MEDIA_REMOVAL', IOCTL_STORAGE_MEDIA_REMOVAL, 0x002d4804],
|
||||||
|
['IOCTL_STORAGE_EJECT_MEDIA', IOCTL_STORAGE_EJECT_MEDIA, 0x002d4808],
|
||||||
|
['IOCTL_STORAGE_QUERY_PROPERTY', IOCTL_STORAGE_QUERY_PROPERTY, 0x002d1400],
|
||||||
|
['IOCTL_CDROM_DISK_TYPE', IOCTL_CDROM_DISK_TYPE, 0x00020040],
|
||||||
|
['IOCTL_DISK_GET_LENGTH_INFO', IOCTL_DISK_GET_LENGTH_INFO, 0x0007405c],
|
||||||
|
])('%s stimmt mit der Doku überein', (_name, wert, erwartet) => {
|
||||||
|
expect(wert).toBe(erwartet)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('classify — Typ aus Spur-Flags und Größe', () => {
|
||||||
|
it('Audio-Spur ist eine CD, egal wie groß', () => {
|
||||||
|
expect(classify(CDROM_DISK_AUDIO_TRACK, 700 * 1e6)).toBe('cd')
|
||||||
|
expect(classify(CDROM_DISK_AUDIO_TRACK | CDROM_DISK_DATA_TRACK, 0)).toBe('cd')
|
||||||
|
})
|
||||||
|
it('Datenspur wird nach Größe eingeordnet', () => {
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, 4.7e9)).toBe('dvd')
|
||||||
|
// Der gemessene Fall vom 28.08.2026: BD-50 mit 48.149.364.736 Bytes
|
||||||
|
// (44,84 GiB) liegt unter der 55-GiB-Schwelle und ist „bluray".
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, 48_149_364_736)).toBe('bluray')
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, 66 * 1024 ** 3)).toBe('uhd')
|
||||||
|
})
|
||||||
|
it('die Schwellen selbst', () => {
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES)).toBe('bluray')
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES - 1)).toBe('dvd')
|
||||||
|
expect(classify(CDROM_DISK_DATA_TRACK, UHD_MIN_BYTES)).toBe('uhd')
|
||||||
|
})
|
||||||
|
it('keine Spur-Info heißt unknown, nicht dvd', () => {
|
||||||
|
expect(classify(0, 33e9)).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('geraetepfad und kennung — hin und zurück', () => {
|
||||||
|
it('baut den Gerätepfad OHNE abschließenden Backslash', () => {
|
||||||
|
// Mit Backslash öffnet CreateFileW das Wurzelverzeichnis statt des
|
||||||
|
// Geräts — die Steuercodes scheitern dann mit ERROR_INVALID_FUNCTION.
|
||||||
|
expect(geraetepfad('G')).toBe('\\\\.\\G:')
|
||||||
|
expect(geraetepfad('g:')).toBe('\\\\.\\G:')
|
||||||
|
expect(geraetepfad('G:\\')).toBe('\\\\.\\G:')
|
||||||
|
})
|
||||||
|
it('wirft bei leerer Angabe', () => {
|
||||||
|
expect(() => geraetepfad('')).toThrow()
|
||||||
|
})
|
||||||
|
it('kennung löst den Pfad wieder auf (der /dev/G-404-Fund)', () => {
|
||||||
|
expect(kennung('\\\\.\\G:')).toBe('G')
|
||||||
|
expect(kennung(geraetepfad('e'))).toBe('E')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('textBei — STORAGE_DEVICE_DESCRIPTOR auswerten', () => {
|
||||||
|
it('liest die Zeichenkette am Offset', () => {
|
||||||
|
const puffer = Buffer.alloc(40)
|
||||||
|
puffer.writeUInt32LE(32, 12) // Offset an Position 12 zeigt auf Byte 32
|
||||||
|
puffer.write('HL-DT-ST\x00', 32, 'latin1')
|
||||||
|
expect(textBei(puffer, 12)).toBe('HL-DT-ST')
|
||||||
|
})
|
||||||
|
it('Offset 0 heißt „gibt es nicht"', () => {
|
||||||
|
expect(textBei(Buffer.alloc(40), 12)).toBe('')
|
||||||
|
})
|
||||||
|
it('Offset außerhalb des Puffers ist leer, kein Absturz', () => {
|
||||||
|
const puffer = Buffer.alloc(16)
|
||||||
|
puffer.writeUInt32LE(999, 12)
|
||||||
|
expect(textBei(puffer, 12)).toBe('')
|
||||||
|
expect(textBei(Buffer.alloc(2), 12)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Die Win32-Nachbildung (Muster aus test_windows.py) ──────────────────
|
||||||
|
interface FakeVerhalten {
|
||||||
|
buchstaben?: string[]
|
||||||
|
typen?: Record<string, number>
|
||||||
|
oeffnenFehler?: number
|
||||||
|
antworten?: Map<number, Buffer | number> // Buffer = Antwort, number = Win32-Fehlercode
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeApi(verhalten: FakeVerhalten): LaufwerkApi & { protokoll: string[] } {
|
||||||
|
const protokoll: string[] = []
|
||||||
|
return {
|
||||||
|
protokoll,
|
||||||
|
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
||||||
|
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
||||||
|
oeffnen(pfad, zugriff) {
|
||||||
|
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
||||||
|
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
||||||
|
return { pfad }
|
||||||
|
},
|
||||||
|
schliessen() {
|
||||||
|
protokoll.push('schliessen')
|
||||||
|
},
|
||||||
|
steuern(_h, code, eingabe) {
|
||||||
|
protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
|
||||||
|
const antwort = verhalten.antworten?.get(code)
|
||||||
|
if (antwort === undefined) return Buffer.alloc(0)
|
||||||
|
if (typeof antwort === 'number') throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, antwort)
|
||||||
|
return antwort
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function u32(wert: number): Buffer {
|
||||||
|
const b = Buffer.alloc(4)
|
||||||
|
b.writeUInt32LE(wert)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
function u64(wert: bigint): Buffer {
|
||||||
|
const b = Buffer.alloc(8)
|
||||||
|
b.writeBigUInt64LE(wert)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
const sofort = async (): Promise<void> => {}
|
||||||
|
|
||||||
|
beforeEach(() => angabenSpeicherLeeren())
|
||||||
|
|
||||||
|
describe('listOptischeLaufwerke', () => {
|
||||||
|
it('nimmt nur DRIVE_CDROM und baut Gerätepfade', () => {
|
||||||
|
const api = fakeApi({ buchstaben: ['C', 'E', 'G'], typen: { C: 3, E: DRIVE_CDROM, G: DRIVE_CDROM } })
|
||||||
|
expect(listOptischeLaufwerke(api)).toEqual(['\\\\.\\E:', '\\\\.\\G:'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auswerfenMitGrund — entriegeln, auswerfen, NACHSEHEN (R3)', () => {
|
||||||
|
it('meldet ok erst, wenn das Medium wirklich weg ist', async () => {
|
||||||
|
let verifyRunde = 0
|
||||||
|
const api = fakeApi({})
|
||||||
|
api.steuern = (_h, code, eingabe) => {
|
||||||
|
api.protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
|
||||||
|
if (code === IOCTL_STORAGE_CHECK_VERIFY2) {
|
||||||
|
verifyRunde += 1
|
||||||
|
// Erst beim zweiten Nachsehen ist die Disc draußen.
|
||||||
|
if (verifyRunde >= 2) throw new Win32Fehler('DeviceIoControl', ERROR_NOT_READY)
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||||
|
expect(grund).toBe('ok')
|
||||||
|
expect(fehler).toBeNull()
|
||||||
|
// Reihenfolge: erst entriegeln (MEDIA_REMOVAL mit 0), dann auswerfen.
|
||||||
|
const reihenfolge = api.protokoll.filter((z) => z.startsWith('steuern'))
|
||||||
|
expect(reihenfolge[0]).toContain(IOCTL_STORAGE_MEDIA_REMOVAL.toString(16))
|
||||||
|
expect(reihenfolge[0]).toContain('ein=00')
|
||||||
|
expect(reihenfolge[1]).toContain(IOCTL_STORAGE_EJECT_MEDIA.toString(16))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Erfolg des Auswurf-Befehls allein ist KEIN ok — bleibt-drin', async () => {
|
||||||
|
// Der v1-Fehler: CDROMEJECT quittiert Erfolg auf verriegeltem Laufwerk
|
||||||
|
// und tut nichts. CHECK_VERIFY2 sagt hier weiter „Medium da".
|
||||||
|
const api = fakeApi({ antworten: new Map() })
|
||||||
|
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||||
|
expect(grund).toBe('bleibt-drin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kein Zugriff aufs Gerät heißt kein-zugriff, wirft nicht', async () => {
|
||||||
|
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
|
||||||
|
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||||
|
expect(grund).toBe('kein-zugriff')
|
||||||
|
expect(fehler).toContain('Win32-Fehler 5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('abgelehnter Auswurf-Befehl heißt abgelehnt', async () => {
|
||||||
|
const api = fakeApi({ antworten: new Map([[IOCTL_STORAGE_EJECT_MEDIA, ERROR_INVALID_FUNCTION]]) })
|
||||||
|
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||||
|
expect(grund).toBe('abgelehnt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gescheitertes Entriegeln verhindert den Auswurf-Versuch nicht', async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
antworten: new Map<number, Buffer | number>([
|
||||||
|
[IOCTL_STORAGE_MEDIA_REMOVAL, ERROR_INVALID_FUNCTION],
|
||||||
|
[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
|
||||||
|
expect(grund).toBe('ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('geraeteInfo — ready/empty/unknown, nie eine falsche Aussage', () => {
|
||||||
|
const beschreibung = (() => {
|
||||||
|
// STORAGE_DEVICE_DESCRIPTOR: Offsets bei 12/16/20/24 → Strings ab 40.
|
||||||
|
const b = Buffer.alloc(96)
|
||||||
|
b.writeUInt32LE(40, 12)
|
||||||
|
b.write('HL-DT-ST\x00', 40, 'latin1')
|
||||||
|
b.writeUInt32LE(56, 16)
|
||||||
|
b.write('BD-RE BU40N\x00', 56, 'latin1')
|
||||||
|
b.writeUInt32LE(72, 20)
|
||||||
|
b.write('1.03\x00', 72, 'latin1')
|
||||||
|
b.writeUInt32LE(80, 24)
|
||||||
|
b.write('0025114C0149\x00', 80, 'latin1')
|
||||||
|
return b
|
||||||
|
})()
|
||||||
|
|
||||||
|
it('Disc drin: ready mit Typ, Größe, Modell, Seriennummer', () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
antworten: new Map<number, Buffer | number>([
|
||||||
|
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
|
||||||
|
[IOCTL_CDROM_DISK_TYPE, u32(CDROM_DISK_DATA_TRACK)],
|
||||||
|
[IOCTL_DISK_GET_LENGTH_INFO, u64(33_759_690_752n)],
|
||||||
|
[IOCTL_STORAGE_QUERY_PROPERTY, beschreibung],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
id: 'G',
|
||||||
|
status: 'ready',
|
||||||
|
typ: 'bluray',
|
||||||
|
groesseBytes: 33_759_690_752,
|
||||||
|
modell: 'HL-DT-ST BD-RE BU40N 1.03',
|
||||||
|
seriennummer: '0025114C0149',
|
||||||
|
grund: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leeres Laufwerk: empty, kein Fehler', () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
antworten: new Map<number, Buffer | number>([[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY]]),
|
||||||
|
})
|
||||||
|
expect(geraeteInfo(api, '\\\\.\\G:').status).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Gerät nicht ansprechbar: unknown MIT Grund — nicht empty (R2)', () => {
|
||||||
|
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
|
||||||
|
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||||
|
expect(info.status).toBe('unknown')
|
||||||
|
expect(info.grund).toContain('hält das Laufwerk fest')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der 30.08.-Fall: Medium da, aber Typ-Abfrage bricht — ready mit Grund', () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
antworten: new Map<number, Buffer | number>([
|
||||||
|
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
|
||||||
|
[IOCTL_DISK_GET_LENGTH_INFO, ERROR_INVALID_FUNCTION],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
const info = geraeteInfo(api, '\\\\.\\G:')
|
||||||
|
expect(info.status).toBe('ready')
|
||||||
|
expect(info.typ).toBe('unknown')
|
||||||
|
expect(info.grund).toContain('beantwortet keine Medien-Abfragen mehr')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// AUSWURF-MESSUNG an echter Hardware — der W-1-Beweis „Auswerfen wirkt
|
||||||
|
// (nachgemessen, nicht geglaubt)". Läuft NUR auf ausdrückliches Verlangen:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG_AUSWURF=1 npx vitest run test/messung.auswurf.test.ts
|
||||||
|
//
|
||||||
|
// ⚠ Wirft die Disc im ersten optischen Laufwerk WIRKLICH aus. Der BU40N
|
||||||
|
// kann die Schublade nicht selbst einziehen (LOAD_MEDIA →
|
||||||
|
// ERROR_INVALID_FUNCTION, windows.py 28.08.2026) — nach dem Lauf bleibt
|
||||||
|
// der Tray offen. Deshalb getrennt von messung.laufwerk (nur lesend).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG_AUSWURF === '1' && process.platform === 'win32'
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('Auswurf am echten Laufwerk — Zustand davor und danach', () => {
|
||||||
|
it('entriegelt, wirft aus und SIEHT NACH', async () => {
|
||||||
|
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||||
|
const { auswerfenMitGrund, geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||||
|
|
||||||
|
const api = win32Api()
|
||||||
|
const geraete = listOptischeLaufwerke(api)
|
||||||
|
expect(geraete.length).toBeGreaterThan(0)
|
||||||
|
const geraet = geraete[0]
|
||||||
|
|
||||||
|
const davor = geraeteInfo(api, geraet)
|
||||||
|
console.log(`MESSUNG davor: status=${davor.status} typ=${davor.typ}`)
|
||||||
|
expect(davor.status).toBe('ready') // ohne Disc misst dieser Lauf nichts
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
|
const { grund, fehler } = await auswerfenMitGrund(api, geraet)
|
||||||
|
const dauerMs = Date.now() - start
|
||||||
|
console.log(`MESSUNG auswurf: grund=${grund} fehler=${fehler ?? '-'} dauer=${dauerMs}ms`)
|
||||||
|
|
||||||
|
const danach = geraeteInfo(api, geraet)
|
||||||
|
console.log(`MESSUNG danach: status=${danach.status}`)
|
||||||
|
|
||||||
|
// Der eigentliche Beweis ist der ZUSTAND davor/danach — nicht der
|
||||||
|
// Rückgabewert des Steuercodes (windows.py: 4 → 1 am echten Gerät).
|
||||||
|
expect(grund).toBe('ok')
|
||||||
|
expect(danach.status).toBe('empty')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Auswurf-Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG_AUSWURF=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// DER W-3-BEWEIS: kompletter Encode einer echten Roh-MKV plus Ablage in
|
||||||
|
// echter Jellyfin-Struktur (Ordner „<Titel> (Jahr)" + movie.nfo). Läuft
|
||||||
|
// nur auf Verlangen und dauert je nach Material 20 min bis Stunden:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG_ENCODE=1 RIPPY_MESSUNG_MKV=<roh.mkv> \
|
||||||
|
// RIPPY_MESSUNG_ABLAGE=<basis> RIPPY_MESSUNG_TITEL=<titel> \
|
||||||
|
// RIPPY_MESSUNG_JAHR=<jahr> [RIPPY_MESSUNG_PRESET=<preset>] \
|
||||||
|
// npx vitest run test/messung.encode-voll.test.ts --reporter=verbose
|
||||||
|
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG_ENCODE === '1' && process.platform === 'win32'
|
||||||
|
const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? ''
|
||||||
|
const ablageBasis = process.env.RIPPY_MESSUNG_ABLAGE ?? ''
|
||||||
|
const titel = process.env.RIPPY_MESSUNG_TITEL ?? 'Unbenannt'
|
||||||
|
const jahr = process.env.RIPPY_MESSUNG_JAHR ?? ''
|
||||||
|
const preset = process.env.RIPPY_MESSUNG_PRESET ?? 'H.265 MKV 1080p30'
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('Voller Encode + Ablage am echten System', () => {
|
||||||
|
it('komprimiert und legt nach Jellyfin-Schema ab', { timeout: 8 * 60 * 60 * 1000 }, async () => {
|
||||||
|
expect(rohMkv.length, 'RIPPY_MESSUNG_MKV fehlt').toBeGreaterThan(0)
|
||||||
|
expect(existsSync(rohMkv), `${rohMkv} fehlt`).toBe(true)
|
||||||
|
expect(ablageBasis.length, 'RIPPY_MESSUNG_ABLAGE fehlt').toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { komprimieren } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const { zielordner } = await import('../src/kern/ablage/struktur')
|
||||||
|
const { baueNfo } = await import('../src/kern/ablage/nfo')
|
||||||
|
|
||||||
|
const programm = finden('handbrake')
|
||||||
|
expect(programm.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const ordner = zielordner(ablageBasis, titel, jahr.length > 0 ? jahr : null, 'v5-beweis')
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const ausgabe = pfad.join(ordner, pfad.basename(ordner) + '.mkv')
|
||||||
|
|
||||||
|
console.log(`ENCODE-START ${rohMkv} → ${ausgabe} · Preset „${preset}"`)
|
||||||
|
const start = Date.now()
|
||||||
|
let letzteStufe = -5
|
||||||
|
const ergebnis = await komprimieren({
|
||||||
|
programm,
|
||||||
|
eingabe: rohMkv,
|
||||||
|
ausgabe,
|
||||||
|
preset,
|
||||||
|
audioSprachen: ['deu', 'eng'],
|
||||||
|
aufFortschritt: (prozent) => {
|
||||||
|
if (prozent >= letzteStufe + 5) {
|
||||||
|
letzteStufe = prozent
|
||||||
|
console.log(`ENCODE-FORTSCHRITT ${prozent}% nach ${((Date.now() - start) / 60000).toFixed(1)}min`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||||
|
|
||||||
|
if (ergebnis.status !== 'success') {
|
||||||
|
console.log(`ENCODE-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||||
|
}
|
||||||
|
expect(ergebnis.status).toBe('success')
|
||||||
|
if (ergebnis.status !== 'success') return
|
||||||
|
|
||||||
|
const rohGb = statSync(rohMkv).size / 1e9
|
||||||
|
const fertigGb = statSync(ergebnis.ausgabe).size / 1e9
|
||||||
|
console.log(
|
||||||
|
`ENCODE-ERGEBNIS success nach ${minuten}min · ${rohGb.toFixed(2)} GB → ${fertigGb.toFixed(2)} GB` +
|
||||||
|
(ergebnis.hinweis !== undefined ? ` · ${ergebnis.hinweis}` : ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const nfoPfad = pfad.join(ordner, 'movie.nfo')
|
||||||
|
writeFileSync(nfoPfad, baueNfo({ quelle: 'Rippy v5 W-3-Beweis' }, titel, jahr.length > 0 ? jahr : null))
|
||||||
|
console.log(`ABLAGE-FERTIG ${ordner} (movie.nfo + ${pfad.basename(ergebnis.ausgabe)})`)
|
||||||
|
expect(existsSync(nfoPfad)).toBe(true)
|
||||||
|
expect(fertigGb).toBeGreaterThan(0.05)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Encode-Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG_ENCODE=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// MESSUNG am echten HandBrakeCLI: Encoder-Auskunft und ein kurzer
|
||||||
|
// Probe-Encode an einer echten Roh-MKV. Läuft nur auf Verlangen:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG=1 [RIPPY_MESSUNG_MKV=<roh.mkv>] \
|
||||||
|
// npx vitest run test/messung.handbrake.test.ts --reporter=verbose
|
||||||
|
import { existsSync, mkdtempSync, statSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||||
|
const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? ''
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('HandBrake am echten System', () => {
|
||||||
|
it('meldet Encoder, Backends und Presets', { timeout: 120_000 }, async () => {
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { encoderMessen } = await import('../src/kern/komprimieren/encoder')
|
||||||
|
const programm = finden('handbrake')
|
||||||
|
expect(programm.length).toBeGreaterThan(0)
|
||||||
|
const auskunft = await encoderMessen(programm)
|
||||||
|
console.log(`MESSUNG Encoder (${auskunft.encoder.length}): ${auskunft.encoder.join(', ')}`)
|
||||||
|
console.log(`MESSUNG Backends: ${auskunft.backends.join(', ')}`)
|
||||||
|
console.log(`MESSUNG Presets: ${auskunft.presets.length} Stück, z. B. ${auskunft.presets.slice(0, 4).join(' | ')}`)
|
||||||
|
expect(auskunft.encoder.length).toBeGreaterThan(0)
|
||||||
|
expect(auskunft.presets.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(rohMkv.length === 0 || !existsSync(rohMkv))(
|
||||||
|
'kurzer Probe-Encode an der echten Roh-MKV (5 s Material)',
|
||||||
|
{ timeout: 15 * 60_000 },
|
||||||
|
async () => {
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { komprimieren } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const programm = finden('handbrake')
|
||||||
|
const ziel = join(mkdtempSync(join(tmpdir(), 'rippy-hb-')), 'probe.mkv')
|
||||||
|
const start = Date.now()
|
||||||
|
const ergebnis = await komprimieren({
|
||||||
|
programm,
|
||||||
|
eingabe: rohMkv,
|
||||||
|
ausgabe: ziel,
|
||||||
|
preset: 'H.265 MKV 1080p30',
|
||||||
|
audioSprachen: ['deu', 'jpn', 'eng'],
|
||||||
|
extraArgs: ['--start-at', 'duration:60', '--stop-at', 'duration:5'],
|
||||||
|
})
|
||||||
|
const sekunden = ((Date.now() - start) / 1000).toFixed(1)
|
||||||
|
if (ergebnis.status === 'success') {
|
||||||
|
console.log(
|
||||||
|
`MESSUNG Probe-Encode: success in ${sekunden}s → ${ergebnis.ausgabe} (${(statSync(ergebnis.ausgabe).size / 1e6).toFixed(1)} MB)` +
|
||||||
|
(ergebnis.hinweis !== undefined ? ` · Hinweis: ${ergebnis.hinweis}` : ''),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log(`MESSUNG Probe-Encode: ${ergebnis.status} in ${sekunden}s ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||||
|
}
|
||||||
|
expect(ergebnis.status).toBe('success')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// MESSUNG an echter Hardware — läuft nur auf Verlangen:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG=1 npx vitest run test/messung.laufwerk.test.ts
|
||||||
|
//
|
||||||
|
// In der Ampel (Linux, kein Laufwerk) wird sie übersprungen; die Ergebnisse
|
||||||
|
// werden im SAVEPOINT.md mit Ausgabe dokumentiert (beweisen statt
|
||||||
|
// behaupten). NUR LESEND — kein Auswerfen, kein Verriegeln; der
|
||||||
|
// Auswurf-Beweis ist ein eigener, ausdrücklicher Lauf (messung.auswurf).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('Messung am echten Laufwerk', () => {
|
||||||
|
it('listet Laufwerke und liest ihren Zustand', async () => {
|
||||||
|
// koffi erst hier laden — beim Einsammeln der Tests bleibt alles rein.
|
||||||
|
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||||
|
const { geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||||
|
|
||||||
|
const api = win32Api()
|
||||||
|
const geraete = listOptischeLaufwerke(api)
|
||||||
|
console.log(`MESSUNG optische Laufwerke: ${JSON.stringify(geraete)}`)
|
||||||
|
expect(Array.isArray(geraete)).toBe(true)
|
||||||
|
|
||||||
|
for (const geraet of geraete) {
|
||||||
|
const info = geraeteInfo(api, geraet)
|
||||||
|
console.log(
|
||||||
|
`MESSUNG ${info.name} status=${info.status} typ=${info.typ} ` +
|
||||||
|
`groesse=${info.groesseBytes} modell="${info.modell}" ` +
|
||||||
|
`serial=${info.seriennummer}${info.grund.length > 0 ? ` grund="${info.grund}"` : ''}`,
|
||||||
|
)
|
||||||
|
expect(info.id.length).toBeGreaterThan(0)
|
||||||
|
expect(['ready', 'empty', 'unknown']).toContain(info.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ohne Messung ist die Datei trotzdem ein gültiges Test-Modul.
|
||||||
|
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// MESSUNG an echter Hardware: makemkvcon finden und die eingelegte Disc
|
||||||
|
// befragen (Titel + Sprachen). Läuft nur auf Verlangen:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG=1 npx vitest run test/messung.makemkv.test.ts
|
||||||
|
//
|
||||||
|
// Der VOLLE Rip ist ein eigener Lauf (messung.rip.test.ts) — er dauert
|
||||||
|
// je Disc 30–90 Minuten.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('makemkvcon am echten System', () => {
|
||||||
|
it('wird über den Katalog gefunden', async () => {
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const pfad = finden('makemkv')
|
||||||
|
console.log(`MESSUNG makemkvcon: ${pfad.length > 0 ? pfad : 'NICHT GEFUNDEN'}`)
|
||||||
|
const handbrake = finden('handbrake')
|
||||||
|
console.log(`MESSUNG HandBrakeCLI: ${handbrake.length > 0 ? handbrake : 'NICHT GEFUNDEN'}`)
|
||||||
|
const flac = finden('flac')
|
||||||
|
console.log(`MESSUNG flac: ${flac.length > 0 ? flac : 'NICHT GEFUNDEN'}`)
|
||||||
|
expect(pfad.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest Titel und Sprachen der eingelegten Disc', { timeout: 360_000 }, async () => {
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
|
||||||
|
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||||
|
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||||
|
|
||||||
|
const programm = finden('makemkv')
|
||||||
|
expect(programm.length).toBeGreaterThan(0)
|
||||||
|
const geraete = listOptischeLaufwerke(win32Api())
|
||||||
|
expect(geraete.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const info = await titelInfoLesen(programm, geraete[0])
|
||||||
|
console.log(`MESSUNG Titel: ${info.titel.length}`)
|
||||||
|
for (const t of info.titel.slice(0, 12)) {
|
||||||
|
console.log(
|
||||||
|
`MESSUNG #${t.nr}: ${Math.floor(t.dauerS / 60)}min ${t.dauerS % 60}s · ` +
|
||||||
|
`${(t.groesseBytes / 1e9).toFixed(2)} GB · ${t.kapitel} Kapitel`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`MESSUNG Sprachen Ton: ${info.sprachen.audio.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`MESSUNG Sprachen Untertitel: ${info.sprachen.untertitel.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
|
||||||
|
)
|
||||||
|
expect(info.titel.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// MESSUNG gegen die echten Metadaten-APIs (TMDb/OMDb) — läuft nur auf
|
||||||
|
// Verlangen und mit Schlüsseln aus der Umgebung (nie committet):
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG_META=1 RIPPY_TMDB_KEY=… RIPPY_OMDB_KEY=… \
|
||||||
|
// npx vitest run test/messung.metadaten.test.ts --reporter=verbose
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG_META === '1'
|
||||||
|
const tmdbKey = process.env.RIPPY_TMDB_KEY ?? ''
|
||||||
|
const omdbKey = process.env.RIPPY_OMDB_KEY ?? ''
|
||||||
|
|
||||||
|
// Echte Disc-Titel-Fälle aus dem Projektalltag: der saubere Studio-Titel,
|
||||||
|
// der spezifische Fall (Evangelion-Fund), ein kryptisches Label und eine
|
||||||
|
// Serien-Disc.
|
||||||
|
const FAELLE = [
|
||||||
|
'Akira',
|
||||||
|
'Evangelion 2.22',
|
||||||
|
'Spartacus Gods Of The Arena',
|
||||||
|
'Bd Evg D2',
|
||||||
|
]
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('Zuordnung gegen echte APIs', () => {
|
||||||
|
it('ordnet die Projekt-Fälle zu', { timeout: 120_000 }, async () => {
|
||||||
|
expect(tmdbKey.length, 'RIPPY_TMDB_KEY fehlt').toBeGreaterThan(0)
|
||||||
|
const { TmdbClient } = await import('../src/kern/metadaten/tmdb')
|
||||||
|
const { OmdbClient } = await import('../src/kern/metadaten/omdb')
|
||||||
|
const { zuordnen } = await import('../src/kern/metadaten/zuordnung')
|
||||||
|
|
||||||
|
const tmdb = new TmdbClient(tmdbKey)
|
||||||
|
const omdb = new OmdbClient(omdbKey)
|
||||||
|
for (const fall of FAELLE) {
|
||||||
|
const z = await zuordnen(fall, tmdb, omdb)
|
||||||
|
console.log(
|
||||||
|
`MESSUNG „${fall}" → „${z.titel}"${z.jahr !== null ? ` (${z.jahr})` : ''} · ` +
|
||||||
|
`${Math.round(z.confidence * 100)} % · ${z.meta.quelle || 'keine Quelle'} · ${z.meta.typ}`,
|
||||||
|
)
|
||||||
|
expect(z.confidence).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Metadaten-Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG_META=1', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// DER W-2-BEWEIS: ein voller, verlustfreier Rip der eingelegten Disc am
|
||||||
|
// echten Laufwerk — dauert 30–90 Minuten. Läuft nur auf Verlangen:
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG_RIP=1 RIPPY_MESSUNG_ZIEL=<ordner> \
|
||||||
|
// npx vitest run test/messung.rip.test.ts --reporter=verbose
|
||||||
|
//
|
||||||
|
// Fortschritt wird in 5-%-Schritten geloggt; das Ergebnis nennt Dateien
|
||||||
|
// und Größen. Das Protokoll wandert in den SAVEPOINT (beweisen statt
|
||||||
|
// behaupten).
|
||||||
|
import { statSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const messen = process.env.RIPPY_MESSUNG_RIP === '1' && process.platform === 'win32'
|
||||||
|
const zielWurzel = process.env.RIPPY_MESSUNG_ZIEL ?? ''
|
||||||
|
|
||||||
|
describe.skipIf(!messen)('Voller Rip am echten Laufwerk', () => {
|
||||||
|
it('rippt die eingelegte Disc verlustfrei', { timeout: 3 * 60 * 60 * 1000 }, async () => {
|
||||||
|
expect(zielWurzel.length, 'RIPPY_MESSUNG_ZIEL fehlt').toBeGreaterThan(0)
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { rippen } = await import('../src/kern/rip/makemkv')
|
||||||
|
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||||
|
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||||
|
|
||||||
|
const programm = finden('makemkv')
|
||||||
|
expect(programm.length).toBeGreaterThan(0)
|
||||||
|
const geraete = listOptischeLaufwerke(win32Api())
|
||||||
|
expect(geraete.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
|
let letzteStufe = -5
|
||||||
|
const ergebnis = await rippen({
|
||||||
|
programm,
|
||||||
|
geraetepfad: geraete[0],
|
||||||
|
zielOrdner: zielWurzel,
|
||||||
|
titel: 'all',
|
||||||
|
aufFortschritt: (prozent) => {
|
||||||
|
if (prozent >= letzteStufe + 5) {
|
||||||
|
letzteStufe = prozent
|
||||||
|
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||||
|
console.log(`RIP-FORTSCHRITT ${prozent}% nach ${minuten}min`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
aufMeldung: (code, text) => {
|
||||||
|
// Nur die tragenden Meldungen — sonst wird das Protokoll unlesbar.
|
||||||
|
if (code >= 5000 || text.includes('errors') || text.includes('gesichert') || text.includes('saved')) {
|
||||||
|
console.log(`RIP-MSG ${code}: ${text}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||||
|
if (ergebnis.status === 'success') {
|
||||||
|
console.log(`RIP-ERGEBNIS success nach ${minuten}min · lesefehler=${ergebnis.lesefehler}`)
|
||||||
|
for (const datei of ergebnis.dateien) {
|
||||||
|
console.log(`RIP-DATEI ${datei} · ${(statSync(datei).size / 1e9).toFixed(2)} GB`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`RIP-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||||
|
}
|
||||||
|
expect(ergebnis.status).toBe('success')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(messen)('Rip-Messung übersprungen', () => {
|
||||||
|
it('läuft nur mit RIPPY_MESSUNG_RIP=1 unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// Tests der Metadaten-Schicht: Disc-Merkmale (pur/Dateisystem) und die
|
||||||
|
// Confidence-Kaskade mit nachgebildeten Clients — jede Stufe hat ihren
|
||||||
|
// gemessenen Grund (prescan.py).
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
normalizeDiscLabel,
|
||||||
|
parseUdfDstring,
|
||||||
|
titelAusBdmt,
|
||||||
|
titelKandidaten,
|
||||||
|
} from '../src/kern/metadaten/discmerkmale'
|
||||||
|
import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung'
|
||||||
|
import { posterUrl } from '../src/kern/ablage/poster'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeDiscLabel', () => {
|
||||||
|
it('GROSS_MIT_UNTERSTRICHEN → Titel-Schreibung', () => {
|
||||||
|
expect(normalizeDiscLabel('PULP_FICTION_DE')).toBe('Pulp Fiction De')
|
||||||
|
expect(normalizeDiscLabel('BD_EVG_D2')).toBe('Bd Evg D2')
|
||||||
|
})
|
||||||
|
it('Gemischtes bleibt, wie es ist', () => {
|
||||||
|
expect(normalizeDiscLabel('Akira 4K')).toBe('Akira 4K')
|
||||||
|
expect(normalizeDiscLabel('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseUdfDstring', () => {
|
||||||
|
it('liest Latin-1 (Kompressions-ID 8)', () => {
|
||||||
|
const roh = Buffer.concat([Buffer.from([8]), Buffer.from('SPARTACUS', 'latin1'), Buffer.alloc(21)])
|
||||||
|
roh[roh.length - 1] = 10 // genutzte Länge: ID + 9 Zeichen
|
||||||
|
expect(parseUdfDstring(roh)).toBe('SPARTACUS')
|
||||||
|
})
|
||||||
|
it('liest UTF-16BE (Kompressions-ID 16)', () => {
|
||||||
|
const text = Buffer.from([0x00, 0x41, 0x00, 0x42])
|
||||||
|
const roh = Buffer.concat([Buffer.from([16]), text, Buffer.alloc(27)])
|
||||||
|
roh[roh.length - 1] = 1 + text.length
|
||||||
|
expect(parseUdfDstring(roh)).toBe('AB')
|
||||||
|
})
|
||||||
|
it('Unsinnige Längen sind leer, kein Absturz', () => {
|
||||||
|
expect(parseUdfDstring(Buffer.alloc(0))).toBe('')
|
||||||
|
expect(parseUdfDstring(Buffer.from([8, 65, 99]))).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('titelAusBdmt', () => {
|
||||||
|
it('liest den Studio-Titel, bdmt_eng bevorzugt', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
const meta = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
|
mkdirSync(meta, { recursive: true })
|
||||||
|
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Deutscher Titel</di:name></x>')
|
||||||
|
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>English Title</di:name></x>')
|
||||||
|
expect(titelAusBdmt(wurzel)).toBe('English Title')
|
||||||
|
})
|
||||||
|
it('ohne BDMV-Metadaten leer', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
expect(titelAusBdmt(wurzel)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('titelKandidaten — progressive Degradation (ARM-Lehre)', () => {
|
||||||
|
it('voller Titel zuerst, dann Varianten, dann gestutzt', () => {
|
||||||
|
const kandidaten = titelKandidaten('Evangelion: 2.22 You Can (Not) Advance.')
|
||||||
|
expect(kandidaten[0]).toBe('Evangelion: 2.22 You Can (Not) Advance')
|
||||||
|
expect(kandidaten).toContain('Evangelion')
|
||||||
|
expect(kandidaten.length).toBeLessThanOrEqual(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Die Kaskade mit Fake-Clients ────────────────────────────────────────
|
||||||
|
interface FilmEintrag {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, FilmEintrag[]> = {}) {
|
||||||
|
return {
|
||||||
|
verfuegbar: true,
|
||||||
|
searchMovie: async (t: string) => filme[t] ?? [],
|
||||||
|
searchTv: async (t: string) => (serien[t] ?? []).map((s) => ({ id: s.id, name: s.title })),
|
||||||
|
movieDetails: async (id: number) => {
|
||||||
|
for (const liste of Object.values(filme)) {
|
||||||
|
const treffer = liste.find((f) => f.id === id)
|
||||||
|
if (treffer !== undefined) {
|
||||||
|
return { id, title: treffer.title, release_date: '1988-07-16', overview: 'Ü', ...treffer.details }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
tvDetails: async (id: number) => {
|
||||||
|
for (const liste of Object.values(serien)) {
|
||||||
|
const treffer = liste.find((f) => f.id === id)
|
||||||
|
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
findByImdb: async () => null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keinOmdb = { verfuegbar: false, lookup: async () => null }
|
||||||
|
|
||||||
|
describe('zuordnen — die Confidence-Kaskade', () => {
|
||||||
|
it('wörtlicher Filmtreffer: 0,95', async () => {
|
||||||
|
const z = await zuordnen('Akira', fakeTmdb({ Akira: [{ id: 1, title: 'Akira' }] }), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.titel).toBe('Akira')
|
||||||
|
expect(z.jahr).toBe(1988)
|
||||||
|
expect(istSicherGenug(z)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wörtlicher Serientreffer: 0,90', async () => {
|
||||||
|
const z = await zuordnen('Akte X', fakeTmdb({}, { 'Akte X': [{ id: 7, title: 'Akte X' }] }), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.9)
|
||||||
|
expect(z.meta.typ).toBe('tv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der Evangelion-Fund: voller Titel + wenige Treffer = 0,80', async () => {
|
||||||
|
// 'Evangelion 2.22' → 1 Treffer, dessen Titel NICHT wörtlich gleich
|
||||||
|
// ist — vor dem Fix wurde genau der richtige Film verworfen.
|
||||||
|
const z = await zuordnen(
|
||||||
|
'Evangelion 2.22',
|
||||||
|
fakeTmdb({ 'Evangelion 2.22': [{ id: 9, title: 'Evangelion: 2.0 You Can (Not) Advance' }] }),
|
||||||
|
keinOmdb,
|
||||||
|
)
|
||||||
|
expect(z.confidence).toBe(0.8)
|
||||||
|
expect(z.titel).toBe('Evangelion: 2.0 You Can (Not) Advance')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('viele Treffer auf den vollen Titel sind KEIN spezifischer Fund', async () => {
|
||||||
|
const viele = Array.from({ length: WENIGE_TREFFER + 1 }, (_, i) => ({
|
||||||
|
id: 100 + i,
|
||||||
|
title: `Irgendwas ${i}`,
|
||||||
|
}))
|
||||||
|
const z = await zuordnen('Evangelion', fakeTmdb({ Evangelion: viele }), keinOmdb)
|
||||||
|
// Kaskade fällt bis zum Vorschlag durch (0,6) — das UI fragt nach.
|
||||||
|
expect(z.confidence).toBe(0.6)
|
||||||
|
expect(istSicherGenug(z)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('OMDb greift, wenn TMDb nichts Spezifisches hat: 0,80', async () => {
|
||||||
|
const omdb = {
|
||||||
|
verfuegbar: true,
|
||||||
|
lookup: async (t: string) =>
|
||||||
|
t === 'Seltener Film'
|
||||||
|
? {
|
||||||
|
type: 'movie' as const,
|
||||||
|
id: 'tt0123',
|
||||||
|
title: 'Seltener Film',
|
||||||
|
year: 1999,
|
||||||
|
overview: 'en',
|
||||||
|
poster_path: '',
|
||||||
|
runtime: 100,
|
||||||
|
genres: [],
|
||||||
|
source: 'omdb' as const,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
const z = await zuordnen('Seltener Film', fakeTmdb({}), omdb)
|
||||||
|
expect(z.confidence).toBe(0.8)
|
||||||
|
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 () => {
|
||||||
|
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
|
||||||
|
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
||||||
|
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
|
||||||
|
const z = await zuordnen(
|
||||||
|
'Spartacus Gods Of The Arena',
|
||||||
|
fakeTmdb(
|
||||||
|
{ Spartacus: [{ id: 5, title: 'Spartacus' }] },
|
||||||
|
{ 'Spartacus Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] },
|
||||||
|
),
|
||||||
|
keinOmdb,
|
||||||
|
)
|
||||||
|
expect(z.confidence).toBe(0.8)
|
||||||
|
expect(z.meta.typ).toBe('tv')
|
||||||
|
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('OMDb-Unsinn fällt am Ähnlichkeits-Gate (der Bd-Evg-D2-Fund)', async () => {
|
||||||
|
const omdb = {
|
||||||
|
verfuegbar: true,
|
||||||
|
lookup: async () => ({
|
||||||
|
type: 'movie' as const,
|
||||||
|
id: 'tt9',
|
||||||
|
title: 'BD Scream 5',
|
||||||
|
year: 2013,
|
||||||
|
overview: '',
|
||||||
|
poster_path: '',
|
||||||
|
runtime: 90,
|
||||||
|
genres: [],
|
||||||
|
source: 'omdb' as const,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const z = await zuordnen('Bd Evg D2', fakeTmdb({}), omdb)
|
||||||
|
expect(z.confidence).toBe(0.3)
|
||||||
|
expect(z.meta.typ).toBe('unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wortUeberlappung rechnet Jaccard über Wörter', () => {
|
||||||
|
expect(wortUeberlappung('Pulp Fiction', 'Pulp Fiction')).toBe(1)
|
||||||
|
expect(wortUeberlappung('Bd Evg D2', 'BD Scream 5')).toBeLessThan(0.5)
|
||||||
|
expect(wortUeberlappung('', 'x')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gar nichts gefunden: 0,30 mit dem Disc-Titel', async () => {
|
||||||
|
const z = await zuordnen('Logical Volume Id', fakeTmdb({}), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.3)
|
||||||
|
expect(z.titel).toBe('Logical Volume Id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leerer Titel bleibt unbekannt, ohne API-Anfragen', async () => {
|
||||||
|
const z = await zuordnen('', fakeTmdb({}), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('posterUrl', () => {
|
||||||
|
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
||||||
|
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
|
||||||
|
expect(posterUrl('https://x/y.jpg')).toBe('https://x/y.jpg')
|
||||||
|
expect(posterUrl('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Tests für das Nachrichten-Schema (gemeinsam/nachrichten.ts). Die
|
||||||
|
// Prüf-Funktionen sind die Eingangstür beider Prozesse — was hier
|
||||||
|
// durchkommt, wird drüben ungeprüft verwendet.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
istDbStatus,
|
||||||
|
istFensterNachricht,
|
||||||
|
istHauptNachricht,
|
||||||
|
istKernNachricht,
|
||||||
|
} from '../src/gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
describe('istKernNachricht', () => {
|
||||||
|
it('akzeptiert kern-bereit mit vollständigen Feldern', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'kern-bereit',
|
||||||
|
pid: 1234,
|
||||||
|
nodeVersion: '24.18.1',
|
||||||
|
datenbank: { ok: true, pfad: 'C:\\x\\rippy.db' },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('akzeptiert kern-bereit auch mit Datenbank-Fehler', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'kern-bereit',
|
||||||
|
pid: 1,
|
||||||
|
nodeVersion: '24.18.1',
|
||||||
|
datenbank: { ok: false, fehler: 'Platte voll' },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('akzeptiert pong und kern-fehler', () => {
|
||||||
|
expect(istKernNachricht({ art: 'pong', zeit: 17 })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'kern-fehler', text: 'kaputt' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lehnt Unvollständiges und Fremdes ab', () => {
|
||||||
|
expect(istKernNachricht(null)).toBe(false)
|
||||||
|
expect(istKernNachricht('kern-bereit')).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'kern-bereit', pid: 1 })).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'pong', zeit: 'gleich' })).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'unbekannt' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istDbStatus', () => {
|
||||||
|
it('verlangt zum ok-Wert den Pfad, zum Fehler den Text', () => {
|
||||||
|
expect(istDbStatus({ ok: true, pfad: 'x' })).toBe(true)
|
||||||
|
expect(istDbStatus({ ok: false, fehler: 'y' })).toBe(true)
|
||||||
|
expect(istDbStatus({ ok: true })).toBe(false)
|
||||||
|
expect(istDbStatus({ ok: false })).toBe(false)
|
||||||
|
expect(istDbStatus({ ok: 'ja' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istFensterNachricht', () => {
|
||||||
|
it('akzeptiert nur ping mit Zeit', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'ping', zeit: 1 })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'ping' })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'pong', zeit: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istHauptNachricht', () => {
|
||||||
|
it('akzeptiert nur fenster-port', () => {
|
||||||
|
expect(istHauptNachricht({ art: 'fenster-port' })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'port' })).toBe(false)
|
||||||
|
expect(istHauptNachricht(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten
|
||||||
|
// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake
|
||||||
|
// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt
|
||||||
|
// wo er liegt; Statusmeldungen tragen die Phase.
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { RipStatus } from '../src/gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
const ripErgebnis = vi.hoisted(() => ({
|
||||||
|
wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown,
|
||||||
|
}))
|
||||||
|
const encodeErgebnis = vi.hoisted(() => ({
|
||||||
|
wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/kern/rip/makemkv', () => ({
|
||||||
|
rippen: vi.fn(async () => ripErgebnis.wert),
|
||||||
|
}))
|
||||||
|
vi.mock('../src/kern/komprimieren/handbrake', () => ({
|
||||||
|
komprimieren: vi.fn(async () => encodeErgebnis.wert),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
|
||||||
|
pipeline: Pipeline
|
||||||
|
meldungen: RipStatus[]
|
||||||
|
} {
|
||||||
|
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
||||||
|
wegwerf.push(ablage)
|
||||||
|
const meldungen: RipStatus[] = []
|
||||||
|
const pipeline = new Pipeline({
|
||||||
|
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
|
||||||
|
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||||
|
flac: () => '',
|
||||||
|
geraet: () => ({
|
||||||
|
id: 'G',
|
||||||
|
name: 'Laufwerk G:',
|
||||||
|
typ: 'bluray',
|
||||||
|
pfad: '\\\\.\\G:',
|
||||||
|
status: 'ready',
|
||||||
|
modell: '',
|
||||||
|
seriennummer: '',
|
||||||
|
groesseBytes: 33e9,
|
||||||
|
grund: '',
|
||||||
|
}),
|
||||||
|
ablage: () => ablage,
|
||||||
|
einstellungen: () => ({}),
|
||||||
|
melden: (s) => meldungen.push(s),
|
||||||
|
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
||||||
|
...überschreiben,
|
||||||
|
})
|
||||||
|
return { pipeline, meldungen }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Pipeline', () => {
|
||||||
|
it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||||
|
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||||
|
const { pipeline, meldungen } = umgebung()
|
||||||
|
await pipeline.starten('G')
|
||||||
|
const phasen = meldungen.map((m) => m.phase)
|
||||||
|
expect(phasen[0]).toBe('rippt')
|
||||||
|
expect(phasen).toContain('komprimiert')
|
||||||
|
expect(phasen).toContain('legt-ab')
|
||||||
|
expect(phasen.at(-1)).toBe('fertig')
|
||||||
|
expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||||
|
const { pipeline, meldungen } = umgebung({
|
||||||
|
einstellungen: () => ({ transcodePresetBluray: 'keine' }),
|
||||||
|
})
|
||||||
|
await pipeline.starten('G')
|
||||||
|
const letzte = meldungen.at(-1)
|
||||||
|
expect(letzte?.phase).toBe('fertig')
|
||||||
|
expect(letzte?.text).toContain('abgewählt')
|
||||||
|
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||||
|
const { pipeline, meldungen } = umgebung({ handbrake: () => '' })
|
||||||
|
await pipeline.starten('G')
|
||||||
|
expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||||
|
encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' }
|
||||||
|
const { pipeline, meldungen } = umgebung()
|
||||||
|
await pipeline.starten('G')
|
||||||
|
const letzte = meldungen.at(-1)
|
||||||
|
expect(letzte?.phase).toBe('fehler')
|
||||||
|
expect(letzte?.text).toContain('bleibt stehen')
|
||||||
|
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true }
|
||||||
|
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||||
|
const { pipeline, meldungen } = umgebung()
|
||||||
|
await pipeline.starten('G')
|
||||||
|
const letzte = meldungen.at(-1)
|
||||||
|
expect(letzte?.lesefehler).toBe(true)
|
||||||
|
expect(letzte?.text).toContain('nicht lesen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
|
||||||
|
const { pipeline, meldungen } = umgebung({
|
||||||
|
geraet: () => ({
|
||||||
|
id: 'G',
|
||||||
|
name: 'Laufwerk G:',
|
||||||
|
typ: 'unknown',
|
||||||
|
pfad: '\\\\.\\G:',
|
||||||
|
status: 'empty',
|
||||||
|
modell: '',
|
||||||
|
seriennummer: '',
|
||||||
|
groesseBytes: 0,
|
||||||
|
grund: '',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await pipeline.starten('G')
|
||||||
|
expect(meldungen[0].phase).toBe('fehler')
|
||||||
|
expect(meldungen[0].text).toContain('Kein Datenträger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NFO landet im fertigen Ordner', async () => {
|
||||||
|
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||||
|
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||||
|
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
||||||
|
wegwerf.push(ablage)
|
||||||
|
const meldungen: RipStatus[] = []
|
||||||
|
const pipeline = new Pipeline({
|
||||||
|
makemkvcon: () => 'x',
|
||||||
|
handbrake: () => 'y',
|
||||||
|
flac: () => '',
|
||||||
|
geraet: () => ({
|
||||||
|
id: 'G',
|
||||||
|
name: 'Laufwerk G:',
|
||||||
|
typ: 'dvd',
|
||||||
|
pfad: '\\\\.\\G:',
|
||||||
|
status: 'ready',
|
||||||
|
modell: '',
|
||||||
|
seriennummer: '',
|
||||||
|
groesseBytes: 4e9,
|
||||||
|
grund: '',
|
||||||
|
}),
|
||||||
|
ablage: () => ablage,
|
||||||
|
einstellungen: () => ({}),
|
||||||
|
melden: (s) => meldungen.push(s),
|
||||||
|
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
||||||
|
})
|
||||||
|
await pipeline.starten('G')
|
||||||
|
expect(meldungen.at(-1)?.phase).toBe('fertig')
|
||||||
|
const { existsSync, readdirSync } = await import('node:fs')
|
||||||
|
const filme = join(ablage, 'Filme')
|
||||||
|
const ordner = readdirSync(filme)
|
||||||
|
expect(ordner).toHaveLength(1)
|
||||||
|
expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Tests der Rip-Lauf-Bausteine: Zeilenzerlegung (binär, halbe Zeilen)
|
||||||
|
// und die Auswertung (Fortschritt, Lesefehler, kritische Ursachen) —
|
||||||
|
// alles ohne echtes makemkvcon.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildInfoArgs, buildRipArgs, RipAuswertung, ZeilenLeser } from '../src/kern/rip/makemkv'
|
||||||
|
|
||||||
|
describe('Kommandobau', () => {
|
||||||
|
it('info: Robot-Mode ohne Geräte-Scan', () => {
|
||||||
|
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', 'info', 'dev:G:'])
|
||||||
|
})
|
||||||
|
it('mkv: Fortschritt in denselben Strom', () => {
|
||||||
|
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
|
||||||
|
'-r',
|
||||||
|
'--noscan',
|
||||||
|
'--progress=-same',
|
||||||
|
'mkv',
|
||||||
|
'dev:G:',
|
||||||
|
'all',
|
||||||
|
'C:\\ziel',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ZeilenLeser — binär, halbe Zeilen bleiben liegen', () => {
|
||||||
|
it('setzt Zeilen über Stück-Grenzen zusammen', () => {
|
||||||
|
const leser = new ZeilenLeser()
|
||||||
|
expect(leser.hinzu(Buffer.from('PRGV:1,2,'))).toEqual([])
|
||||||
|
expect(leser.hinzu(Buffer.from('65536\r\nMSG:1,0,0,"a"\nRest'))).toEqual([
|
||||||
|
'PRGV:1,2,65536',
|
||||||
|
'MSG:1,0,0,"a"',
|
||||||
|
])
|
||||||
|
expect(leser.schluss()).toEqual(['Rest'])
|
||||||
|
})
|
||||||
|
it('dekodiert jede Zeile einzeln — auch cp1252 mitten im Strom', () => {
|
||||||
|
const leser = new ZeilenLeser()
|
||||||
|
const zeilen = leser.hinzu(
|
||||||
|
Buffer.concat([Buffer.from('MSG:5010,0,0,"Das '), Buffer.from('Öffnen', 'latin1'), Buffer.from('"\n')]),
|
||||||
|
)
|
||||||
|
expect(zeilen).toEqual(['MSG:5010,0,0,"Das Öffnen"'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RipAuswertung', () => {
|
||||||
|
it('meldet Fortschritt nur bei Änderung', () => {
|
||||||
|
const a = new RipAuswertung()
|
||||||
|
expect(a.verarbeite('PRGV:0,32768,65536')).toEqual({ prozent: 50 })
|
||||||
|
expect(a.verarbeite('PRGV:0,32769,65536')).toEqual({})
|
||||||
|
expect(a.verarbeite('PRGV:0,45876,65536')).toEqual({ prozent: 70 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt Lesefehler an der sprachfesten URL — auch auf Deutsch', () => {
|
||||||
|
const a = new RipAuswertung()
|
||||||
|
a.verarbeite(
|
||||||
|
'MSG:2003,0,3,"Encountered 29 errors of type \'Read Error\' - see http://www.makemkv.com/errors/read/","f"',
|
||||||
|
)
|
||||||
|
expect(a.lesefehler).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sammelt kritische URSACHEN über die Nummer, nicht den Text', () => {
|
||||||
|
const a = new RipAuswertung()
|
||||||
|
a.verarbeite('MSG:5051,0,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Schlüssel"')
|
||||||
|
a.verarbeite('MSG:5010,0,0,"Das Öffnen der Disk schlug fehl"')
|
||||||
|
const fehler = a.fehlerText(11, false)
|
||||||
|
expect(fehler).toContain('Code 11')
|
||||||
|
expect(fehler).toContain('nicht freigeschaltet')
|
||||||
|
expect(fehler).toContain('keine MKV-Datei entstanden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('englische Textmuster bleiben als Rückfall', () => {
|
||||||
|
const a = new RipAuswertung()
|
||||||
|
a.verarbeite('MSG:9999,0,0,"AACS volume key is unknown for this disc"')
|
||||||
|
expect(a.kritischeMeldungen).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Tests des makemkvcon-Parsers — die Testfälle wandern sinngemäß aus dem
|
||||||
|
// Docker-Zweig mit (test_makemkv_aufruf.py, ripping.py-Doku): Ein neu
|
||||||
|
// geschriebener Parser, der dieselben Fälle besteht, kennt dieselben
|
||||||
|
// Fallen (KONZEPT § 10).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
episodenTitel,
|
||||||
|
KRITISCHE_CODES,
|
||||||
|
laengsterTitel,
|
||||||
|
parseMsg,
|
||||||
|
parseStreamInfo,
|
||||||
|
parseTitelInfo,
|
||||||
|
progressAusPrgv,
|
||||||
|
quelle,
|
||||||
|
sprachenZusammenfassen,
|
||||||
|
textVon,
|
||||||
|
} from '../src/kern/rip/parser'
|
||||||
|
|
||||||
|
describe('quelle — wie MakeMKV das Laufwerk nennt', () => {
|
||||||
|
it.each([
|
||||||
|
['\\\\.\\G:', 'dev:G:'],
|
||||||
|
['G:', 'dev:G:'],
|
||||||
|
['G:\\', 'dev:G:'],
|
||||||
|
['\\\\?\\E:', 'dev:E:'],
|
||||||
|
['/dev/sr0', 'dev:/dev/sr0'],
|
||||||
|
])('%s → %s', (eingabe, erwartet) => {
|
||||||
|
expect(quelle(eingabe)).toBe(erwartet)
|
||||||
|
})
|
||||||
|
it('ohne Gerät kommt nichts', () => {
|
||||||
|
expect(quelle('')).toBe('')
|
||||||
|
expect(quelle(' ')).toBe('')
|
||||||
|
})
|
||||||
|
it('ein langer Windows-Pfad wird nicht verstümmelt', () => {
|
||||||
|
expect(quelle('\\\\.\\D:\\Ordner')).toBe('dev:D:\\Ordner')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('textVon — Kodierung bestimmen statt behaupten', () => {
|
||||||
|
it('liest UTF-8', () => {
|
||||||
|
expect(textVon(Buffer.from('Das Öffnen', 'utf8'))).toBe('Das Öffnen')
|
||||||
|
})
|
||||||
|
it('liest AUCH cp1252', () => {
|
||||||
|
expect(textVon(Buffer.from('Das Öffnen', 'latin1'))).toBe('Das Öffnen')
|
||||||
|
})
|
||||||
|
it('der Fehler des Commanders kommt NICHT zurück (kein Ã)', () => {
|
||||||
|
for (const kodierung of ['utf8', 'latin1'] as const) {
|
||||||
|
const gelesen = textVon(Buffer.from('Das Öffnen der Disk schlug fehl', kodierung))
|
||||||
|
expect(gelesen).not.toContain('Ã')
|
||||||
|
expect(gelesen).toBe('Das Öffnen der Disk schlug fehl')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Text bleibt Text, leer bleibt leer, kaputte Bytes werfen nicht', () => {
|
||||||
|
expect(textVon('schon Text')).toBe('schon Text')
|
||||||
|
expect(textVon(Buffer.alloc(0))).toBe('')
|
||||||
|
expect(textVon(Buffer.from([0xff, 0xfe, 0x20]))).toBeTypeOf('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseMsg', () => {
|
||||||
|
it('liest Code und Klartext', () => {
|
||||||
|
expect(parseMsg('MSG:5010,0,1,"Das Öffnen der Disk schlug fehl","%1","x"')).toEqual({
|
||||||
|
code: 5010,
|
||||||
|
text: 'Das Öffnen der Disk schlug fehl',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('Meldungen MIT Komma werden nicht abgeschnitten (25.07.-Falle)', () => {
|
||||||
|
const m = parseMsg('MSG:3025,0,1,"Title #1 has length of 12 seconds, which is less than minimum","f"')
|
||||||
|
expect(m?.text).toBe('Title #1 has length of 12 seconds, which is less than minimum')
|
||||||
|
})
|
||||||
|
it('maskierte Anführungszeichen werden entpackt', () => {
|
||||||
|
expect(parseMsg('MSG:1,0,0,"sagt \\"hallo\\""')?.text).toBe('sagt "hallo"')
|
||||||
|
})
|
||||||
|
it('Nicht-MSG-Zeilen sind null', () => {
|
||||||
|
expect(parseMsg('PRGV:0,1,65536')).toBeNull()
|
||||||
|
expect(parseMsg('irgendwas')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('progressAusPrgv', () => {
|
||||||
|
it('total/max ist der Gesamtfortschritt', () => {
|
||||||
|
expect(progressAusPrgv('PRGV:100,32768,65536')).toBe(50)
|
||||||
|
expect(progressAusPrgv('PRGV:0,65536,65536')).toBe(100)
|
||||||
|
})
|
||||||
|
it('ein echtes 0 % ist eine Angabe, keine Zeile ist -1', () => {
|
||||||
|
expect(progressAusPrgv('PRGV:0,0,65536')).toBe(0)
|
||||||
|
expect(progressAusPrgv('MSG:1,0,0,"x"')).toBe(-1)
|
||||||
|
expect(progressAusPrgv('PRGV:1,1,0')).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fixture nach dem dokumentierten Robot-Format; die SINFO-Zeilen sind das
|
||||||
|
// am 26.07.2026 an der Akira-Blu-ray abgenommene Muster.
|
||||||
|
const INFO_AUSGABE = [
|
||||||
|
'TINFO:0,8,0,"12"',
|
||||||
|
'TINFO:0,9,0,"1:57:07"',
|
||||||
|
'TINFO:0,11,0,"31474929664"',
|
||||||
|
'TINFO:1,9,0,"0:02:01"',
|
||||||
|
'TINFO:1,8,0,"2"',
|
||||||
|
'SINFO:0,0,1,6201,"Video"',
|
||||||
|
'SINFO:0,1,1,6202,"Audio"',
|
||||||
|
'SINFO:0,1,3,0,"deu"',
|
||||||
|
'SINFO:0,1,4,0,"German"',
|
||||||
|
'SINFO:0,1,6,0,"DTS-HD MA"',
|
||||||
|
'SINFO:0,1,14,0,"2"',
|
||||||
|
'SINFO:0,1,30,0,"DTS-HD MA Stereo German"',
|
||||||
|
'SINFO:0,2,1,6202,"Audio"',
|
||||||
|
'SINFO:0,2,3,0,"jpn"',
|
||||||
|
'SINFO:0,2,4,0,"Japanese"',
|
||||||
|
'SINFO:0,3,1,6203,"Subtitles"',
|
||||||
|
'SINFO:0,3,3,0,"deu"',
|
||||||
|
'SINFO:0,3,4,0,"German"',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('parseTitelInfo', () => {
|
||||||
|
it('liest Dauer, Größe und Kapitel je Titel', () => {
|
||||||
|
expect(parseTitelInfo(INFO_AUSGABE)).toEqual([
|
||||||
|
{ nr: 0, dauerS: 7027, groesseBytes: 31_474_929_664, kapitel: 12 },
|
||||||
|
{ nr: 1, dauerS: 121, groesseBytes: 0, kapitel: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('leere Ausgabe ist eine leere Liste', () => {
|
||||||
|
expect(parseTitelInfo('')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseStreamInfo + sprachenZusammenfassen', () => {
|
||||||
|
it('trennt Audio und Untertitel, Sprache aus Attribut 3/4', () => {
|
||||||
|
const streams = parseStreamInfo(INFO_AUSGABE)
|
||||||
|
const titel0 = streams.get(0)!
|
||||||
|
expect(titel0.audio.map((s) => s.lang)).toEqual(['deu', 'jpn'])
|
||||||
|
expect(titel0.audio[0]).toMatchObject({ codec: 'DTS-HD MA', kanaele: '2', sprache: 'German' })
|
||||||
|
expect(titel0.untertitel.map((s) => s.lang)).toEqual(['deu'])
|
||||||
|
})
|
||||||
|
it('fasst die Sprachen der ganzen Disc zusammen', () => {
|
||||||
|
const sprachen = sprachenZusammenfassen(parseStreamInfo(INFO_AUSGABE))
|
||||||
|
expect(sprachen.audio).toEqual([
|
||||||
|
{ lang: 'deu', sprache: 'German', spuren: 1 },
|
||||||
|
{ lang: 'jpn', sprache: 'Japanese', spuren: 1 },
|
||||||
|
])
|
||||||
|
expect(sprachen.untertitel).toEqual([{ lang: 'deu', sprache: 'German', spuren: 1 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('laengsterTitel', () => {
|
||||||
|
const dauern = new Map([
|
||||||
|
[0, 7027], // Hauptfilm ~117 min
|
||||||
|
[1, 7300], // „Play All" wäre länger
|
||||||
|
[2, 121],
|
||||||
|
])
|
||||||
|
it('ohne Soll-Laufzeit gewinnt der längste', () => {
|
||||||
|
expect(laengsterTitel(dauern)).toBe(1)
|
||||||
|
})
|
||||||
|
it('mit Soll-Laufzeit gewinnt der nächstliegende (Play-All-Falle)', () => {
|
||||||
|
expect(laengsterTitel(dauern, 117)).toBe(0)
|
||||||
|
})
|
||||||
|
it('Soll-Laufzeit weit weg von allem → wieder der längste', () => {
|
||||||
|
expect(laengsterTitel(new Map([[0, 100]]), 117)).toBe(0)
|
||||||
|
expect(laengsterTitel(new Map(), 117)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('episodenTitel', () => {
|
||||||
|
it('findet Titel nahe der mittleren Episoden-Laufzeit (±10 min)', () => {
|
||||||
|
const dauern = new Map([
|
||||||
|
[1, 42 * 60],
|
||||||
|
[2, 44 * 60],
|
||||||
|
[3, 43 * 60],
|
||||||
|
[9, 170 * 60], // Play All
|
||||||
|
[10, 2 * 60], // Trailer
|
||||||
|
])
|
||||||
|
expect(episodenTitel(dauern, [43, 43, 42])).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
it('ohne Angaben keine Treffer', () => {
|
||||||
|
expect(episodenTitel(new Map(), [43])).toEqual([])
|
||||||
|
expect(episodenTitel(new Map([[1, 60]]), [])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('KRITISCHE_CODES — die gemessenen Ursachen', () => {
|
||||||
|
it('kennt die vier am Commander-Laufwerk gesehenen Nummern + 5010', () => {
|
||||||
|
for (const code of [2024, 5010, 5020, 5021, 5051]) {
|
||||||
|
expect(KRITISCHE_CODES[code], `Code ${code}`).toBeDefined()
|
||||||
|
expect(KRITISCHE_CODES[code].length).toBeGreaterThan(20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Tests der Update-Regeln (§ 6.8) — die HTTPS-Pflicht ist keine Kür:
|
||||||
|
// Ohne Code-Signing (Entscheid 5) ist der Transportweg die EINZIGE
|
||||||
|
// Absicherung des Selbst-Updates.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { updateUrlPruefen, STANDARD_UPDATE_URL } from '../src/haupt/update-regeln'
|
||||||
|
|
||||||
|
describe('updateUrlPruefen', () => {
|
||||||
|
it('leer heißt Standard (der ist https)', () => {
|
||||||
|
expect(updateUrlPruefen('').ok).toBe(true)
|
||||||
|
expect(STANDARD_UPDATE_URL.startsWith('https://')).toBe(true)
|
||||||
|
})
|
||||||
|
it('https wird angenommen', () => {
|
||||||
|
expect(updateUrlPruefen('https://mein-server.example/rippy').ok).toBe(true)
|
||||||
|
})
|
||||||
|
it('http wird ABGELEHNT, nicht stillschweigend akzeptiert', () => {
|
||||||
|
const urteil = updateUrlPruefen('http://mein-server.example/rippy')
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.grund).toContain('https://')
|
||||||
|
})
|
||||||
|
it('anderes Zeug wird abgelehnt', () => {
|
||||||
|
expect(updateUrlPruefen('ftp://x').ok).toBe(false)
|
||||||
|
expect(updateUrlPruefen('file://x').ok).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Tests der Disc-Wache — die Übergangs-Logik pur, und der Fehlerpfad:
|
||||||
|
// Eine gescheiterte Runde darf den Zustand NICHT löschen (R2) und muss
|
||||||
|
// gemeldet werden (R4).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { GeraeteInfo } from '../src/gemeinsam/nachrichten'
|
||||||
|
import { DiscWache, laufwerksUnterschiede } from '../src/kern/laufwerk/wache'
|
||||||
|
|
||||||
|
function geraet(id: string, status: GeraeteInfo['status'], typ: GeraeteInfo['typ'] = 'unknown'): GeraeteInfo {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Laufwerk ${id}:`,
|
||||||
|
typ,
|
||||||
|
pfad: `\\\\.\\${id}:`,
|
||||||
|
status,
|
||||||
|
modell: '',
|
||||||
|
seriennummer: '',
|
||||||
|
groesseBytes: 0,
|
||||||
|
grund: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsMap(...geraete: GeraeteInfo[]): Map<string, GeraeteInfo> {
|
||||||
|
return new Map(geraete.map((g) => [g.id, g]))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('laufwerksUnterschiede', () => {
|
||||||
|
it('empty → ready ist eine eingelegte Disc', () => {
|
||||||
|
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap(geraet('G', 'ready', 'bluray')))
|
||||||
|
expect(ereignisse).toEqual([{ art: 'disc-eingelegt', geraet: expect.objectContaining({ id: 'G' }) }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ready → empty ist eine entnommene Disc', () => {
|
||||||
|
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'empty')))
|
||||||
|
expect(ereignisse).toEqual([{ art: 'disc-entnommen', geraet: expect.objectContaining({ id: 'G' }) }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ready → unknown ist KEINE Entnahme — „konnte nicht nachsehen" (R2)', () => {
|
||||||
|
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'unknown')))
|
||||||
|
expect(ereignisse).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('neues Laufwerk mit Disc meldet beides', () => {
|
||||||
|
const ereignisse = laufwerksUnterschiede(alsMap(), alsMap(geraet('G', 'ready', 'dvd')))
|
||||||
|
expect(ereignisse.map((e) => e.art)).toEqual(['laufwerk-neu', 'disc-eingelegt'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verschwundenes Laufwerk wird gemeldet', () => {
|
||||||
|
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap())
|
||||||
|
expect(ereignisse).toEqual([{ art: 'laufwerk-weg', id: 'G' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DiscWache', () => {
|
||||||
|
it('meldet Änderungen und behält bei einem Rundenfehler den Stand (R2/R4)', () => {
|
||||||
|
let stand: GeraeteInfo[] = [geraet('G', 'empty')]
|
||||||
|
let lesenBricht = false
|
||||||
|
const meldungen: string[] = []
|
||||||
|
const fehler: string[] = []
|
||||||
|
|
||||||
|
const wache = new DiscWache(
|
||||||
|
() => {
|
||||||
|
if (lesenBricht) throw new Error('Win32 kaputt')
|
||||||
|
return stand
|
||||||
|
},
|
||||||
|
(meldung) => meldungen.push(meldung.ereignisse.map((e) => e.art).join(',') || 'stand'),
|
||||||
|
(f) => fehler.push(String(f)),
|
||||||
|
999_999,
|
||||||
|
)
|
||||||
|
|
||||||
|
wache.starten() // Runde 1: erster Stand
|
||||||
|
expect(wache.aktuellerStand()).toHaveLength(1)
|
||||||
|
expect(meldungen).toEqual(['laufwerk-neu'])
|
||||||
|
|
||||||
|
stand = [geraet('G', 'ready', 'bluray')]
|
||||||
|
wache.runde() // Runde 2: Disc rein
|
||||||
|
expect(meldungen).toEqual(['laufwerk-neu', 'disc-eingelegt'])
|
||||||
|
|
||||||
|
lesenBricht = true
|
||||||
|
wache.runde() // Runde 3: Lesen bricht — Stand bleibt, Fehler gemeldet
|
||||||
|
expect(fehler).toHaveLength(1)
|
||||||
|
expect(wache.aktuellerStand()).toHaveLength(1)
|
||||||
|
expect(wache.aktuellerStand()[0].status).toBe('ready')
|
||||||
|
|
||||||
|
lesenBricht = false
|
||||||
|
wache.runde() // Runde 4: nichts geändert — keine neue Meldung
|
||||||
|
expect(meldungen).toHaveLength(2)
|
||||||
|
|
||||||
|
wache.stoppen()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
|
||||||
|
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
|
||||||
|
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join, relative, sep } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const WURZEL = join(__dirname, '..')
|
||||||
|
const SRC = join(WURZEL, 'src')
|
||||||
|
|
||||||
|
function quelldateien(ordner: string): string[] {
|
||||||
|
const ergebnis: string[] = []
|
||||||
|
for (const name of readdirSync(ordner)) {
|
||||||
|
const pfad = join(ordner, name)
|
||||||
|
if (statSync(pfad).isDirectory()) {
|
||||||
|
ergebnis.push(...quelldateien(pfad))
|
||||||
|
} else if (/\.(ts|tsx)$/.test(name)) {
|
||||||
|
ergebnis.push(pfad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativPosix(pfad: string): string {
|
||||||
|
return relative(WURZEL, pfad).split(sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateien = quelldateien(SRC).map((pfad) => ({
|
||||||
|
pfad: relativPosix(pfad),
|
||||||
|
inhalt: readFileSync(pfad, 'utf8'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
||||||
|
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
|
||||||
|
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
|
||||||
|
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
|
||||||
|
const erlaubt = new Set(['src/haupt/leine.ts', 'src/kern/laufwerk/win32.ts'])
|
||||||
|
|
||||||
|
it('koffi wird nirgendwo sonst angefasst', () => {
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => !erlaubt.has(d.pfad))
|
||||||
|
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
|
||||||
|
it('kein leerer catch-Block', () => {
|
||||||
|
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
|
||||||
|
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
|
||||||
|
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
|
||||||
|
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
|
||||||
|
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
|
||||||
|
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
|
||||||
|
it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
|
||||||
|
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
|
||||||
|
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
|
||||||
|
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
|
||||||
|
// reinen Variablennamen ist erlaubt.
|
||||||
|
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
|
||||||
|
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
|
||||||
|
it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => {
|
||||||
|
// Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten
|
||||||
|
// als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs
|
||||||
|
// leiden, und ein Test sah dadurch anders aus, als sein Quelltext las.
|
||||||
|
const testDateien = readdirSync(join(WURZEL, 'test'))
|
||||||
|
.filter((name) => /\.(ts|tsx)$/.test(name))
|
||||||
|
.map((name) => ({
|
||||||
|
pfad: `test/${name}`,
|
||||||
|
inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'),
|
||||||
|
}))
|
||||||
|
const verstoesse = [...dateien, ...testDateien]
|
||||||
|
.filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
||||||
|
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
||||||
|
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
||||||
|
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
|
||||||
|
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": [],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/bruecke/**/*.ts", "src/gemeinsam/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.bruecke.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node", "electron"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/haupt/**/*.ts",
|
||||||
|
"src/kern/**/*.ts",
|
||||||
|
"src/gemeinsam/**/*.ts",
|
||||||
|
"electron.vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"test/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/fenster/**/*.ts", "src/fenster/**/*.tsx", "src/gemeinsam/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Testlauf (Vitest) — läuft mit dem System-Node, NICHT in Electron.
|
||||||
|
// Deshalb dürfen Tests nur Module anfassen, die ohne Electron und ohne
|
||||||
|
// Windows laufen: gemeinsam/, kern/speicher/ (node:sqlite gibt es in
|
||||||
|
// Node 24 wie in Electrons Node — KONZEPT-WINDOWS.md § 3.4) und die
|
||||||
|
// Wächter, die Quelltext nur LESEN. Alles mit koffi/Win32 wird hier
|
||||||
|
// weder importiert noch ausgeführt — das beweist der Smoke-Lauf (BAUEN.md).
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user