Author SHA1 Message Date
HitonabiandClaude Opus 5 f84c5ee399 docs: SAVEPOINT — 5.7.1 veröffentlicht (Tag aktuell, Assets 46/47/48, anonym gegengemessen)
Ampel / ampel (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:09:33 +02:00
HitonabiandClaude Opus 5 fbedb9946d docs: SAVEPOINT — 5.7.1 gebaut und lokal installiert, NICHT veröffentlicht; Der-Patriot-Fund mit Ursache (VOB-Scan) und Fix (Stille-Grenze)
Ampel / ampel (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:06:44 +02:00
HitonabiandClaude Opus 5 eb54c59141 fix(v5): 5.7.1 — Titel lesen wartet, solange MakeMKV arbeitet; Grenze gilt für Stille, Dialog zeigt Schritt, Prozent und Meldung
Ampel / ampel (push) Successful in 1m20s
WAS: Der Info-Lauf verlangt --progress=-same und wertet die Ausgabe
zeilenweise aus (PRGC/PRGT/PRGV/MSG). titelInfoLesen beendet makemkvcon
nur noch, wenn er so lange STILL ist, wie die Einstellung erlaubt
(Vorgabe 15 min) — jede Fortschritts- oder Meldungszeile setzt die Uhr
zurück. Kündigt MakeMKV den VOB-Scan an („IFO-Datei … beschädigt, die
VOB-Datei muss gescannt werden"), gilt nur noch eine Obergrenze von 4 h.
Der Dialog zeigt während des Lesens Schritt, Prozent und letzte Meldung
(titelLaufZwischenstand), beim VOB-Scan mit Erklärung (20–60 min, im
MakeMKV-Programm genauso). Bricht Rippy ab, stehen die letzten acht
Zeilen von makemkvcon im Fehler und im Protokoll. Einstellungs-Text
„Titel lesen — ohne Lebenszeichen höchstens". Tests mit nachgebautem
makemkvcon (redselig über die Stille-Grenze hinaus, stumm, VOB-Scan mit
Obergrenze). Version 5.7.1, Änderungsnotizen.
WARUM: Commander 12.09.2026 spät: „Der Patriot" (DVD, IFO für VTS #1
beschädigt) scheiterte in 5.7.0 erneut nach 15 Minuten, das MakeMKV-
Programm las die Disc fertig — MakeMKV liest bei so einer Disc die ganze
VOB durch, das dauert so lange wie die Disc braucht. Eine feste
Gesamtgrenze ist dafür immer zu kurz; Stille ist das richtige Maß.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:04:08 +02:00
HitonabiandClaude Opus 5 0088948b4f docs: SAVEPOINT — 5.7.0 veröffentlicht (Tag aktuell, Assets 43/44/45, anonym gegengemessen)
Ampel / ampel (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:47:14 +02:00
HitonabiandClaude Opus 5 d8d34e8fbb docs: SAVEPOINT — 5.7.0 lokal installiert (22:41), Ampel grün, Update vorbereitet (Kanal-Stand gemessen), NICHT veröffentlicht
Ampel / ampel (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:45:04 +02:00
HitonabiandClaude Opus 5 5353b381ba docs: SAVEPOINT — 5.7.0 gebaut und am Paket bewiesen, NICHT installiert, NICHT veröffentlicht; Durchsicht abgearbeitet, Spuren einzeln, F31 Ton verlustfrei; Ampel wartet hinter hängendem Lauf
Ampel / ampel (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:35:21 +02:00
HitonabiandClaude Opus 5 9d16e82b5f test(v5): F2-Test ohne /proc — Nodes rekursives mkdirSync pendelt dort endlos, die Ampel hing
Ampel / ampel (push) Successful in 1m14s
WAS: Der Test „Ablage nicht erreichbar" nimmt jetzt einen Ordner UNTER
einer Datei als Ablage (scheitert auf jeder Plattform sofort mit
ENOTDIR/EEXIST) statt /proc/rippy-nicht-da unter Linux.
WARUM: Ampel-Lauf 279 (12.09.2026) blieb in test/pipeline.test.ts
hängen: auf procfs antwortet mkdir mit ENOENT, obwohl /proc da ist —
Nodes mkdirSync({recursive:true}) läuft dann zwischen Kind und Eltern-
ordner im Kreis. Lokal (Windows) war der Lauf grün, weil Q:\ dort nicht
existiert und Node am Wurzelpfad abbricht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:29:04 +02:00
HitonabiandClaude Opus 5 d45760018e chore(v5): Version 5.7.0 — Änderungsnotizen, Klick-Beweis-Bilder, Bericht mit Nachtrag F31
Ampel / ampel (push) Canceled after 23m46s
WAS: package.json/package-lock auf 5.7.0; bau/release-notes.md mit den
5.7.0-Notizen (Beta-Schlüssel, Spuren einzeln, verlustfreier Ton, Titel
lesen 15 min, 4K nach Leistung, Disc-Typ aus dem Dateisystem, kein
stilles Scheitern, Serien über mehrere Discs, Erkennung, Installer-
Rückfrage, Kleinkram); die elf Klick-Beweis-Bilder neu (34 Schritte,
Dialog mit Spur-Haken); Durchsicht-Bericht um F31 ergänzt (HandBrake
rechnete seit 5.0 jede Tonspur in AAC um — Kopiermaske fehlte).
WARUM: Commander-Entscheid 12.09.2026 „Alles in einer Fassung 5.7.0" —
Bau und lokale Installation folgen, der Kanal erst auf sein Wort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:14:22 +02:00
HitonabiandClaude Opus 5 e7e33e9021 feat(v5): jede Spur einzeln anhaken — Codec und Kanäle je Tonspur, Untertitel genauso
WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die
der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German ·
DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie
bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch-
sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist
jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung),
22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna-
Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"-
Untertitel schon. Die Ablage scannt die Roh-Datei und findet die
gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie +
Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1
--subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan,
gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl-
regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte
Regel wird in der Registry ersetzt, eine fremde bleibt.
Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask`
verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit
5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich
(gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs).
WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") —
bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der
Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am
mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle
(test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und
deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor
deu, --subtitle none → keine Spur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:11:27 +02:00
HitonabiandClaude Opus 5 fc2d16ca38 feat(v5): Disc-Typ aus dem Dateisystem — INDX0300 ist UHD, VIDEO_TS ist DVD
WAS: laufwerke.typVerfeinern liest je Disc einmal BDMV/index.bdmv (Kopf
„INDX" + Fassung: 0300 = UHD-Format, 0100/0200 = Blu-ray) bzw. sieht
VIDEO_TS (DVD) und überschreibt damit, was Steuercode und Größe sagten;
das Ergebnis wird je Laufwerk und Disc-Größe gemerkt (die Wache fragt
alle 3 s). Gemessen am Akira-UHD-Abbild unter F:\Video\backup\UHD_AKIRA
(12.09.2026): INDX0300.
WARUM: Durchsicht 12.09.2026, Fund F10 — bei manchen USB-Brücken
antwortet IOCTL_CDROM_DISK_TYPE nicht („unbekannt" → ISO), und die Größe
allein machte aus einer 4K-Disc mit 48 GB Daten eine „bluray" (falsches
Preset). Am echten Laufwerk mit einer gewöhnlichen Blu-ray noch zu
messen, dass dort 0200 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:49:49 +02:00
HitonabiandClaude Opus 5 0fc87aa022 feat(v5): 4K-UHD nach der Leistung — Grafikkarte mit 4K-Preset komprimiert, nur CPU bleibt verlustfrei
WAS: presetEmpfehlung liefert für UHD das 4K-Preset der gemessenen
Grafikkarten-Familie (VCN/NVENC/QSV) — sonst PRESET_KEINE (verlustfrei);
komprimierenFuer folgt der Empfehlung, wenn der Typ auf „automatisch"
steht. Eigene Wahl schlägt beides. Fenster: Kachel, Kompressions-Karte und
Erst-Einrichtung sagen „(8 Bit)" bzw. „verlustfrei — nur CPU-Encoder".
Gemessen am 12.09.2026 (HandBrake 1.11.2, RX 9070 XT, Akira-UHD-Abbild):
die 4K-Hardware-Presets sind 8-Bit (vce_h265, Profil main); der 10-Bit-
HEVC-Encoder vce_h265_10bit stürzt beim Start ab (Segfault, mit Profil
auto und main10); AV1 10-Bit („AV1 VCN 2160p 4K", vce_av1_10bit) läuft
— steht als eigene Wahl in der Liste, mit dem Hinweis auf AV1-fähige
Abspielgeräte.
WARUM: Durchsicht 12.09.2026, A4 — Commander-Entscheid: „Rippy analysiert
doch die Performance des PCs, könnten wir das nicht davon abhängig
machen?" Vorher rechnete der CPU-Default 4K auf 1080p klein, tagelang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:48:38 +02:00
HitonabiandClaude Opus 5 6b6e5c3436 feat(v5): Titel lesen bis 15 Minuten (einstellbar), Zwischenstand im Dialog, Abbrechen beendet den Lauf
WAS: Die Grenze fürs Titel-Lesen kommt aus der Einstellung
titelLesenMinuten (5–60, Vorgabe 15; Einstellungen → Allgemein). Ab der
zweiten Minute meldet der Kern jede Minute, wie lange MakeMKV schon liest
und wo die Grenze liegt; „Abbrechen" im Dialog beendet den Lauf auch im
Kern (titel-abbruch), damit das Laufwerk frei wird. Läuft die Zeit ab,
nennt die Meldung Kopierschutz und viele Playlisten als mögliche Gründe
statt nur „beschädigte Disc" — mit dem Rat, die Disc im MakeMKV-Programm
zu öffnen und die Zeit zu stoppen.
WARUM: Durchsicht 12.09.2026, Kandidat 2 (Commander-Entscheid: 15 Minuten
plus Regler) — fünf Minuten fest war für ältere DVDs mit Kopierschutz zu
kurz, das GUI wartet, Rippy nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:44:44 +02:00
HitonabiandClaude Opus 5 ae82d8c0fd fix(v5): MakeMKV-Installer aus dem Internet Archive nur nach Rückfrage, mit Größe und SHA-256
WAS: makemkvInstallerHolen fragt vor der Archiv-Quelle über eine
Freigabe-Funktion; ohne Freigabe wird das Archiv übersprungen und im
Bericht genannt. Der Kern stellt die Frage ans Fenster (beschaffung-status
mit `frage`), die MakeMKV-Karte zeigt sie mit „Archiv-Kopie laden" /
„Lieber nicht" (beschaffung-antwort); ohne Antwort in zehn Minuten gilt
Nein. Die geladene Datei wird mit Größe und SHA-256 gemeldet.
WARUM: Durchsicht 12.09.2026, Fund F16 (Commander-Entscheid: Archiv nur
nach Rückfrage) — eine unsignierte EXE von fremder Hand, die mit
Adminrechten läuft, entscheidet der Nutzer, nicht Rippy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:43:13 +02:00
HitonabiandClaude Opus 5 3dacd7cb4f fix(v5): Erkennung vergleicht auch den Originaltitel und ignoriert Akzente
WAS: TMDb antwortet mit language=de-DE auf Deutsch („Der Patriot", „Déjà Vu
– Wettlauf gegen die Zeit"), die Disc trägt den Originaltitel
(THE_PATRIOT, DEJA_VU). Der wörtliche Vergleich prüft jetzt title UND
original_title (name/original_name bei Serien); Akzente fallen vor dem
Vergleich (NFD, ohneAkzente) — auch bei Wort-Überlappung und Abdeckung.
WARUM: Durchsicht 12.09.2026, Fund F14 (Sonde 1): „Déjà Vu" ≠ „Deja Vu",
„Der Patriot" ≠ „The Patriot" — nur 60 % statt 95 %, die Automatik
wartete. Tests mit genau diesen Titeln.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:41:11 +02:00
HitonabiandClaude Opus 5 c1ee339446 fix(v5): Kleinkram der Durchsicht — Rückfragen vor Beenden und Löschen, Enter speichert, Escape schließt, ein Toast je Disc, NFO mit Kennung, „(2)" statt Kennungsstumpf, Werkzeug-Suche gemerkt, CD-Lesen ohne Blockade
WAS (je Fund der Durchsicht vom 12.09.2026):
- F15 Tray „Beenden" fragt nach, wenn ein Rip oder eine Kompression läuft
  (app.quit reißt makemkvcon/HandBrake über die Leine mit).
- F17 „Roh-Dateien löschen" und „Ordner löschen" fragen im Fenster nach
  (Größe und Pfad stehen dabei) — ein Klick war vorher 30 GB.
- F18 Eingabefelder speichern auch bei Enter und beim Verschwinden des
  Feldes (Bereichswechsel) — Tipparbeit ging vorher verloren.
- F19 Dialoge: Escape schließt, role=dialog/aria-modal, Fokus im Dialog.
- F20 Toast „Disc erkannt" einmal je Disc und Titel, nicht dreimal.
- F21 Was mit dem Poster passiert, steht in den Hinweisen (R4).
- F22 Werkzeug-Funde gelten 60 s; ein neuer Pfad und „werkzeuge-laden"
  suchen frisch — vorher bei fehlendem MakeMKV je Aufruf dreimal
  reg query /s (bis 45 s, synchron).
- F23 movie.nfo/tvshow.nfo tragen <uniqueid type=tmdb|imdb>.
- F24 Doppelter Zielordner heißt „Akira (1988) (2)" statt „[bluray-2]".
- F25 Umlaute in drei Knopftexten.
- F26 Phase „rettet" zählt im Haupt als laufender Vorgang (Update-Sperre,
  Taskleiste).
- F27 „Neu komprimieren" nicht mehr für verschobene, unkomprimierte
  Vorgänge.
- F28 Die Automatik sagt bei Audio-CD und unbekannter Disc, warum sie
  nichts tut.
- F30 Audio-CD-Spuren werden asynchron gelesen (alle acht Blöcke Luft),
  „Abbrechen" wirkt mitten in der Spur.
- A5 Der tote Schlüssel transcodePreset ist aus presetFuer raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:40:07 +02:00
HitonabiandClaude Opus 5 783b8e4897 fix(v5): Laufwerks-Gesundheit zählt nur das eigene Gerät (\Device\CdRomN), nie eine Festplatte
WAS: win32.ts kennt QueryDosDeviceW (am 12.09.2026 an diesem PC gemessen:
C: → \Device\HarddiskVolume7, X: → \Device\LanmanRedirector\…\192.168.178.62\rippy);
LaufwerkApi.geraeteName liefert das Kernel-Gerät hinter dem Buchstaben.
beurteilen() filtert damit auf DIESES Gerät; ohne Gerätename zählt nur
noch der cdrom-Treiber (gehoertZuLaufwerk). Vorher nahm der XPath cdrom
UND disk für alle Geräte — eine hustende Festplatte oder ein zweites
Laufwerk erzeugte „VERDACHT: DIE HARDWARE" in der Kachel des Blu-ray-
Laufwerks.
WARUM: Durchsicht 12.09.2026, Fund F11 — mit Tests (Festplatten-
Ereignisse, zweites Laufwerk, ruhig mit Gerätename).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:35:33 +02:00
HitonabiandClaude Opus 5 551f1d7b2c perf(v5): Kompressions-Fortschritt als kleine Nachricht, Roh-Größen 30 s gemerkt
WAS: Ein Prozent-Tick von HandBrake (etwa einmal je Sekunde) geht jetzt
als `kompression-stand` (nur die Warteschlange) an Haupt und Fenster; der
volle Vorgangs-Stand — mit rekursivem Ordnerlauf durch jeden Roh-Ordner,
fremden Ordnern und zwei statfs — kommt nur noch bei einem Phasenwechsel
oder auf Zuruf. Roh-Größen werden 30 s gemerkt; Löschen, Aufräumen und der
Knopf „Aktualisieren" messen frisch.
WARUM: Durchsicht 12.09.2026, Fund F7 — auf einer NAS als Arbeitsordner
war das ein Netz-Ordnerlauf pro Sekunde, und das Fenster zeichnete jede
Sekunde komplett neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:33:22 +02:00
HitonabiandClaude Opus 5 904e4b2ef6 fix(v5): Serien über mehrere Discs — nur die eigenen Folgen benennen, nie überschreiben; HandBrake schreibt auf Zwischennamen
WAS:
- episodenUmbenennen bekommt die Dateien DIESER Disc (nurDiese) statt
  den ganzen Season-Ordner zu zählen. Vorher hieß es bei Disc 2 „über-
  sprungen: 4 Dateien, 2 Zuordnungen", sobald Disc 1 dort schon S01E01/
  S01E02 abgelegt hatte — Serien-Merker und „ab Folge" liefen ins Leere.
- freierName: eine vorhandene Datei gleichen Namens im Ziel wird nie
  ersetzt („title_t00 (2).mkv") — außer beim Wiederholen, wo die alte
  Fassung ausdrücklich weicht (auftrag.ersetzt).
- komprimieren() lässt HandBrake auf „<name>.teil.mkv" schreiben und
  benennt erst die fertige Datei um; nach Abbruch oder Fehler werden die
  Reste (auch eine Datei daneben) entfernt. Vorher blieb eine halbe Datei
  unter dem endgültigen Namen liegen — ohne Arbeitsordner direkt in der
  Ablage, wo Jellyfin sie als kaputten Film zeigte.
WARUM: Durchsicht 12.09.2026, Funde F3 und F9 — mit Tests (Season-Ordner
mit Bestand, Kollision, Zwischenname, Reste).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:31:38 +02:00
HitonabiandClaude Opus 5 8c4456d005 fix(v5): kein stilles Scheitern mehr — Rip-Start meldet unerreichbare Ablage, Kern-Neustart verdrahtet das Fenster neu, eine Wache-Kette je Laufwerk, Rettungs-Abbild überlebt, Titel-Auftrag schlägt „unbekannt"
WAS:
- pipeline.starten fängt jede Ausnahme und meldet sie als Fehler des
  Laufwerks; vor dem Start eine Schreibprobe am Zielordner mit Klartext
  („nicht erreichbar … NAS verbunden?"). Vorher warf mkdirSync bei toter
  Ablage aus starten() heraus, der Aufrufer schluckte es: kein Fehler im
  Fenster, keine Protokollzeile, der Dialog ging zu (Sonde 10).
- kern/index.ts: unbehandelte Promise-Ablehnungen landen im Protokoll und
  im Fenster (gemessen: ein Electron-utilityProcess stirbt daran nicht,
  er schweigt); echte Ausnahmen werden protokolliert, der Kern endet
  kontrolliert, der Haupt startet ihn neu. Die Einlege-Kette hat ein
  Fangnetz.
- kernstart.ts spannt nach einem Kern-Neustart den Fenster-Port neu auf;
  das Fenster schließt den toten Port und meldet den Neustart als Zeile.
  Vorher blieb es taub, bis Rippy komplett neu gestartet wurde.
- wache.runde() löscht den geplanten Timer, bevor es einen neuen setzt —
  jeder Auswurf legte sonst eine weitere 3-s-Kette an (Sonde 3).
- beenden() räumt den leeren Roh-Ordner, verschont aber rettung.iso und
  den Bericht (RETTUNG_DATEIEN).
- Typ „unbekannt" heißt nur ohne Titel-Auftrag ISO; mit Auftrag rippt
  makemkvcon, der Typ folgt der Größe (Blu-ray/DVD) für Preset und Ordner.
WARUM: Durchsicht 12.09.2026, Funde F2, F4, F6, F8, F10 — jeder mit Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:27:47 +02:00
HitonabiandClaude Opus 5 12b9720386 fix(v5): Beta-Key mit @ kommt ganz an; Schlüssel-Stand ehrlich (rot statt „Rips laufen", nicht prüfbar ohne Laufwerk); Zeiten in Ortszeit
WAS: KEY_MUSTER nimmt alles bis Leerraum/HTML-Zeichen (der September-Key
„T-…4BI@N5rPp" hat 68 Zeichen mit @; das alte Muster lieferte 62 und
MakeMKV lief danach ohne Schlüssel). extractGueltigBis versteht „end of
September 2026". Die Kette prüft zuerst den vorhandenen Schlüssel und
lässt ihn stehen, wenn MakeMKV ihn annimmt (kein Überschreiben gekaufter
Keys mehr); reg add wird auf Erfolg geprüft; 5042 ohne Lizenzzeile heißt
„nicht prüfbar" statt „angenommen". Fenster: Ablehnung ist ROT mit dem
Satz, dass Blu-rays dann nur noch in MakeMKVs Gnadenfrist laufen; die
MakeMKV-Karte sagt, was Rippy in MakeMKVs Einstellungen schreibt. Ein
neuer Werkzeug-Pfad wird sofort gesucht, ein neuer eigener Schlüssel
sofort geprüft. datumKurz zeigt Ortszeit statt UTC.
WARUM: Durchsicht 12.09.2026, Funde F1, F5, F12, F13 und A2 — am echten
Forum-HTML und an makemkvcon ohne Laufwerk gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:17:43 +02:00
HitonabiandClaude Opus 5 6f1add480e docs: Durchsicht 12.09.2026 — Bericht mit 30 Funden, fünf Ursachen-Kandidaten für die drei Filme, Messungen
Kompletter Check von Kern, Haupt und Fenster (5.6.1). Wichtigster Fund:
der Forum-Beta-Key vom September enthält ein @, Rippys Muster schneidet
ihn ab, MakeMKV läuft dann unlizenziert. Dazu: stiller Rip-Start ohne
Ablage, Serien-Disc-2-Umbenennung, tauber Kern-Neustart, Wache-Takt-
Ketten, Vorgangs-Stand pro HandBrake-Tick. Der Datenbank-Befund vom
01.09. war ein Messartefakt der MSIX-Schattensicht des Claude-Terminals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:13:40 +02:00
HitonabiandClaude Fable 5.1 473eea7d4e docs: SAVEPOINT — 5.6.1 gebaut und lokal installiert, NICHT veröffentlicht; Untertitel-Fund mit Messung
Ampel / ampel (push) Successful in 1m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:40:49 +02:00
HitonabiandClaude Fable 5.1 687173430c fix(v5): 5.6.1 — Untertitel-Spuren wieder erkannt: Stream-Typ per Code (6203), nicht per lokalisiertem Wort
Commander-Fund 03.09.2026 (Digimon Adventure: Last Evolution Kizuna): im
Auswahl-Dialog fehlten die UNTERTITEL-Chips. Gemessen mit makemkvcon64 -r
info auf diesem PC: MakeMKV schreibt den Stream-Typ in seiner Anzeigesprache
("Untertitel"), der Parser prüfte auf "Subtitles" (Messung vom 26.07. an
einem englischen MakeMKV). "Audio" heißt zufällig gleich, deshalb fiel nur
der Untertitel-Weg aus. Jetzt entscheidet der Typ-Code im vierten Feld
(6201 Video, 6202 Audio, 6203 Untertitel); das Wort bleibt Notnagel.

Test rip-parser-561 mit den gemessenen Zeilen (deu×2/jpn×2 Ton, deu×3
Untertitel, darunter "PGS German (nur erzwungene)"); die volle Messung
liegt unter beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:40:22 +02:00
HitonabiandClaude Fable 5.1 8f671ff160 docs: SAVEPOINT — 5.6.0 veröffentlicht (Tag aktuell, Assets 40/41/42, anonym gegengemessen)
Ampel / ampel (push) Successful in 1m22s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:32:21 +02:00
HitonabiandClaude Fable 5.1 512b38cced docs: SAVEPOINT — 5.6.0 gebaut und lokal installiert, NICHT veröffentlicht; Übergabe
Ampel / ampel (push) Successful in 1m25s
Prüfsumme, Klick-Beweis (33 Schritte), Tests (446), die acht Punkte von 5.6.0
(inkl. Vorgangs-Leiste), was am Gerät noch zu messen ist, bekannte Schwächen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:19:11 +02:00
HitonabiandClaude Fable 5.1 f5dab5d1c1 feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus
Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:

* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
  aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
  der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
  Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
  RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
  filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
  und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
  ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
  <html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
  machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
  vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
  aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.

Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:17:37 +02:00
HitonabiandClaude Fable 5.1 76ed3b337a docs: SAVEPOINT — 5.5.0 veröffentlicht (Tag aktuell, Assets 37/38/39, anonym gegengemessen)
Ampel / ampel (push) Successful in 1m24s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:07:17 +02:00
HitonabiandClaude Fable 5.1 2ae79fceb4 docs: SAVEPOINT — 5.5.0 gebaut und lokal installiert, NICHT veröffentlicht; Übergabe
Ampel / ampel (push) Successful in 1m21s
Prüfsumme, Klick-Beweis (29 Schritte), Tests (413), was 5.5.0 enthält, was am
Gerät noch zu messen ist (Rettungs-Abbild, Automatik, Benachrichtigung live),
der weiter ungeklärte Datenbank-Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:52:17 +02:00
HitonabiandClaude Fable 5.1 3dc6798c79 feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse
Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:51:15 +02:00
HitonabiandClaude Fable 5.1 6678c81592 docs: SAVEPOINT — zweiter Bau 5.4.0 (Pruefsumme, Tests, Fix 2c74dc6)
Ampel / ampel (push) Successful in 1m29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:31:33 +02:00
HitonabiandClaude Fable 5.1 2c74dc6958 fix(v5): Neu komprimieren ersetzt die alte Fassung — erst wenn die neue fertig ist
Ampel / ampel (push) Successful in 1m31s
Bei Serien stand die alte S01E03.mkv der Umbenennung im Weg (existiert
schon), bei Filmen ueberschrieb HandBrake stumm. Jetzt traegt der
Wiederholungs-Auftrag die alten fertigen Dateien (ersetzt) und loescht
sie NACH dem Encode; scheitert der zweite Lauf, bleibt die erste Fassung.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:30:07 +02:00
HitonabiandClaude Fable 5.1 6555562a4c docs: SAVEPOINT — 5.4.0 gebaut und lokal installiert, NICHT veroeffentlicht; Uebergabe
Ampel / ampel (push) Successful in 1m54s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:27:55 +02:00
HitonabiandClaude Fable 5.1 c46a280b1f chore(v5): Version 5.4.0 — Aenderungsnotizen fuer latest.yml
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:24:19 +02:00
HitonabiandClaude Fable 5.1 6dd1b0bc7a feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:22:04 +02:00
HitonabiandClaude Fable 5.1 e3ce8220f1 feat(v5): Oberflaeche 5.4.0 — Sprachen je Titel, Staffel/Folge, Vorschau, Kino-Banner v2, Roh-Dateien, Warteschlange
WAS: Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und
Untertitelspuren als Chips (vorbelegt aus der Einstellung, sonst alles,
was der Titel hat), Felder fuer Staffel und erste Folge, und zwei Zeilen
Zielpfad-Vorschau (gemeinsam/ablagepfad.ts — dieselbe Namensregel wie der
Kern). Die Dialoge leben jetzt im App-Zustand statt in der Kachel: ein
leerer Takt der Wache raeumte sonst Kachel UND Dialog ab.
Die Laufwerks-Kachel ist das Kino-Banner v2: Logo statt Text, Tagline,
Zeile Jahr/FSK/Laufzeit/Genre/Bewertung, Darsteller-Streifen, die
Hinweise der Nachpruefung, ein Knopf „Laufwerk pruefen" mit Urteil aus
dem Windows-Protokoll, und nach dem Rip der Stand der Kompression.
Neu in der Uebersicht: die Kompressions-Karte (laeuft/wartet, Abbruch).
Neu in den Einstellungen: „Roh-Dateien" mit Regel, Belegung, jedem
Vorgang (Neu komprimieren mit Preset, Roh loeschen) und Ordnern ohne
Vorgang; der Auswurf-Schalter in „Ablage"; der Datenbank-Pfad unter
„Programm". Das Haupt zeigt die Kompression in Taskleiste und Tray und
laesst Updates auch auf sie warten. Playwright kommt als Bau-Werkzeug
dazu (Commander-Ja nach Regel B; MIT, wird nicht ausgeliefert).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:13:22 +02:00
HitonabiandClaude Fable 5.1 3f7f962dff feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:02:03 +02:00
HitonabiandClaude Opus 5 d70a81d194 docs: SAVEPOINT — UEBERGABE an die naechste Session
Ampel / ampel (push) Successful in 1m39s
Zwoelf Punkte in empfohlener Reihenfolge, davon zwei vom Commander:
Metadaten v2 mit Hero-Banner (Laufzeit-Abgleich, Struktur-Fingerabdruck,
TMDb-Logo/FSK/Besetzung) und Sprachwahl je Titel im Auswahl-Dialog.

Dazu Startanleitung, Token-Fundstelle, das bewaehrte Vorgehen
(erst lokal, dann veroeffentlichen), Hardware-Lage und der Bug, der
noch offen sein koennte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:49:22 +02:00
HitonabiandClaude Opus 5 7c489f8404 fix(ui): Dialoge schliessen nicht mehr beim Markieren von Text
Ampel / ampel (push) Successful in 2m18s
Commander: "es gibt einen kleinen bug im UI wenn man selbst den titel
sucht das suchfenster schliesst sich dann einfach."

Bekannte Falle bei Dialogen mit klickbarer Hintergrundflaeche: Wer im
Eingabefeld Text markiert und dabei ueber den Dialogrand hinauszieht,
drueckt INNEN und laesst AUSSEN los. Der Browser meldet als Ziel des
Klicks dann den gemeinsamen Vorfahren — die Hintergrundflaeche. Der
onClick-Horcher darauf schloss den Dialog also mitten im Tippen.

Neu useHintergrundKlick(): merkt sich, WO der Klick begann. Nur wenn
Druecken UND Loslassen auf der Flaeche selbst passierten, ist es ein
Klick daneben. Gilt fuer beide Dialoge (Titelwahl und "Aendern ...").

Gegengeprueft und VERWORFEN wurde eine andere Vermutung: Die Kachelliste
haengt an `laufwerke.length === 0`, ein einziger leerer Takt der
Laufwerks-Wache wuerde die Kachel samt Dialog abraeumen. Gemessen: 20 von
20 Abfragen liefern das Laufwerk, keine leere Antwort. Die Fragilitaet
bleibt als Risiko notiert (SAVEPOINT), ist aber nicht die Ursache.

261 Tests gruen. NICHT veroeffentlicht — der Kanal steht auf 5.3.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:48:19 +02:00
HitonabiandClaude Opus 5 1f678c036d docs: SAVEPOINT — 5.3.2 im Kanal, Titelauswahl am Geraet bestaetigt
Ampel / ampel (push) Successful in 1m32s
"Serie: 2 Folgen erwartet, 2 gefunden - passt." mit 52:47 und 54:09 min —
2,5 Prozent auseinander, weit innerhalb von AEHNLICH. Die Auslegung
stimmt an echten Zahlen.

Zwei eigene Fehler festgehalten: der still verworfene Nachrichtentyp und
der alte Titel-Lauf, der sich ueber die fertige Liste legte. Dazu das
Vorgehen, das sich bewaehrt hat: erst lokal installieren, testen lassen,
dann veroeffentlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:32:48 +02:00
HitonabiandClaude Opus 5 6d149212c9 fix(v5): Nur der NEUESTE Titel-Lauf antwortet — ein alter legte sich darueber
Ampel / ampel (push) Successful in 1m49s
Commander: "Er liest es korrekt ein, ich bekomme auch die auswahl aber
kurz danach kommt dieser fehler" — "makemkvcon info: keine Antwort nach
300 s", MINUTEN nach der fertigen Titelliste.

Mit nur einem Lauf ist das unmoeglich: Der Timeout wird bei 'close'
geloescht. Es waren also zwei Laeufe unterwegs, und der aeltere meldete
verspaetet seinen Abbruch — auf die laengst gelieferte Liste drauf.

Behoben, je Laufwerk:
* Ein neuer Lauf LOEST den alten AB: AbortController im Kern,
  titelInfoLesen nimmt jetzt ein AbortSignal, killt das Kind und lehnt
  mit der Marke ABGELOEST ab. Das Laufwerk ist sofort frei, und es
  laufen nie zwei makemkvcon auf derselben Disc.
* Nur die NEUESTE Laufnummer darf antworten (titelLaufNummern) — eine
  verspaetete Antwort wird verworfen, nicht angezeigt.
* Ein abgeloester Lauf SCHWEIGT: abgeloest ist Absicht, kein Fehlschlag.
* Laeuft es wirklich in den Timeout, steht jetzt ein Satz statt einer
  Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
  Minuten brauchen; Rippy beendet den Versuch und gibt das Laufwerk frei.

Am lebenden Objekt bestaetigt (SPARTACUS_GOTA_D1, Commander-Screenshot):
"Serie: 2 Folgen erwartet, 2 gefunden - passt." | Titel 0 52:47 min
16,28 GB 7 Kap. | Titel 1 54:09 min 16,67 GB 7 Kap. | beide Rolle Folge |
2 von 2 gewaehlt, zusammen 32,95 GB — und KEIN Timeout hinterher.

Die Laengen liegen 2,5 Prozent auseinander, also weit innerhalb von
AEHNLICH (15 Prozent) — deshalb "passt" ohne Unsicherheits-Warnung.

261 Tests gruen, Typpruefung sauber. Version 5.3.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:29:25 +02:00
HitonabiandClaude Opus 5 9622f4e6ef fix(v5): Der Kern verwarf 'titel-lesen' STILL — Auswahl-Dialog hing ewig
Ampel / ampel (push) Successful in 1m48s
Commander: "das funktioniert nicht, du kannst gerne am lebenen objekt
pruefen". Der Dialog blieb bei "Rippy liest die Titel der Disc ..."
stehen, ohne Fehler, ohne Ende.

Ursache, am lebenden Objekt gemessen: NICHT der Info-Lauf.

  makemkvcon -r --noscan info dev:G:  ->  0,8 s, TCOUNT:0
  MSG:5042 "konnte keine verwendbaren optischen Laufwerke finden"

Der Lauf antwortet also sofort. Der Fehler lag in MEINEM Code von 5.2.0:
istFensterNachricht() kannte 'titel-lesen' und 'rip-ueberspringen' nicht
— beide standen in der FensterNachricht-Union, aber nicht im Pruefer.
Der Kern verwarf sie mit `if (!istFensterNachricht(frage)) return`.
STILL. Genau die R4-Wunde, vor der die Hausordnung warnt.

Auf drei Ebenen behoben:

1. Der Pruefer kennt beide Nachrichten.
2. Eine verworfene Nachricht ist jetzt LAUT: console.error plus
   kern-fehler ins Fenster mit der Art, die verworfen wurde.
3. Neuer Waechter-Test (waechter.test.ts): Er liest das Schema und
   vergleicht JEDE `art: '...'` der Unions mit dem jeweiligen Pruefer;
   dazu prueft er, dass der Kern nicht mehr still verwirft.
   GEGENPROBE gemacht: Mit zurueckgenommener Reparatur schlaegt er fehl
   ("expected [ 'titel-lesen' ] to deeply equal []"), mit Reparatur ist
   er gruen. Kein Deko-Test.

Dazu: Der Info-Lauf reicht die URSACHE durch (DiscInfo.ursachen aus den
kritischen MSG-Nummern). Kam nichts heraus und MakeMKV hat einen Grund
genannt, steht im Dialog der Grund statt "keine Titel gefunden" — beim
5042-Zustand also die Abhilfe (Disc neu einlegen, Laufwerk ab- und
anstecken, notfalls neu starten) statt eines Fingerzeigs auf die Disc.

Neu test/messung.titelwahl.test.ts — faehrt am echten Laufwerk genau den
Weg des Kerns nach (Info-Lauf, Vorauswahl, Dialog-Fehlertext).

Zweimal in dieselbe Heredoc-Falle getappt (AGENTS.md warnt davor):
Escape-Sequenzen wurden halbiert, ein rohes CR landete in makemkv.ts.
Beides korrigiert; der Hygiene-Waechter haette das CR auch gefangen.

260 Tests gruen (vorher 257), Typpruefung sauber. Version 5.3.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:12:44 +02:00
HitonabiandClaude Opus 5 306b090ccf docs: SAVEPOINT — 5.3.0 im Kanal, Serien-Ablage und Rollen
Ampel / ampel (push) Successful in 1m30s
Zum dritten Mal in dieser Sitzung dasselbe Muster: Die Bausteine lagen
fertig UND getestet da (serienOrdner, matcheEpisoden, episodenUmbenennen,
tvStaffel) und waren nur nicht verbunden. Als Lehre festgehalten.

Offen notiert: Weder Auswahl-Dialog noch Serien-Ablage sind je an echter
Hardware gelaufen, und der extras/-Ordnername ist aus Wissen gesetzt,
nicht gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:40:36 +02:00
HitonabiandClaude Opus 5 7ce06889f8 feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Ampel / ampel (push) Successful in 1m56s
Commander (01.09.2026): "Mach das gerne noch mit den Serien." und "Fuer
FILME sollte so eine auswahl auch gelten z.B. Nur hauptfeature oder
hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."

Wieder lagen die Teile fertig herum und waren nur nicht verbunden:
serienOrdner(), matcheEpisoden(), episodenUmbenennen() (struktur.ts) und
tvStaffel() (tmdb.ts) sind gebaut UND getestet — aufgerufen hat sie
niemand. Folgen landeten deshalb unter Filme/, und Jellyfin sah lauter
Einzelfilme statt einer Staffel.

Neu: jeder Titel im Auftrag traegt eine ROLLE (gemeinsam/nachrichten.ts,
EINE Definition fuer Kern und Fenster):

  hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
  extra     -> Filme/<Titel> (Jahr)/extras/...
  folge     -> Serien/<Titel>/Season NN/... S01E02.mkv

* Rippy schlaegt die Rolle vor (Serie -> Folge, Film -> Hauptfilm, Rest
  -> Extra), im Dialog ist sie je Titel aenderbar.
* Die Zuordnung Datei->Rolle ist EXAKT, nicht aus Dateinamen geraten: Je
  makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt die
  Rolle des Auftragseintrags.
* Extras liegen im Unterordner "extras" neben dem Film — die Konvention,
  die Jellyfin, Emby und Kodi als Zugaben lesen. Der Ordner entsteht nur,
  wenn es wirklich Extras gibt.
* Neuer Schnellwahl-Knopf "Hauptinhalt + Extras": alles Sehenswerte, aber
  weder Sammeltitel noch Logos/Alterskennzeichen.

Episoden-Nummern: Die Laufzeit jedes gewaehlten Titels reist im Auftrag
mit (RipAuftragTitel.dauerS), die Staffel-Laufzeiten kommen ueber einen
injizierten Rueckruf von TMDb (tvStaffel). matcheEpisoden() benennt NUR
bei EINDEUTIGER Zuordnung um. Sonst behalten die Dateien ihre Namen, und
Rippy SAGT warum — entweder "keine Staffel-Laufzeiten von TMDb" oder
"nicht eindeutig zuzuordnen". Lieber gar nicht als falsch; das war ARMs
offene Wunde #395.

Die Staffel-Nummer kommt aus dem Disc-Titel (discZusatzAbtrennen, seit
5.1.1) — sonst 1.

257 Tests gruen (vorher 254), Typpruefung sauber. Version 5.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:37:10 +02:00
HitonabiandClaude Opus 5 043052b453 docs: SAVEPOINT — 5.2.0 im Kanal, Titelauswahl und Defekt-Waechter
Ampel / ampel (push) Successful in 1m34s
Inklusive der Korrektur einer eigenen Fehldiagnose: Der FESTE Offset
widerlegt die USB-Strom-Vermutung — es ist ein Disc-Defekt, die
Controllerfehler sind die Folge des halbstuendigen Haemmerns.

Offen notiert: Der Auswahl-Dialog lief noch nie an echter Hardware, und
die Serien-Ablage (Serien/<Titel>/Staffel X mit SxxEyy) fehlt weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:26:05 +02:00
HitonabiandClaude Opus 5 a8e8b2bef3 feat(v5): Titel auswaehlen statt immer alles — und ein Defekt kostet nicht mehr alles
Ampel / ampel (push) Successful in 1m34s
Commander (01.09.2026): "es fehlt generell noch komplett die auswahl WAS
man rippen moechte. Bei Serien eben genau das die folgen, bei filmen z.B.
nur das hauptfeature oder auch die extras."

Bis hierher uebergab die Pipeline fest `titel: 'all'` — Rippy rippte
IMMER alles, samt Trailer, Werbung und Alterskennzeichen. Beide Wuensche
brauchen denselben Umbau, deshalb in einem Zug:

TITEL FUER TITEL statt einem 'all'-Aufruf. Nur so laesst sich auswaehlen,
und nur so ueberlebt der Rest, wenn EIN Titel an einem Disc-Defekt haengt.

Was schon da war und nur nicht verbunden: titelInfoLesen() (Titel mit
Dauer/Groesse/Kapiteln) rief niemand auf, und buildRipArgs() konnte
laengst einen einzelnen Titel. Dazu kennt Rippy seit heute das
Inhaltsverzeichnis der Disc (EP 1, EP 2, DUB GER 1, 104 GER FSK).

Neu kern/rip/titelwahl.ts (pur, ohne Laufwerk):
* Serie (von der Disc BEZEUGT): so viele Titel wie das Inhaltsverzeichnis
  Folgen nennt; passen sie nicht zusammen, sagt Rippy das.
* Film: der laengste Titel.
* Zwei Fallen mit Tests abgesichert:
  - SAMMELTITEL: Ein Titel, der so lang ist wie die anderen zusammen
    ("alle Folgen am Stueck"), waere der laengste und wuerde die
    Film-Auswahl gewinnen — Rippy wuerde alles doppelt sichern.
  - ANGEL/Seamless Branching: Mehrere fast gleich lange Fassungen. Rippy
    nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
* Unklar heisst: nichts vorausgewaehlt. Lieber fragen als raten.

Der Defekt-Waechter:
* offsetAusMeldung() liest die Stelle aus MakeMKVs Meldung — als LETZTE
  gequotete Zahl, damit es unabhaengig von MakeMKVs Sprache bleibt
  (deutsch "bei Offset", englisch "at offset").
* RipAuswertung zaehlt Wiederholungen derselben Stelle; ab HAENGT_AB gilt
  der Lauf als haengend. Fortschritt raeumt den Verdacht wieder ab.
* Das Fenster zeigt dann Klartext plus "Titel ueberspringen" — der
  laufende makemkvcon wird beendet, der naechste Titel laeuft weiter.

Gemessen am lebenden Objekt (Spartacus Disc 2, 01.09.2026): MakeMKV
meldete eine halbe Stunde denselben Offset 56881152, makemkvcon
verbrauchte dabei 1,7 s CPU (es wartete), und Windows protokollierte 41
fehlerhafte Bloecke in 10 Minuten. Der Defekt lag in Folge 1 — Folge 2
lag heil daneben und ging trotzdem verloren.

17 neue Tests in test/titelwahl.test.ts, alle beim ersten Lauf gruen.
254 Tests gesamt (vorher 237), Typpruefung sauber. Version 5.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:22:55 +02:00
HitonabiandClaude Opus 5 1253148652 docs: SAVEPOINT — der Selbst-Update-Beweis ist erbracht
Ampel / ampel (push) Successful in 1m33s
Commander: "Also das upate funktioniert!" — 5.1.4 fand 5.1.5 selbst,
lud sie, installierte sie und kam in der neuen Fassung zurueck, ohne
Handanlegen.

Damit ist der offene Punkt 1 aus der Uebergabe vom 31.08. erledigt und
die Kette zum ERSTEN Mal komplett bewiesen: Kanal UND letzter Meter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:50:09 +02:00
HitonabiandClaude Opus 5 2549b9643b docs: SAVEPOINT — 5.1.5 im Kanal, Spartacus am lebenden Objekt bewiesen
Ampel / ampel (push) Successful in 1m36s
Der Commander legte die Disc mit dem neuen UI ein: "erkannt als
Spartacus: Gods of the Arena - 90 %". Der Fund von 5.1.1 ist damit
erledigt.

Dazu 5.1.5: DWM-Rand weg (HRESULT 0, auch im Paket), Preset-Empfehlung
sichtbar gemacht (die Logik gab es schon), Update-Adresse raus, Wizard
mit fuenf Befunden geradegezogen. Waechter R1 kennt jetzt drei koffi-Orte
— bewusst und gemeldet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:47:17 +02:00
HitonabiandClaude Opus 5 663127f854 feat(v5): Windows-Rand weg, Hardware-Empfehlung sichtbar, Wizard geradegezogen
Ampel / ampel (push) Successful in 1m35s
Vier Punkte des Commanders (01.09.2026):

1. "Ich hab noch den rand ich glaube der kommt aber von windows selbst"
   Stimmt. Ein rahmenloses Fenster behaelt unter Windows 11 den DWM-Rand
   — eine feine Linie, die der Fenstermanager zeichnet, nicht die Seite;
   mit CSS ist da nichts zu holen. Neu fensterrand.ts:
   DwmSetWindowAttribute mit DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE.
   Gemessen: HRESULT 0, Windows nimmt es an. Runde Ecken bleiben (sie
   sind unter Windows 11 das Uebliche); ein Schalter fuer kantig ist da.
   Aeltere Windows antworten mit Fehler — der wird als Wert
   zurueckgegeben und protokolliert, nicht verschluckt (R4); dort gibt es
   den Rand schlicht nicht.

   Das ist der DRITTE erlaubte koffi-Ort. Waechter R1 kennt jetzt drei
   statt zwei Dateien; die Regel selbst ("Win32 nur an benannten Orten")
   bleibt und haelt alles andere weiter dicht.

2. "Die alte Version konnte automatisch anhand der gescannten hardware
   encoder das beste preset waehlen."
   Das KANN diese auch — presetEmpfehlung() waehlt nach den gemessenen
   Backends (VCN -> NVENC -> QSV -> CPU) und laeuft seit dem Kino-Mix.
   Man sah es nur nirgends ausser als Wort "automatisch:" in einem
   Auswahlfeld. Die Erst-Einrichtung zeigt jetzt die gemessenen Encoder
   als Badges und was daraus fuer DVD, Blu-ray und 4K wird — plus eine
   ehrliche Warnung, wenn nur CPU-Encoder da sind.

3. Feld "Update-Adresse" ist raus. Der Schluessel updateUrl gilt
   weiterhin (Datenbank), Paragraph 6.8 prueft ihn wie zuvor.

4. Erst-Einrichtung geprueft. Gefunden und behoben:
   * "Dieses Setup stellt vier Dinge ein" — es stellte ZWEI ein.
   * Der Ablage-Schritt zeigte eine LEERE Zeile, wenn nichts gewaehlt
     war. Es gibt aber eine Vorgabe (Videos\Rippy im Benutzerprofil) —
     die steht jetzt da.
   * Kein Wort zum TMDb-Schluessel, obwohl Schritt 1 "Rippy erkennt, was
     drauf ist" verspricht. Ohne Schluessel heissen Filme wie das
     Disc-Label. Jetzt ein eigener Schritt mit Eingabefeld.
   * Rohe HTML-Checkbox statt des Schalters aus dem Kino-Mix.
   * Kein Hinweis, dass Rippy im Infobereich weiterlebt.
   Neu sechs Schritte, drei Fragen — und die Zahl im Text stimmt.

   Dazu: #einrichtung als Beweis-Anker (RIPPY_START_BEREICH), sonst ist
   der Wizard nach dem ersten Start nicht mehr zu fotografieren und
   damit nicht mehr pruefbar. Beweis: beweise/ui-515-wizard.png

237 Tests gruen, Typpruefung sauber. Version 5.1.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:43:58 +02:00
HitonabiandClaude Opus 5 45402e887e docs: SAVEPOINT — 5.1.4 im Kanal, Leine-Fix gemessen
Ampel / ampel (push) Successful in 1m31s
Die Update-Kette hat ab 5.1.2 nie installiert; bewiesen war immer nur der
Kanal, nie der letzte Meter. Ursache, Fix und die dreifache Messung
festgehalten — inklusive des ersten, falsch negativen Messlaufs.

Notiert: Der Sprung AUF 5.1.4 ist einmalig Handarbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:30:43 +02:00
HitonabiandClaude Opus 5 7a4f92036d test(beweise): dritter Fall — erlaubt das Ausklinken die Leine auf?
Ampel / ampel (push) Successful in 1m33s
Die eigentliche Regressionsfrage fehlte noch: Ein normales Kind muss auch
dann sterben, wenn die Arbeitsgruppe das Ausklinken ERLAUBT. Sonst waere
Paragraph 3.3 aufgeweicht und makemkvcon koennte als Waise das Laufwerk
festhalten (der rc10-Fund).

  1) Leine wie bisher, Kind normal                  -> stirbt  WIE ERWARTET
  2) Leine erlaubt Ausklinken, Kind normal          -> stirbt  WIE ERWARTET
  3) Leine erlaubt Ausklinken, Kind klinkt sich aus -> lebt    WIE ERWARTET

Das blosse Erlauben aendert also nichts; nur wer CREATE_BREAKAWAY_FROM_JOB
verlangt, kommt raus. Genau das tut allein der Update-Installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:29:23 +02:00
HitonabiandClaude Opus 5 051cab3afa fix(v5): Das Update installierte nie — die eigene Leine erschlug den Installer
Ampel / ampel (push) Successful in 1m41s
Commander: "Rippy wird zwar sauber beendet aber startet NICHT neu in der
neuen version."

Ursache, auf drei Beinen belegt:

1. Bibliothek: electron-updater startet den Installer per
   spawn(..., { detached: true }) (BaseUpdater.js, an der installierten
   Fassung nachgelesen). `detached` setzt unter Windows KEIN Breakaway.
2. Leine: leine.ts setzt KILL_ON_JOB_CLOSE, und Kinder erben die
   Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
3. Platte: pending\RippySetup-5.1.3.exe lag geladen da (18:11:47),
   installiert war weiter 5.1.2. Der Download klappte, nur die
   Installation nicht.

Der Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.

Fix — die Leine bleibt, der Installer klinkt sich aus:

* leine.ts setzt zusaetzlich JOB_OBJECT_LIMIT_BREAKAWAY_OK. Das aendert
  fuer sich genommen NICHTS: Kinder erben weiter, ausser sie verlangen
  das Ausklinken selbst. Kern, makemkvcon und HandBrakeCLI bleiben
  angeleint, die rc10-Waisen-Falle (Paragraph 3.3) ist unveraendert zu.
* Neu: ausserhalbDerLeineStarten() startet EIN Programm per CreateProcessW
  mit CREATE_BREAKAWAY_FROM_JOB. Liegt in leine.ts, weil dort die
  Arbeitsgruppe verwaltet wird — und weil Waechter R1 koffi nur dort und
  in kern/laufwerk/win32.ts erlaubt.
* update.ts installiert jetzt SELBST: autoInstallOnAppQuit ist aus, der
  Installerpfad kommt aus downloadUpdate() (laut AppUpdater.d.ts "Paths to
  downloaded files"), die Argumente sind die von electron-updater
  gemessenen (--updated /S --force-run). Gestartet wird beim Beenden
  (will-quit) und ueber den Knopf.
* Neu kommandozeile.ts (pur, ohne koffi/Electron): CreateProcessW nimmt
  EINE Zeichenkette. Ohne die Regeln von CommandLineToArgvW zerfiele ein
  Pfad wie "C:\Users\Tobi Neu\..." in zwei Argumente.

BEWIESEN, nicht behauptet — neu: beweise/leine-breakaway.js

  OHNE Ausklinken (so macht es electron-updater)
    lebt nach dem Tod des Elternprozesses: NEIN  -> WIE ERWARTET
  MIT Ausklinken (so macht es Rippy ab 5.1.4)
    lebt nach dem Tod des Elternprozesses: JA    -> WIE ERWARTET

Der erste Messlauf zeigte ein falsches Negativ: Der Enkel starb an der
sterbenden KONSOLE des Elternprozesses, nicht an der Arbeitsgruppe. Erst
mit CREATE_NO_WINDOW (eigene Konsole) misst der Versuch, was er messen
soll. Diese Lehre steht als Kommentar im Code und der Flag ist auch im
echten Aufruf gesetzt.

237 Tests gruen (vorher 230), Typpruefung sauber. Version 5.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:27:36 +02:00
HitonabiandClaude Opus 5 1d53e8526f docs: SAVEPOINT — 5.1.3 im Kanal, "zieht nichts hoch" aufgeklaert
Ampel / ampel (push) Successful in 1m33s
Das Update ZOG: RippySetup-5.1.2.exe lag geladen und pruefsummengleich
in rippy-updater\pending. Es fehlte nur das echte Beenden ueber das Tray
— Fenster zumachen beendet Rippy nicht (Paragraph 4.1 Punkt 3).

5.1.3 anonym von aussen nachgemessen: Content-Length = latest.yml =
lokale Datei, releaseNotes durchgereicht, Umlaute intakt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:10:50 +02:00
HitonabiandClaude Opus 5 827910fd63 feat(v5): eigenes Fenster ohne Windows-Rahmen + Layout skaliert mit
Ampel / ampel (push) Successful in 1m34s
Zwei Befunde des Commanders (01.09.2026):

1. "Momentan ist alles nach links gerueckt, nichts skaliert mit der
   fenstergroesse und es ist alles gequetscht."

   Ursache gefunden: <main> hatte WEDER Zentrierung NOCH Maximalbreite,
   die Karten darin aber einen harten Deckel von max-w-2xl (672 px). In
   einem 1280er Fenster klebte damit alles am linken Rand und rechts
   stand nichts. Behoben:
   * <main> und Kopfzeile teilen sich mx-auto max-w-[1600px] mit
     mitwachsendem Innenabstand — der Inhalt ist mittig und nutzt die
     Breite.
   * Der 672-px-Deckel der Einstellungs-Spalte ist weg. Drei
     Encoder-Felder stehen jetzt nebeneinander statt untereinander.
   * Die Sektionswahl der Einstellungen wird im schmalen Fenster zur
     Zeile ueber den Karten (lg:flex-row), statt eine 176-px-Spalte vom
     ohnehin knappen Platz abzuziehen.
   * Info-Kacheln der Laufwerks-Karte: zwei Spalten schmal, vier breit.
   * Startgroesse 1000x700 -> 1280x820, Mindestmass 720x480 -> 860x560.

2. "waere sogar richtig cool wenn du den rahmen wegbekommst"

   frame:false, und die Kopfzeile IST jetzt die Titelleiste:
   Minimieren / Maximieren / Schliessen sitzen oben rechts in
   Windows-Massen (46x32), gezogen wird an der Kopfzeile, Doppelklick
   maximiert. Groesse aendern bleibt moeglich — Electron setzt fuer
   rahmenlose Fenster unter Windows weiter WS_THICKFRAME (thickFrame,
   Standard true), die Kanten ziehen also wie gewohnt.

   Schliessen nimmt GENAU den Weg des alten Fensterkreuzes
   (fenster.close() -> der bestehende close-Horcher versteckt nur):
   Rippy lebt im Tray weiter, ein laufender Rip merkt davon nichts
   (Paragraph 4.1 Punkt 3).

   Die Ziehflaeche ist .ziehbar in stil.css; alles Bedienbare darin
   traegt .nicht-ziehbar, sonst verschluckt der Griff den Klick.
   fensterMaximiert im HauptStatus entscheidet ueber das Knopf-Symbol
   und wird auch bei Doppelklick/Tastenkuerzel nachgefuehrt (maximize-
   und unmaximize-Horcher).

Nachgemessen mit dem Smoke-Beweis: beweise/ui-513-uebersicht.png (kein
Rahmen, eigene Knoepfe, Inhalt ueber die volle Breite) und
beweise/ui-513-einstellungen.png (drei Encoder nebeneinander).

230 Tests gruen, Typpruefung sauber. Version 5.1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:07:45 +02:00
HitonabiandClaude Opus 5 0c38754c09 docs: SAVEPOINT — 5.1.2 im Update-Kanal, Changelog nachgemessen
Ampel / ampel (push) Successful in 1m36s
releaseNotes stehen anonym abrufbar in latest.yml (1191 B statt 337 B,
Umlaute intakt), Content-Length der EXE = Zahl in latest.yml = lokale
Datei. Notiert: Die Update-Karte ist erst AB 5.1.2 sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:54:48 +02:00
HitonabiandClaude Opus 5 c0a7618e33 feat(v5): Update sichtbar machen — Meldung, Knopf und Changelog
Ampel / ampel (push) Successful in 1m33s
Der Commander: "irgendwie fehlt in rippy selbst der Button 'Auf updates
pruefen' oder auch direkt beim start ne meldung 'ey jo update
verfuegbar' - waere noch cool wenn das kommt inkl. changelog".

Zu Recht. Der Kanal arbeitete korrekt, war aber UNSICHTBAR: Der
Update-Stand lag im Haupt und wurde nur beim Laden des Fensters EINMAL
mitgeschickt. Fand die Pruefung etwas, aenderte sich am Bildschirm
nichts; der einzige Hinweis war ein Satz unter "Update-Adresse", den man
kennen musste, um ihn zu finden.

Was jetzt da ist:

* Eine goldene Karte OBEN in der Uebersicht, sobald etwas gefunden wird
  — mit neuer Versionsnummer, der laufenden Version, Ladefortschritt und
  den Aenderungsnotizen. Dazu ein Windows-Toast beim Fund.
* Knopf "Jetzt auf Updates pruefen" in den Einstellungen. Waehrend eines
  Rips ist er AUS, mit Begruendung im Tooltip — Rippy sucht dann
  bewusst nicht (Paragraph 6.8).
* Knopf "Jetzt neu starten und installieren" auf der Karte, sobald das
  Paket geladen ist. Sagt NEIN mit Grund statt still nichts zu tun (R4).
* Der Stand meldet JEDE Aenderung sofort ans Fenster (setzen() ruft
  immer beiAenderung) — kein Zustand mehr, den niemand erfaehrt.

Der Changelog kommt aus bau/release-notes.md. An der installierten
Bibliothek geprueft statt aus dem Kopf (Regel D): app-builder-lib
out/publish/updateInfoBuilder.js getReleaseInfo() liest genau diesen
Dateinamen und legt den Inhalt als releaseNotes in latest.yml;
builder-util-runtime deklariert UpdateInfo.releaseNotes als
"string | Array<ReleaseNoteInfo> | null". neuerungenText() vertraegt
beide Formen, wirft HTML-Marken raus und deckelt die Laenge.

UpdateStand ist jetzt EINE Definition in gemeinsam/nachrichten.ts, die
sich Haupt und Fenster teilen.

230 Tests gruen (vorher 225), Typpruefung sauber. Version 5.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:51:38 +02:00
HitonabiandClaude Opus 5 7d496cf654 docs: SAVEPOINT — 5.1.1 im Update-Kanal, Gitea-Muster geklaert
Ampel / ampel (push) Successful in 1m59s
Release aktuell traegt jetzt 5.1.1 (vier Dateien, anonym von aussen
nachgemessen: latest.yml 200/version 5.1.1, EXE Content-Length
131526527 = Zahl in latest.yml = lokale Datei).

KONZEPT 3.6 beantwortet: releases/latest/download liefert HTTP 404 —
Gitea 1.27 bedient das GitHub-Muster NICHT. Das feste Tag aktuell ist
damit der einzige Weg, nicht nur die Rueckfallebene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:42:41 +02:00
HitonabiandClaude Opus 5 4f943e2c4f docs: SAVEPOINT — Disc-Erkennung repariert, 5.1.1 gebaut
Ampel / ampel (push) Successful in 1m37s
Ampel gruen fuer 38a3a87, Paket-Smoke gruen, Setup liegt in dist-setup.
Offen bleibt allein der Release-Upload: der gespeicherte Gitea-Zugang ist
ein Passwort, kein API-Token (HTTP 401).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:36:46 +02:00
HitonabiandClaude Opus 5 38a3a87f70 fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
Ampel / ampel (push) Successful in 1m38s
Der Commander legte Disc 2 von „Spartacus: Gods of the Arena" ein, Rippy
meldete den FILM „Spartacus" von 1960. An der echten Disc gemessen
(Laufwerk G:, Label SPARTACUS_GOTA_D2, UDF, 33.759.100.928 Bytes): Der
stark gestutzte Kandidat „Spartacus" stand auf Platz 2 der Suchvarianten
und holte dort einen wörtlichen Filmtreffer mit 95 Prozent — die richtige
Serie stand auf Platz 6 und wurde nie gefragt.

Vier Ursachen, alle behoben:

1. Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der Frage.
   Neu: abdeckung() + MINDEST_ABDECKUNG — wer mehr als die Hälfte des
   Titels wegwerfen musste, um zu treffen, bekommt höchstens einen
   Vorschlag (0,60), und das UI fragt nach, statt sich sicher zu irren.

2. Verpackungs-Zusätze galten als Wortmüll. Neu: discZusatzAbtrennen()
   trennt "- Disc 2" / "_D2" / "S1" ab UND merkt sie — die Nummer braucht
   die Ablage später sowieso. Vier Fallen sind abgesichert: "Rocky 2",
   "Kill Bill Teil 2", "Ocean s 11" (Apostroph-Wortgrenze) und nackte
   Zahlen am Ende.

3. Der Titelvergleich war zeichengenau. Ein Volume-Label KANN keinen
   Doppelpunkt tragen, TMDb schreibt ihn — der exakt richtige Treffer
   fiel an einem Satzzeichen durch. Neu: gleichBedeutend().

4. Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste
   Zeile daraus. Neu: inhaltAusBdmt() liest auch <di:titleName>. "EP 1"
   und "EP 2" beweisen die Serie, "DUB GER 1" und "104 GER FSK" nicht.
   Ist die Serie bewiesen, sind die Film-Zweige der Kaskade AUS.

Gemessen an der eingelegten Disc (neue messung.disc-erkennung.test.ts):
  Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt)
  Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true
  Varianten: "...Arena - Disc 2" -> "...Arena" -> ... -> "Spartacus"

225 Tests grün (vorher 209), Typprüfung sauber. Disc-Nummer, Staffel und
Folgenzahl stehen jetzt auch im Fenster (Kachel neben der Erkennung).

Version 5.1.1 — zugleich der erste echte Selbst-Update-Beweis (§ 6.8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:31:56 +02:00
HitonabiandClaude Fable 5 673f7ff3e7 docs: SAVEPOINT — Übergabe an die nächste Session
Ampel / ampel (push) Successful in 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:24:35 +02:00
HitonabiandClaude Fable 5 5522a50425 docs: SAVEPOINT — deployt: Update-Kanal aktuell live, main bereinigt
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:15:57 +02:00
HitonabiandClaude Fable 5 d21332e3c7 docs: SAVEPOINT — Einstellungen-Karten und Version 5.1.0
Ampel / ampel (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:10:10 +02:00
HitonabiandClaude Fable 5 b64ddf5f06 feat(v5): Einstellungen als erklärende Karten-Seite — Version 5.1.0
Ampel / ampel (push) Successful in 1m35s
Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine
Sektions-Navigation, rechts Karten mit je einem Satz Einordnung —
Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression
(Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung),
Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV
(eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf,
Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand,
Versionszeile) und Werkzeuge — NEU mit den drei „Eigener
Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher
ohne UI).

Dazu: #einstellungen/#bibliothek als Startanker (RIPPY_START_BEREICH),
RIPPY_SMOKE_WARTE_MS für Bild-Beweise, die auf späte Kern-Antworten
warten (Einstellungen+Encoder-Messung kommen nach den 750 ms).
Version 5.1.0 — erste Fassung ohne Vorab-Suffix, der Bau erzeugt
damit erstmals latest.yml (der Update-Kanal aus § 6.8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:06:38 +02:00
HitonabiandClaude Fable 5 c12b1be448 docs: SAVEPOINT — Kino-Mix-Design durchgängig eingebaut
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 21:56:39 +02:00
HitonabiandClaude Fable 5 b9de2ba729 feat(v5): Kino-Mix-Design durchs ganze Fenster (Commander-Entscheid)
Ampel / ampel (push) Successful in 1m54s
Der auf dem Design-Canvas gewählte Mix aus Kinosaal und Schaltpult,
durchgängig über Übersicht, Einstellungen, Bibliothek, Erst-Einrichtung
und Dialoge: warmes Bühnen-Schwarz mit Saallicht und Filmkorn, Gold als
Akzent, Poster mit Goldkante, Plakat-Titel (Bricolage Grotesque) — dazu
die Schaltpult-Präzision: REC-Glühen bei laufendem Rip, Info-Kacheln
(Medium/Größe/Erkennung/Preset inkl. der echten Preset-Kette),
20-Segment-Aussteuerungsanzeige mit großer Mono-Prozentzahl,
Mono-Protokoll, LED-Systemleiste statt Status-Kacheln (Systemfehler
werden zur roten Karte, R4). Schriften gebündelt via fontsource
(OFL, bau/lizenzen) — zur Laufzeit lädt Rippy nichts aus dem Netz.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 21:54:23 +02:00
HitonabiandClaude Fable 5 98ca2855e9 docs: SAVEPOINT — MakeMKV-Selbst-Update und Kino-Look
Ampel / ampel (push) Successful in 1m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:20:13 +02:00
HitonabiandClaude Fable 5 d5ea48cd1e feat(v5): MakeMKV-Selbst-Update (§ 9) und Kino-Look fürs Fenster
Ampel / ampel (push) Successful in 1m34s
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste
gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung
(GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt
(shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll
bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher
Zweig: installierte == neueste Fassung → kein sinnloser Download.

Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung,
Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als
Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit
Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:18:08 +02:00
HitonabiandClaude Fable 5 5622d8e47d docs: SAVEPOINT — erster Commander-Test, drei Funde behoben
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:23:55 +02:00
HitonabiandClaude Fable 5 c5dd90cc18 fix(v5): Die drei Funde aus dem ersten Commander-Test
Ampel / ampel (push) Successful in 1m25s
FUND 1 — 'Key korrekt, aber als ungueltig angezeigt': Stimmt beides.
Live gemessen: Der aktuelle Forum-Key ist in Ordnung, aber MakeMKV
1.18.4 ist ZU ALT fuer ihn — makemkvcon meldet 5020 UND 5021 zusammen,
und die alte Pruefung brach beim ersten Code ab und beschuldigte den
Schluessel. urteilAusAusgabe sammelt jetzt ALLE Codes (5021 schlaegt
5020, MakeMKV-Version aus MSG 1005 wird mitgelesen) und die Kachel
sagt den wahren Satz: 'Der Key ist in Ordnung — MakeMKV 1.18.4 ist zu
alt. Rippy hat ihn entfernt, damit der Beta-Modus weiterlaeuft (Rips
klappen trotzdem). Abhilfe: MakeMKV aktualisieren.' Neues Feld
weiterBetrieb: Beta-Modus ist GELB (Warnung), nicht rot. Test mit der
echten Ausgabe als Fixture.

FUND 2 — 'Komprimieren auf CPU statt GPU': Der Default 'H.265 MKV
1080p30' ist ein CPU-Preset. Neue GEMESSENE Empfehlung
(gemeinsam/preset-empfehlung.ts): aus den echten Backends und der
echten Preset-Liste des eigenen HandBrake — VCN vor NVENC vor QSV vor
CPU, nur Namen die WIRKLICH in der Liste stehen, HandBrake-Presets
deckeln die Aufloesung nur (DVD bleibt SD). Kette: eigene Wahl >
allgemeines Preset > Empfehlung > CPU-Default. Auf dem Commander-PC:
bluray/dvd -> 'H.265 VCN 1080p', uhd -> 'H.265 VCN 2160p 4K'. Die
Preset-Auswahl zeigt 'automatisch: <Empfehlung>' als Leer-Option.

FUND 3 — 'keine Alternative waehlbar': 'Aendern …'-Dialog an der
Laufwerks-Kachel (§ 5 Schritt 4): TMDb-Suche (Film + Serie, mit
Postern), Klick uebernimmt — Nutzer-Wahl gilt als 100 % und steuert
Ablage-Ordner, NFO und Poster. Neue Nachrichten metadaten-suchen /
zuordnung-setzen / metadaten-vorschlaege.

Dazu: Poster in der Laufwerks-Kachel (CSP um image.tmdb.org
erweitert), Restzeit-Schaetzung am Fortschritt.

192 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:20:10 +02:00
HitonabiandClaude Fable 5 0e1c927e7b docs: SAVEPOINT — Rippy v5 komplett (W-0 bis W-7), Beweise und offene Hand-am-Geraet-Punkte
Ampel / ampel (push) Successful in 1m21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:20:40 +02:00
HitonabiandClaude Fable 5 9fa3df500d feat(v5): Etappe W-6 — Setup, Selbst-Update, Erst-Einrichtung, Lizenzen
Ampel / ampel (push) Successful in 1m22s
electron-builder → NSIS: EIN RippySetup-<version>.exe, pro Benutzer,
ohne Adminrechte, eigenes Icon (PNG-basiertes ICO selbst gebaut).
extraResources: die MITGELIEFERTEN Werkzeuge (HandBrakeCLI GPL-2,
flac.exe MIT libFLAC.dll — die Ohne-DLL-Falle aus § 9) samt
Lizenztexten und Quellverweisen (bau/lizenzen, Entscheid 9); vendor/
liegt nicht im Git (74 MB), Befuellung steht in BAUEN.md. Der Kern
findet die mitgelieferten Werkzeuge ueber RIPPY_VENDOR (resourcesvendor) VOR allen anderen Suchwegen.

haupt/update.ts (§ 6.8): electron-updater gegen das oeffentliche Gitea
(festes Tag 'aktuell' als Rueckfallebene, Entscheid 6) — NUR https
(http wird ABGELEHNT; Regel-Tests), NIE waehrend eines Rips (der Haupt
kennt die aktiven Vorgaenge), Fehlschlaege still aber ablesbar
(letzte erfolgreiche Pruefung im Status), installiert beim naechsten
Start. haupt/autostart.ts: HKCU-Run ueber app.setLoginItemSettings,
Toggle in den Einstellungen. Erst-Einrichtung (§ 6.9) im Programm:
5 Schritte, NUR ein Fehler blockiert (kein Laufwerk = Warnung,
MakeMKV-Pflicht mit Klartext-Weg).

GEBAUT UND GEMESSEN (30.08.2026):
- dist-setup/RippySetup-5.0.0-w0.exe — 130,8 MB, SHA-256
  07D39D6BAB74A47C21AA53D847FB5A5DB6990AE9FA1CBC7BE814B51050883115,
  plus .blockmap.
- MESSUNG: Die Update-Auskunft heisst bei Vorab-Versionen nach dem
  KANAL (w0.yml) — erst ein Release ohne Suffix erzeugt latest.yml
  (steht jetzt in BAUEN.md).
- Das GEPACKTE Programm (win-unpacked, asar + resources) besteht den
  Smoke: SMOKE OK, und der Screenshot zeigt die Erst-Einrichtung —
  das Bild eines frischen Rechners.

Bewusst offen (SAVEPOINT): die automatische MakeMKV-Beschaffung
(beschaffen.ts — Download+Installer-Start braucht einen Live-Test);
das Setup NENNT den Weg klar. 185 Tests gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:03:40 +02:00
HitonabiandClaude Fable 5 2d9cef22b3 feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
Ampel / ampel (push) Successful in 1m20s
rip/audiocd.ts: die Rechnungen PUR (MSF→LBA mit den 150 Vorlauf-Frames,
TOC-Zerlegung mit Lead-Out-Pflicht und Daten-Track-Bit, 27-Sektoren-
Bloecke unter der 64-KB-Treibergrenze, RAW_READ_INFO mit DiskOffset in
2048ern OBWOHL der Sektor 2352 hat, WAV-Kopf, AC/DC-sichere Namen).
metadaten/musicbrainz.ts: DiscID nach musicbrainz.org-Rechnung — gegen
die ECHTE AC/DC-Kennung getestet (3KVSIWn_…), Vorlauf wird beim Melden
wieder HINZUgerechnet; Tags je Spur mit Bindewort-Kuenstlern.
laufwerk/disc.ts: audioToc + audioSpurLesen ueber die LaufwerkApi.

rip/iso.ts (§ 6.4): 1:1-Abbild fuer unbekannte Discs — async (ein
33-GB-Abbild darf den Kern nicht blockieren), sektor-ausgerichtet,
unvollstaendig heisst FEHLER mit Byte-Zahlen, nie Schoenfaerberei.

werkzeuge/schluessel.ts (§ 9.1): Dauerlizenz schlaegt Forum-Beta-Key
(t=1053, T-Muster aus makemkv_key.py), Ablage in HKCU ueber reg.exe
(kein zweiter koffi-Ort), GEFRAGT statt gerechnet (makemkvcon info
disc:9999, MSG 5020/5021/5051), abgelehnter Key wird ZURUECKGENOMMEN
(schlimmer als keiner: 0 Titel, kein Laufwerk), app_UpdateEnable nur-
wenn-fehlt (der 4K-Schluesselkanal). Kern prueft beim Start und
taeglich; Dashboard-Kachel zeigt Stand + letzten Pruefzeitpunkt;
Dauerlizenz-Feld in den Einstellungen.

Pipeline verzweigt nach Disc-Typ: cd → Sektoren→WAV→FLAC mit
MusicBrainz-Tags (Ordner 'Kuenstler - Album', ohne Treffer ehrlich
'Track NN'), unknown → ISO. Bibliothek-Eintraege fuer beide.

181 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:56:07 +02:00
HitonabiandClaude Fable 5 c78ff22ab7 feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s
haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter-
laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt /
fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt
(setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC.
Eigenes Icon (bau/icon.png + tray.png, Disc-Optik).

Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7,
Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene
HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 —
fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim
Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast),
Pipeline traegt fertige Vorgaenge ein.

Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek).
Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ
(Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren'
waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand
mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle.

Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das
Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in
'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext
samt Abhilfe statt eines stummen Zustands.

165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:47:39 +02:00
HitonabiandClaude Fable 5 a9fa8fa2d8 fix(v5): discmerkmale auf Plattform-Pfade — dritte Lehre desselben Musters
Ampel / ampel (push) Successful in 1m17s
titelAusBdmt lief auf der Laufzeit-Wurzel (G:\ im Betrieb, tmp-Ordner im
Linux-CI-Test) mit win32-join — der BDMV-Pfad wurde auf POSIX zu EINEM
Namen mit Backslashes und der Test brach. Dieselbe Regel wie
struktur/pipeline: Laufzeit-Wurzeln nehmen plattform-path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:41:45 +02:00
HitonabiandClaude Fable 5 5f386e5406 feat(v5): Etappe W-4 — Metadaten-Automatik mit Sicherheitsgrad
Ampel / ampel (push) Failing after 1m20s
metadaten/: discmerkmale.ts (BDMT-Studio-Titel, ISO-9660- und
UDF-Volume-Label direkt vom Medium — OHNE koffi, ueber fs auf \.\G: —,
Label-Normalisierung, Fingerabdruck, progressive Titel-Kandidaten),
tmdb.ts (v3-Key UND v4-Token, de-DE, /find fuer deutsche Texte zu
OMDb-Treffern), omdb.ts, zuordnung.ts (die Confidence-Kaskade
0,95/0,90/0,80/0,60/0,30 aus prescan.py). ablage/poster.ts. Kern meldet
'disc-info' beim Einlegen; die Pipeline benennt den fertigen Ordner
'<Titel> (Jahr)', schreibt movie.nfo/tvshow.nfo mit echten Metadaten
und laedt das Poster. Fenster zeigt 'sehr wahrscheinlich: … · 95 %'.

ZWEI Kaskaden-Schwaechen LIVE GEFUNDEN und gehaertet (gegen die echten
APIs gemessen, Keys aus den Docker-Settings der VM, nicht persistiert):
1. 'Spartacus Gods Of The Arena' wurde zum FILM 'Spartacus' (1960) —
   der stark gekuerzte Kandidat traf woertlich, bevor die volle Anfrage
   als spezifische SERIE galt. Kaskade jetzt JE KANDIDAT
   woertlich→spezifisch (spezifisch nur fuer die volle Anfrage, auch
   fuer Serien). Nachgemessen: jetzt Serie, 90 %.
2. OMDbs t=-Suche machte aus dem Label 'Bd Evg D2' selbstbewusst
   'BD Scream 5' — jetzt Wort-Jaccard-Gate >= 0,5. Nachgemessen: der
   Fall ist jetzt ein ehrlicher 60-%-Vorschlag, den das UI nachfragt.

Messwerte: Akira→95% · Evangelion 2.22→80% (der dokumentierte Fund) ·
Spartacus GotA→Serie 90% · Bd Evg D2→Vorschlag 60%.

Dazu ein neuer Quelltext-Hygiene-Waechter (keine rohen Steuerbytes —
zweimal beim Schreiben passiert, einmal haette ein Test dadurch anders
gemessen als gelesen). 165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:39:09 +02:00
HitonabiandClaude Fable 5 e840b7f327 fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
Ampel / ampel (push) Successful in 1m21s
Der NFO-Ablage-Test brach in der Linux-CI: pipeline/struktur bauten mit
path.win32 auf POSIX-Testordnern (mkdir legte 'x\Filme' als EINEN Namen
an, readdir fand nichts). Regel jetzt dokumentiert: Module ueber
LAUFZEIT-Wurzeln (Ablage) nutzen plattform-path — zur echten Laufzeit
immer Windows, in der CI physisch korrekt auf POSIX; Module mit FESTER
Windows-Semantik (katalog, dateiDaneben) bleiben bei path.win32.
Testerwartungen entsprechend ueber join() statt Literale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:29:46 +02:00
HitonabiandClaude Fable 5 3dc296f071 feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem
Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions-
Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN),
--format aus der Endung (Container kommt sonst aus dem Preset),
Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12
Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen
(kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung
mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help,
--preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ,
PRESET_KEINE, Sprachlisten.

ablage/: struktur.ts (sichere Namen, '<Titel> (Jahr)', Season NN,
Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema),
medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie).

ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als
EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und
nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird
in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI).

GEMESSEN am echten System (30.08.2026):
- Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1
  (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets.
- Probe-Encode 5 s Material: success in 45,5 s.
- VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11
  am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min
  auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt:
  E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2
  (2011)\ mit movie.nfo.
- Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was
  not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen
  und es nicht sind (der abgebrochene rc11-Rip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:26:00 +02:00
HitonabiandClaude Fable 5 ca338ad467 fix(v5): Windows-Pfadregeln ausdruecklich — path.win32 statt join
Ampel / ampel (push) Successful in 1m21s
Die Ampel (Linux) brach an den Katalog-Tests: das plattformabhaengige
path.join mischte dort Schraegstriche in Windows-Pfade. Rippy v5 baut
NUR Windows-Pfade — katalog.ts, handbrake.ts und ablauf/rippen.ts
rechnen jetzt ausdruecklich mit path.win32 und damit auf jeder
Plattform gleich. Im node:24-Container nachgestellt: vorher 2 failed,
mit dem Fix lokal 96 gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:16:17 +02:00
HitonabiandClaude Fable 5 844bca37ff feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
Ampel / ampel (push) Failing after 1m22s
rip/parser.ts: die reine Textverarbeitung mit allen bezahlten Fallen aus
dem Docker-Zweig — quelle() (dev:G: statt dev:\.\G:, der 404-Verwandte),
textVon() (UTF-8 streng, sonst cp1252 — nie wieder 'Öffnen'), parseMsg
(Regex statt split — Meldungen mit Komma), PRGV (-1 vs. echtes 0 %),
TINFO/SINFO (Sprache aus Attr 3/4, NICHT 28/29), laengsterTitel mit
Play-All-Schutz, episodenTitel, KRITISCHE_CODES (sprachfeste NUMMERN).

rip/makemkv.ts: Kommandobau + Lauf — ZeilenLeser (binaer, halbe Zeilen,
je Zeile dekodiert), RipAuswertung (pur), rippen() mit AbortSignal,
Lesefehler-Flag AUCH bei Rueckgabewert 0. werkzeuge/katalog.ts: die
Suchreihenfolge samt %VAR%-Gross/Klein-Falle und Registry-Rueckfall.
ablauf/rippen.ts: ein Rip je Laufwerk, Status-Push, 'erst nachsehen,
dann rippen' (unbekannt heisst trotzdem rippen). Fenster: Rippen-Knopf,
Fortschrittsbalken, Abbrechen; Auswurf waehrend des Rips abgelehnt.

Gemessen am echten System (30.08.2026): Katalog findet
makemkvcon64 (Program Files x86), HandBrakeCLI und flac (beide im
rc11-Werkzeugordner LOCALAPPDATA\Rippy\tools). NEUER BEFUND: Nach dem
abgebrochenen rc11-Rip vom Mittag sieht MakeMKV GAR KEIN Laufwerk mehr
(MSG 5042, leere DRV-Liste, mit und ohne --noscan), waehrend die
Storage-IOCTLs sauber antworten — MakeMKV spricht SCSI direkt. 5042 ist
jetzt ein KRITISCHER_CODE mit Klartext-Abhilfe. Der volle Rip-Beweis
(messung.rip.test.ts liegt bereit) braucht deshalb erst eine Hand am
Geraet: Disc neu einlegen oder USB ab-/anstecken.

96 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:12:44 +02:00
HitonabiandClaude Fable 5 a45069af5e feat(v5): Etappe W-1 — Laufwerk: Wache, Typ, Auswerfen mit Nachsehen
Ampel / ampel (push) Successful in 1m21s
kern/laufwerk in drei Schichten: codes.ts (Steuercodes HERGELEITET wie
win_ioctl.py, gegen die Doku-Zahlen getestet), win32.ts (der einzige
koffi-Ort im Kern, Muster aus beweise/laufwerk.js), disc.ts (Logik gegen
die LaufwerkApi-Schnittstelle — classify mit den cdrom.py-Schwellen,
Geraete-Info mit ready/empty/unknown + Klartext-Grund, Auswurf als
entriegeln→auswerfen→NACHSEHEN mit injizierbarem Warten). Dazu wache.ts
im 3-Sekunden-Takt (§ 5 Plan A): Uebergangslogik pur, gescheiterte Runde
behaelt den Stand (R2) und meldet laut (R4).

Fenster zeigt Laufwerke live (Modell, Typ, Groesse, Grund) mit
Auswerfen-Knopf; Ereigniszeilen fuer Disc rein/raus und Fehler.

Bündel-Falle gefunden und behoben: Ein woertliches require überlebt
Rollup nicht — win32 wird per dynamischem import() als eigener Chunk
gebaut; der Fehler war dank R4 im Fenster sichtbar statt still.

Gemessen am echten BU40N (30.08.2026, RIPPY_MESSUNG=1):
  Laufwerk G: status=ready typ=bluray groesse=33759690752
  modell=HL-DT-ST BD-RE BU40N 1.03 serial=0025114C0149
Der Auswurf-Beweis (messung.auswurf) folgt am Ende der Sitzung — der
BU40N kann die Schublade nicht selbst einziehen.

52 Tests gruen (Steuercodes, classify, Auswurf-Ablauf, Geraete-Info,
Wache, Smoke weiterhin gruen mit Laufwerks-Kachel im Bild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:28:46 +02:00
HitonabiandClaude Fable 5 6ed8035dcc fix(tests): Linux-only-Waechter pruefte die Welt vor d3e86d2
Ampel / ampel (push) Successful in 1m17s
Die Ampel war rot, und die Ursache war NICHT Rippy v5: d3e86d2 hat den
makemkvcon-Zweig aus dem Prescan bewusst entfernt (einer der 15 Funde),
aber test_makemkvcon_wird_ueber_den_katalog_gesucht verlangte weiter den
alten Katalog-Aufruf. Der Test laeuft nur in der CI (fcntl, lokal
ausgeschlossen) — deshalb fiel es erst beim ersten Push des Branches auf.
Der Waechter passt jetzt auf die NEUE Wirklichkeit auf: kein shutil.which
UND makemkvcon bleibt draussen. Unter Linux nachgemessen: 16/16 gruen,
Gesamtlauf der Nachstellung 995 passed + dieser Fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:19:51 +02:00
HitonabiandClaude Fable 5 a172a3e2c0 fix(ci): setup-node raus — der Runner hat Node 24 nativ
Ampel / ampel (push) Failing after 1m20s
Lauf 221 brach nach 2:14 min ab; der einzige neue Schritt war
actions/setup-node@v4. Nachgemessen am 30.08.2026: Das Runner-Image
docker.gitea.com/runner-images:ubuntu-latest bringt Node v24.18.0 und
npm 11.16 von Haus aus mit, und die kompletten Node-Schritte der Ampel
(npm ci + build + test in rippy-windows) laufen in einem frischen
node:24-Container auf der VM gruen durch. Statt Beschaffungs-Magie
prueft die Ampel jetzt im Klartext, dass Node >= 22 da ist — zu alt
heisst ROT mit Ansage, nicht kryptisch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:07:53 +02:00
HitonabiandClaude Fable 5 0464933ada docs: SAVEPOINT — Rippy v5, Etappe W-0
Ampel / ampel (push) Failing after 2m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:54:32 +02:00
HitonabiandClaude Fable 5 c539e65cd2 feat(v5): Etappe W-0 — das Geruest steht
Electron 44 + TypeScript, drei Prozesse (haupt / kern als utilityProcess /
fenster mit React), Nachrichten-Schema mit Pruef-Funktionen, node:sqlite
als einzige Datenbank-Stelle (kern/speicher/db.ts, § 6.7), Prozess-Leine
im Haupt (Job Object aus beweise/leine.js — Begruendung fuer den Ort im
Dateikopf), MessagePort direkt Fenster<->Kern, Einzelinstanz-Sperre,
Kern-Neustart-Wache, strikte CSP im gebauten Fenster.

Wächter-Tests nach § 4.3: koffi nur an zwei benannten Orten (R1), kein
leerer catch und kein catch-mit-Leerwert (R2/R4), kein HTTP-Server
(§ 4.1), node:sqlite nur in db.ts (§ 6.7). Dazu Datenbank- und
Schema-Tests: 18/18 gruen, Typpruefung in drei Kontexten.

Der W-0-Beweis (npm run smoke, echtes Programm):
SMOKE: OK — fenster=geladen kern=pid:13676,node:24.18.1 datenbank=ok
leine=gesetzt pong=ok version=5.0.0-w0

Nachgemessen ausserdem: taskkill /F auf den Haupt-Prozess (ohne /T,
der Absturz-Fall aus rc10) — der Kern stirbt mit.

Bau-Fallen dokumentiert (BAUEN.md): npm 11 blockt Electrons
Install-Skript (§ 3.5), plugin-react 6 verlangt Vite 8 (deshalb 5.2),
TypeScript bewusst auf 5.9.3 gepinnt statt tsgo 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:53:30 +02:00
HitonabiandClaude Fable 5 564bd17f84 ci: Ampel auf Node 24 verankert
Rippy v5 (rippy-windows/) braucht Node 24: Vite 7 und node:sqlite laufen
nicht auf dem Zufalls-Node des Runners. docker/ui unter Node 24 lokal
nachgemessen (npm ci + build gruen, 30.08.2026). Die Ampel prueft
unveraendert — nur das Werkzeug ist jetzt festgelegt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:53:16 +02:00
HitonabiandClaude Fable 5 a0cff205ab docs(konzept): Entscheid 4 — Worktree aufgeloest, Branch normal ausgecheckt
Der doppelte Checkout (Worktree + Hauptordner) blockierte den Start neuer
Sitzungen auf dem Branch. Der Branch selbst und main bleiben unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:25:58 +02:00
HitonabiandClaude Fable 5 c44bfa6daa docs(konzept): Zweite Fassung — geprueft, nachgemessen, drei Entscheide
- Faktenfehler korrigiert: Gitea ist oeffentlich per HTTPS erreichbar
  (gemessen, § 3.6) — Updates kommen direkt daraus, Drei-Wege-Tabelle weg
- node:sqlite in Electron 44 im utilityProcess bewiesen (§ 3.4,
  beweise/sqlite-main.js + sqlite-kern.js) — better-sqlite3 nicht noetig
- Disc-Wache umgedreht: 3-Sekunden-Takt ist Plan A, WM_DEVICECHANGE
  spaetere Verfeinerung (§ 5)
- Entscheid 7 verschaerft: main-Aufraeumen sofort, nicht erst nach W-6
- Entscheid 9 neu: Zielgruppe Bekannte mit Link, Lizenztexte liegen bei
- MakeMKV-Lage tagesaktuell bestaetigt (Key bis Ende September 2026,
  Kaufseite defekt = Dauerzustand seit Sommer 2025)
- Electron-Pflege-Regel (§ 6.8), npm-11-Sperre trifft auch Electron
  selbst (§ 3.5), Release = drei Dateien, 4K-Schluesselkette in § 10,
  Risikotabelle aktualisiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:20:51 +02:00
HitonabiandClaude Opus 5 e514e4070b docs(konzept): Entscheid 8 — der Remote-Worker bleibt bei Docker
Commander: „Das soll NICHT Teil des neuen Standalone-Produktes sein, da
dieser NUR fuer die Docker-Variante verwendet wird."

Damit ist bestaetigt, was in Paragraph 11.1 zuvor nur als Auslegung stand.
Der Remote-Encode-Worker wird WEDER abgeraeumt (er ist Docker, und Docker
bleibt unangetastet — Entscheid 2) NOCH nach v5 uebernommen (v5 ist
Einzelplatz, Entscheid 3 — es gibt dort keinen zweiten Knoten, dem man
etwas schicken koennte).

Dazu die eigentliche Reparatur an diesem Abschnitt: Er war in
Entwickler-Begriffen geschrieben (Modulnamen, PyInstaller, os.name-Zweige)
und deshalb fuer den Adressaten nicht lesbar — zweimal nachgefragt, zweimal
zu Recht. Paragraph 11.1 fuehrt die drei Teile jetzt erst in Klartext ein:

  A ist Rippy. B ist ein Handlanger fuer die VM. C sind Notizzettel,
  die wegen A eingeklebt wurden.

Und macht den Unterschied A/C an dem fest, worauf es beim Aufraeumen
ankommt — nicht am Inhalt, sondern am Ort: A sind ganze Ordner, die Docker
nie aufschlaegt (wegwerfen). C sind einzelne Seiten in Ordnern, die Docker
taeglich benutzt (einzeln durchgehen). Erst danach kommt die Tabelle mit
den Dateinamen.

Das ist kein Beiwerk: Genau diese Verwechslung wuerde beim Aufraeumen die
laufende VM treffen. Und manche Windows-Zeile braucht sogar B weiter — etwa
die Regel, dass eine Laufwerkswurzel ihren abschliessenden Trenner behaelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:59:11 +02:00
HitonabiandClaude Opus 5 fde3a78e97 docs(konzept): Entscheide 5-7 — kein Zertifikat, Gitea-Updates, Altlast weg
Die drei offenen Punkte aus KONZEPT-WINDOWS sind entschieden (30.08.2026).

Entscheid 5 — kein Code-Signing-Zertifikat. Die SmartScreen-Warnung wird
hingenommen und in der Anleitung erklaert. Die Folge gehoert dazu und steht
jetzt in 6.8: Ohne Signatur kann electron-updater ein Update nicht auf
Echtheit pruefen, also ist der Transportweg die einzige Absicherung —
Updates nur ueber HTTPS, eine http-Adresse wird abgelehnt.

Entscheid 6 — Updates aus Gitea, auch fuer Externe erreichbar. Rippys
Seite ist einfach: die Adresse steht in der Konfiguration, electron-updater
braucht nur einen statischen HTTPS-Ort. Die Netz-Seite ist es nicht —
Gitea liegt auf 192.168.178.153 im Heimnetz und ist von aussen nicht
erreichbar. Drei Wege sind aufgeschrieben und bewertet (Gitea
veroeffentlichen / oeffentlicher Spiegel / eigene Quelle je Nutzer),
Empfehlung ist der Spiegel. Rippy wird fuer alle drei gebaut, die
Entscheidung kann spaeter fallen ohne Programmaenderung.

Entscheid 7 — der alte Windows-Weg wird entfernt, auch aus main. Dafuer
gibt es einen neuen Abschnitt 11 mit der Arbeitsliste, und die zerfaellt
in DREI Teile statt einem:

  A  die Standalone-App (PyInstaller, Tray, Fenster, Win32-Treiber,
     lokale Queue, Werkzeug-Beschaffung)          -> weg
  B  der Remote-Encode-Worker fuer die Docker-Installation -> BLEIBT
  C  die verstreuten os.name=="nt"-Zweige im Docker-Code -> einzeln pruefen

Dass B bleibt, ist ausdruecklich als Auslegung markiert, nicht als
Anweisung: Der Remote-Worker ist eine Funktion der Docker-Installation,
und Entscheid 2 haelt die unangetastet. Ihn mit abzuraeumen wuerde der VM
eine Faehigkeit nehmen, die niemand gekuendigt hat.

Teil C ist der heikle: pfade.py bleibt (der Remote-Worker braucht
Laufwerkswurzeln und UNC), nativ_nachsehen() ist zu pruefen, und die
Faehigkeiten-Auskunft /betrieb bleibt sinnvoll — nur ihre Windows-Werte
fallen weg.

Zeitpunkt: erst NACH v5 W-6. Solange v5 nicht installierbar ist, ist die
alte Fassung das einzige Windows-Rippy — sie vorher zu loeschen waere eine
Luecke ohne Gegenwert. Der Entscheid nennt die Richtung, kein Datum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:52:37 +02:00
HitonabiandClaude Opus 5 cb811bec28 docs: KONZEPT-WINDOWS — Rippy v5 als eigenstaendiges Electron-Programm
Die heutige Windows-Fassung ist der Docker-Code in einer EXE: PyInstaller
buendelt docker/api und docker/worker, uvicorn laeuft auf 127.0.0.1:7788,
pywebview stellt ein Fenster davor. Elf Release-Kandidaten in drei Tagen,
und die Funde sind fast alle derselbe Typ — Linux-Annahmen, die unter
Windows etwas anderes bedeuten (timeout ls -d, shutil.which, /app,
/dev/{name}, F: ohne Trenner).

Das Konzept beschreibt den Neubau als Windows-Programm: Electron 44,
TypeScript durchgehend, Einzelplatz, kein Python, kein HTTP-Server.

Vier Entscheide des Commanders (30.08.2026) sind eingearbeitet:
alles TypeScript/Node · Docker bleibt unangetastet · reiner Einzelplatz ·
eigener Branch.

Vor der ersten Zeile Konzept gemessen (Regel D), Belege in beweise/:

  * Win32 aus Node an G: (HL-DT-ST BD-RE BU40N) — Laufwerkssuche,
    CreateFileW, QUERY_PROPERTY (Modell + Serial), CHECK_VERIFY2,
    GET_LENGTH_INFO (33,76 GB -> Blu-ray). IOCTL_CDROM_DISK_TYPE
    antwortet mit Fehler 50 — derselbe Befund wie im Python-Treiber.
  * Prozess-Leine (Job Object): Kind stirbt mit dem per taskkill /F
    ohne /T abgeschossenen Elternprozess. Die Messung aus rc10, in
    Node nachgestellt.
  * node:sqlite ist in Node 24 eingebaut — in Electron noch ungeprueft,
    ausdruecklich als offener Punkt markiert.

Akutestes Risiko im Dokument: MakeMKVs freier Beta-Key laeuft Ende
September 2026 ab, die Kaufseite fuer die Dauerlizenz ist seit Mai defekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:38:39 +02:00
194 changed files with 38308 additions and 6 deletions
+11
View File
@@ -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")
+3
View File
@@ -55,3 +55,6 @@ logs/
*.db *.db
*.sqlite *.sqlite
dist/ dist/
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
design-entwuerfe/
+1131
View File
File diff suppressed because it is too large Load Diff
+2218 -1
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -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.
+402
View File
@@ -0,0 +1,402 @@
# Durchsicht Rippy v5 (5.6.1) — Bericht vom 12.09.2026
> Anlass: Drei Filme (Der Patriot, Déjà Vu, Training Day) lassen sich auf
> einem ANDEREN PC mit Rippy nicht bearbeiten, während MakeMKV allein sie
> lesen und öffnen kann. Dazu die Bitte um einen kompletten Check von
> Kern, Haupt und Fenster.
>
> Was hier steht, ist GEMESSEN oder im Quelltext mit Zeile belegt — nichts
> ist geraten. Wo ich etwas nicht messen konnte, steht das dabei.
Stand des Repos: Branch `worktree-windows-electron`, Spitze `473eea7`,
Arbeitsbaum sauber. `npm run typecheck` grün, `npm test` grün
(450 Tests bestanden, 12 übersprungen — die Messungen, die ein Laufwerk
brauchen). Gelesen wurden ALLE 92 Quelldateien unter `rippy-windows/src`
(16 081 Zeilen), die Tests, die Bau-Konfiguration und die Protokolle des
echten Rippy auf diesem PC.
---
## 1. Die drei Filme — was am anderen PC dahintersteckt
Ich habe die Disc-Ausgaben des anderen PCs nicht; ich habe aber ALLE Wege
gefunden, auf denen Rippy scheitert, während MakeMKV allein weiterkommt.
Sie stehen in der Reihenfolge, in der ich sie für wahrscheinlich halte —
mit der Zeile, an der man sie am anderen PC in einer Minute erkennt.
**Die Protokoll-Datei liegt dort unter `%APPDATA%\Rippy\logs\rippy-JJJJ-MM-TT.log`**
(im Programm: Einstellungen → Allgemein → „Protokoll öffnen"). Die Zeilen
des Tages, an dem einer der drei Filme versucht wurde, entscheiden.
### Kandidat 1 — MakeMKV läuft dort ohne gültigen Schlüssel (Beta-Key-Fehler in Rippy)
Der frei veröffentlichte Beta-Key vom September 2026 enthält ein `@`:
`T-vwvn…6Ox4BI@N5rPp` (68 Zeichen, heute von der Herstellerseite geladen,
„valid until end of September 2026"). Rippys Muster
`KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/` (`kern/werkzeuge/schluessel.ts:19`)
kennt kein `@` und schneidet den Schlüssel auf 62 Zeichen ab (Sonde 9,
gemessen an der echten Seite). Den verstümmelten Schlüssel lehnt MakeMKV
ab (5020), Rippy nimmt ihn „damit MakeMKV im Beta-Modus weiterläuft" wieder
zurück (`schluessel.ts:214-219`) — und MakeMKV läuft ohne Schlüssel.
Für DVDs ist das egal (laut Hersteller dauerhaft frei), **für Blu-rays
schaltet MakeMKV nach Ablauf des Testzeitraums ab** — nach der
Gnadenfrist („noch einige Tage", MSG 5051) beim Speichern, je nach
Fassung auch beim Öffnen. Ob das GUI am anderen PC noch in der
Gnadenfrist ist, sagt dessen Startmeldung; Rippy dagegen meldet den
Zustand nicht als Ausfall (siehe F1/F5).
Warum es bei DIR läuft: In der echten Datenbank dieses PCs steht unter
`makemkvAppKey` ein 68-Zeichen-Schlüssel MIT `@` — du hast den Key von
Hand als „Eigener Schlüssel" eingetragen. Der andere PC verlässt sich
vermutlich auf die Automatik, und die liefert den kaputten Key.
*Erkennen:* Einstellungen → MakeMKV zeigt dort gelb „BETA-MODUS — RIPS
LAUFEN" oder rot; im Protokoll steht `Schlüssel: Beta-Key abgelehnt … —
wieder entfernt`; ein gescheiterter Rip trägt `Ursache: MakeMKV ist nicht
freigeschaltet (Testzeitraum abgelaufen)`; MakeMKV-GUI zeigt beim Start
„Der Testzeitraum ist abgelaufen".
*Sofort-Abhilfe ohne neue Version:* den vollständigen Key (mit `@`) von
Hand unter Einstellungen → MakeMKV → „Eigener Schlüssel" eintragen,
„Schlüssel neu prüfen". Gilt bis 30.09.2026 — danach braucht es den
Rippy-Fix (Fund 1 unten) oder wieder Handarbeit.
### Kandidat 2 — Titel lesen bricht nach fünf Minuten ab (der Timeout, nicht die Disc)
`erkennung.titelLesen` tötet `makemkvcon info` nach 300 s
(`kern/dienste/erkennung.ts:315`, `kern/rip/makemkv.ts:170-173`) und
meldet „MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer
beschädigten Disc kann das passieren". Ältere DVDs mit Kopierschutz
(Sony-ARccOS, Disney-99-Titel-Struktur) und Discs mit vielen Playlisten
brauchen im MakeMKV-GUI regelmäßig länger als fünf Minuten zum Öffnen —
das GUI wartet, Rippy nicht. Ein hart beendetes makemkvcon hinterlässt
außerdem gern den 5042-Zustand („findet kein Laufwerk"), sodass der
NÄCHSTE Versuch sofort scheitert.
*Erkennen:* `Titel-Lauf X gescheitert: MakeMKV hat die Titel nach fünf
Minuten nicht gelesen`; im GUI dauert „Disc wird geöffnet" länger als 5 min.
### Kandidat 3 — der Rip startet still nicht (Ablage oder Arbeitsordner nicht erreichbar)
`Pipeline.starten` legt den Roh-Ordner mit `mkdirSync` an
(`kern/ablauf/pipeline.ts:258`) — ohne Fangnetz. Ist die Ablage oder der
Arbeitsordner gerade nicht da (NAS aus, Netzlaufwerk noch nicht
verbunden, Buchstabe weg), fliegt eine Ausnahme aus `starten()`, der
Aufruf `void laufwerke.bereit.then(() => ripVerwaltung.starten(…))`
(`kern/index.ts:198`) verschluckt sie: **keine Meldung im Fenster, keine
Zeile im Protokoll, der Dialog schließt sich, nichts passiert** (Sonde 10:
`ENOENT … mkdir 'Q:\rippy-nicht-da\roh\…'`, gemeldet: nichts). Der Kern
stirbt dabei nicht (in Electron 44 gemessen: ein utilityProcess überlebt
eine unbehandelte Promise-Ablehnung) — er schweigt nur.
*Erkennen:* Nach `Titel-Lauf X: … s, N Titel` folgt KEINE Zeile
`Vorgang … beginnt`; die Kachel bleibt ohne Balken.
### Kandidat 4 — Disc-Typ „unbekannt" → Rippy sichert ein ISO statt zu rippen
Der Typ kommt aus `IOCTL_CDROM_DISK_TYPE` + Größe (`kern/laufwerk/disc.ts:52-59`).
Antwortet das Laufwerk darauf nicht (bei manchen USB-Brücken
`ERROR_INVALID_FUNCTION`) oder ohne Bits, steht `unknown` — die Kachel
zeigt „unbekannt" und PRESET „ISO-Abbild", der Knopf „Rippen" öffnet
trotzdem den Titel-Dialog (`LaufwerkKachel.tsx:157-166`), und nach der
Auswahl legt die Pipeline ein 1:1-Abbild an statt der gewählten Titel
(`pipeline.ts:236-241`).
*Erkennen:* `Disc eingelegt in X: unknown, … Bytes`, Kachel MEDIUM
„unbekannt", Statuszeile „Sichere 1:1-Abbild …".
### Kandidat 5 — Erkennung bleibt bei 60 %, die Automatik wartet (kein Fehler, aber „tut nichts")
Alle drei Discs tragen vermutlich englische Labels (`THE_PATRIOT`,
`DEJA_VU`, `TRAINING_DAY`). TMDb antwortet auf Deutsch („Der Patriot",
„Déjà Vu Wettlauf gegen die Zeit"); der wörtliche Vergleich
`gleichBedeutend` (`kern/metadaten/zuordnung.ts:184-195`) vergleicht nur
den deutschen `title`, nie `original_title`, und kennt keine Akzente
(Sonde 1: „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot"). Ergebnis:
Vorschlag mit 60 % statt 95 % — die Automatik startet nicht
(`automatik.ts:79-82`), im Kasten steht „Automatik wartet: Die Erkennung
ist nicht sicher genug". Wer auf Zero-Click wartet, sieht: nichts.
*Erkennen:* Kachel „Vorschlag" (gelb) statt „sehr wahrscheinlich"; die
Automatik-Zeile.
### Was ich vom anderen PC brauche, um es festzunageln
1. Die Protokoll-Zeilen des Tages (siehe oben) — am besten die ganze Datei.
2. Einstellungen → MakeMKV: der Text der Schlüssel-Karte, und MakeMKV-Version.
3. Ob die drei Discs DVD, Blu-ray oder 4K sind.
4. Was genau „nicht bearbeiten" war: kein Start, roter Text, ISO, oder ewig „liest die Titel".
---
## 2. Fehler — nach Schwere, mit Beleg
Schwere: **HOCH** = Datenverlust, Ausfall oder stilles Scheitern ·
**MITTEL** = falsches Verhalten, das der Nutzer bemerkt · **NIEDRIG** =
Schönheit, Bedienung, Nebensache. Jede Nummer verweist auf Datei:Zeile im
Stand `473eea7`.
### HOCH
**F1 · Beta-Key mit `@` wird abgeschnitten → MakeMKV unlizenziert**
`kern/werkzeuge/schluessel.ts:19` (`KEY_MUSTER`), `:24-27`, `:212-219`.
Beleg: Sonde 9 an der heute geladenen Forum-Seite: Seite 68 Zeichen mit
`@`, Rippy zieht 62. Wirkung: siehe Kandidat 1 — auf jedem PC ohne
Hand-Key laufen Blu-ray-Rips nach dem Testzeitraum nicht mehr, und Rippy
zeigt dazu GELB „BETA-MODUS — RIPS LAUFEN" (`Karten.tsx:409-413`,
`App.tsx:115`). Dazu: `extractGueltigBis` (`:32-37`) erkennt „end of
September 2026" nicht → kein Ablaufdatum im Fenster (gemessen: leer).
Fix: Muster auf alles bis Leerzeichen/`<` (z. B. `/T-[^\s<"']{50,}/`),
Ablehnung ROT mit Klartext „Blu-ray-Rips laufen nicht", und ein Test mit
dem echten September-Schlüssel.
**F2 · Rip-Start ohne erreichbare Ablage/Arbeitsordner scheitert STILL**
`kern/ablauf/pipeline.ts:258` (`mkdirSync` ohne try), ebenso `:646`
(Audio-CD) und `:721` (ISO); Aufrufer `kern/index.ts:198` und `:236`.
Beleg: Sonde 10 + Electron-Messung (utilityProcess lebt weiter, Fehler
unsichtbar). Verstößt gegen R4. Fix: `starten()` in try/catch, Fehler als
`rip-status fehler` melden UND ins Protokoll; zusätzlich vor dem Start
eine Schreibprobe am Roh-Ordner, damit die Kachel sagt „Ablage nicht
erreichbar" statt zu schweigen; im Kern ein `process.on('unhandledRejection')`,
das ins Protokoll schreibt.
**F3 · Serien: zweite Disc derselben Staffel — Folgen werden nicht benannt, Überschreiben möglich**
`kern/ablage/struktur.ts:224-246`: `episodenUmbenennen` zählt ALLE `.mkv`
im Season-Ordner; liegen dort schon `S01E01`/`S01E02` der ersten Disc,
heißt es „Umbenennen übersprungen: 4 Dateien, 2 Zuordnungen" (Sonde 2,
real ausgeführt). Der Serien-Merker (5.5.0) und „ab Folge" laufen damit ab
Disc 2 ins Leere. Schlimmer: Folgen behalten bis dahin MakeMKVs Namen
(`ablegen.ts:198-209`, `dateinamen.ts:121`), und `title_t00.mkv` der
zweiten Disc landet im SELBEN Ordner (`ablegen.ts:152-156`) — HandBrake
bzw. `renameSync` (`ablegen.ts:95-104`) überschreiben eine gleichnamige
Datei der ersten Disc ohne Warnung, wenn deren Umbenennung ausgefallen
war (kein TMDb). Der bestehende Test `test/ablage.test.ts:106-116`
schreibt das Verhalten sogar fest. Fix: nur die NEUEN Dateien dieser
Disc umbenennen (Liste übergeben statt Ordner lesen); Zielnamen vor dem
Encode auf Kollision prüfen und nie überschreiben.
**F4 · Kern-Neustart: das Fenster bleibt taub**
`haupt/kernstart.ts:59-74` startet den Kern bis zu 3× je Minute neu, aber
`fensterVerbinden` läuft nur bei `did-finish-load` (`haupt/index.ts:245-253`);
das Fenster hält den toten Port (`kernverbindung.ts:268-282`), `anKern`
schickt ins Leere, und `App.tsx:92` zeigt keinen Fehler, weil
`verbindung.kern` noch die alte Bereit-Meldung ist. Wirkung: Laufwerke
frieren ein, Knöpfe tun nichts — bis Rippy komplett neu gestartet wird.
Das KONZEPT verspricht „Der Kern ist abgestürzt, ich starte ihn neu".
Fix: im `spawn`-Ereignis des neuen Kerns `fensterVerbinden` erneut
aufrufen und eine Zeile im Fenster zeigen.
**F31 · Nachtrag (12.09. abends, beim Bau von 5.7.0 gemessen): jede
Tonspur wurde seit 5.0 in AAC umgerechnet — nicht verlustfrei durchgereicht**
`kern/komprimieren/handbrake.ts` gibt `--aencoder copy --audio-fallback
av_aac` mit, aber KEIN `--audio-copy-mask`. HandBrakeCLI 1.11.2 verengt
die Kopiermaske dann auf „copy:aac" — obwohl die Presets („H.265 MKV
1080p30", „H.265 VCN 2160p 4K" …) alle Codecs erlauben. Beleg: Encode-
Protokoll `Auto Passthru: allowed codecs are AAC … passthru not possible
for track 1, using fallback`; Ausgabe per ffprobe `aac`. Mit
ausdrücklicher Maske (`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac`) bleibt
AC3 AC3 und die Passthru-Zeile nennt alle acht Codecs (Messung in
`test/messung.spuren.test.ts`, `RIPPY_MESSUNG=1`). Wirkung: Jede seit
5.0 komprimierte Datei trägt AAC statt DTS-HD MA/TrueHD/AC3 — wo die
Roh-Dateien nach der Kompression gelöscht wurden, ist der Original-Ton
weg. Fix in 5.7.0: Maske auf beiden Wegen (Sprachlisten und Spuren
einzeln). Filme, bei denen der HD-Ton wichtig ist, noch einmal rippen.
### MITTEL
**F5 · Schlüssel-Prüfung urteilt ohne Grundlage**
`schluessel.ts:108-131`: `info disc:9999` liefert ohne angeschlossenes
Laufwerk NUR 5042/5010 (heute gemessen) — keine Lizenzzeile, also
`ok: true` und „angenommen", egal was im Register steht. `:179`:
das Ergebnis von `keyAblegen` (reg add) wird ignoriert — „hinterlegt"
steht auch dann im Protokoll, wenn nichts geschrieben wurde. `:194-237`:
Ablehnung liefert `weiterBetrieb: true` (gelb) statt rot.
**F6 · Jeder Auswurf legt eine weitere Takt-Kette der Disc-Wache an**
`kern/laufwerk/wache.ts:285-303`: `runde()` plant IMMER einen neuen
Timer, ohne den laufenden zu löschen; `laufwerke.ts:175` ruft `runde()`
nach jedem Auswurf. Beleg: Sonde 3 — drei Ketten, neunmal statt fünfmal
gelesen. Wirkung: nach 20 Discs fragt Rippy die Laufwerke alle 150 ms
statt alle 3 s (CreateFileW + IOCTL je Runde). Fix: vorhandenen Timer
in `runde()` erst löschen.
**F7 · Während der Kompression wird jede Sekunde die ganze Welt neu vermessen**
Kette: HandBrake-Fortschritt (mit ETA ≈ 1×/s, `handbrake.ts:152-158`) →
`warteschlange.ts:141-146``vorgaenge.ts:118-124` `senden()`
`stand()` (`:217-260`) läuft mit `ordnerGroesse` rekursiv durch JEDEN
Roh-Ordner plus `fremdeOrdner`, danach `speicher.senden()` mit zwei
`statfsSync` (`speicher.ts:237-247`) — alles synchron im Kern; dann geht
die volle Vorgangsliste an Haupt und Fenster, das komplett neu zeichnet
(`kernverbindung.ts:123-125`). Auf einer NAS als Arbeitsordner ist das
ein Netz-Ordnerlauf pro Sekunde. Fix: Fortschritt als kleine Nachricht
getrennt vom Vorgangs-Stand; Roh-Größen nur bei Phasenwechsel oder
gedrosselt (z. B. alle 30 s) messen.
**F8 · Das Rettungs-Abbild wird gelöscht, wenn der Rip daraus leer ausgeht**
`pipeline.ts:279-300`: `beenden()` entfernt den ganzen Roh-Ordner, wenn
keine MKV entstand — inklusive `rettung.iso`, für das die Disc stundenlang
Sektor für Sektor gelesen wurde (`halbeDateienEntfernen` schont es,
`beenden` nicht). Fix: `rettung.iso` + Bericht vor dem `rmSync` verschonen.
**F9 · Halbe HandBrake-Ausgabe bleibt nach Abbruch/Fehler liegen**
`handbrake.ts:282-285,300-308`, `ablegen.ts:279-286`: nach `kill()` oder
Fehler wird die angefangene Datei nicht entfernt. Ohne Arbeitsordner
liegt sie direkt in `Filme\<Titel>\` — Jellyfin zeigt einen kaputten
Film. Fix: nach `.part`/Zwischenname encodieren, erst am Ende umbenennen;
bei Abbruch löschen.
**F10 · Typ „unbekannt" heißt stumm ISO, obwohl der Nutzer Titel gewählt hat**
`pipeline.ts:236-241` gegen `LaufwerkKachel.tsx:157-166`/`Uebersicht.tsx:114-120`
(siehe Kandidat 4). Dazu `disc.ts:49`: UHD wird allein an ≥ 55 GiB
erkannt — eine 4K-Disc mit 48 GB Daten wird „bluray" (Sonde 4) und
bekommt das Blu-ray-Preset. Fix: liegt ein Titel-Auftrag vor, immer
MakeMKV; UHD-Merkmal am Gerät nachmessen (Regel D) statt Größe.
**F11 · Laufwerks-Gesundheit beschuldigt das falsche Gerät**
`kern/laufwerk/ereignisse.ts:41-44`: der XPath nimmt Provider `cdrom` UND
`disk` für ALLE Geräte; `:67-76` filtert nicht nach `\Device\CdRomN`.
Eine hustende Festplatte oder ein zweites Laufwerk erzeugt „VERDACHT:
DIE HARDWARE" für die Kachel des Blu-ray-Laufwerks. Fix: Gerät des
Laufwerksbuchstabens ermitteln und nur dessen Ereignisse zählen.
**F12 · Zeiten stehen in UTC, nicht in deiner Uhrzeit**
`bausteine.tsx:35-38` (`datumKurz`), `Karten.tsx:269,276,418,574`,
`VerlaufKarte.tsx:295`: ISO-Strings werden abgeschnitten — 21:30 Uhr
erscheint als 19:30 (Sonde 5). Fix: lokal formatieren.
**F13 · Eigener Werkzeug-Pfad eingetragen → Anzeige bleibt „NICHT GEFUNDEN"**
`kern/index.ts:245-254`: nach `werkzeug.*` wird `werkzeuge.senden()` nicht
angestoßen (auch `makemkvAppKey` löst keinen Schlüssel-Lauf aus). In der
Erst-Einrichtung (`ErstEinrichtung.tsx:112-121`) bleibt die rote Zeile
stehen, obwohl der Pfad stimmt. Fix: nach diesen Schlüsseln messen bzw.
prüfen.
**F14 · Erkennung kennt weder Originaltitel noch Akzente**
`zuordnung.ts:184-195,302-309`, `tmdb.ts:24-34` (kein `original_title`).
Siehe Kandidat 5. Fix: `original_title`/`original_name` mitvergleichen,
Akzente falten (NFD + Kombinationszeichen entfernen).
**F15 · Tray „Beenden" tötet einen laufenden Rip ohne Rückfrage**
`haupt/index.ts:278-281,383-387`, `tray.ts:291-297`: `app.quit()` → Kern
`kill()` → die Leine reißt makemkvcon/HandBrake mit — 40 Minuten Rip weg.
Fix: bei aktiven Vorgängen nachfragen.
**F16 · MakeMKV-Installer aus dem Internet Archive mit Adminrechten**
`beschaffen.ts:143-155,193-232`, `haupt/index.ts:206-216`: fällt der
Hersteller aus, wird eine EXE vom Archiv geladen und per UAC gestartet;
geprüft wird nur die Versions-Ressource (fälschbar). Für ein Programm im
Bekanntenkreis ein Risiko. Fix: SHA-256 einer bekannten Fassung
mitführen oder Archiv-Quelle nur nach Bestätigung.
### NIEDRIG
**F17** „Roh-Dateien löschen" und „Ordner löschen" ohne Rückfrage — ein Klick, 30 GB weg (`Karten.tsx:315-319,351-353`; `vorgaenge.ts:305-349`).
**F18** Eingabefelder speichern nur beim Verlassen; Enter und Bereichswechsel verlieren Tipparbeit (`bausteine.tsx:356-389`).
**F19** Dialoge ohne Escape, Fokus-Fang, `role="dialog"` (`bausteine.tsx:453-473`).
**F20** Toast „Disc erkannt" bis zu dreimal je Disc — Erkennung, Nachprüfung, „Ändern" (`haupt/index.ts:126-134`).
**F21** Poster-Fehler wird verschluckt: Rückgabe von `posterSpeichern` ignoriert (`ablegen.ts:385-387`, R4).
**F22** `finden()` ruft bei fehlendem MakeMKV dreimal `reg query … /s` synchron (bis 45 s) — bei jedem Aufruf, auch alle 15 s im Update-Lauf (`katalog.ts:168-203`).
**F23** NFO ohne `<uniqueid type="tmdb">` — Jellyfin muss über Titel/Jahr raten (`nfo.ts:187-209`).
**F24** Doppelter Zielordner bekommt den Zusatz „[bluray-2]" (`struktur.ts:158-170`, `vorgangsName.slice(0,8)`).
**F25** ASCII-Umlaute in Knopftexten: „auswaehlen", „Titel ueberspringen", „naechsten" (`LaufwerkKachel.tsx:161,413,415`).
**F26** Phase `rettet` fehlt in der Haupt-Liste aktiver Vorgänge (`haupt/index.ts:184`) — Update-Sperre und Taskleiste greifen im Rettungs-Abbild nicht.
**F27** „Neu komprimieren" wird auch für verschobene (unkomprimierte) Vorgänge angeboten (`vorgaenge.ts:245`) — Klick endet in „Roh-Dateien sind nicht mehr da".
**F28** Automatik schweigt bei Typ `unknown`/`cd` (`automatik.ts:77`) — kein Grund im Kasten.
**F29** Ton: `--first-audio` nimmt je Sprache genau EINE Spur (`handbrake.ts:56`) — Kommentar- oder Stereo-Spuren derselben Sprache fallen weg. Bewusst? Dann ins Fenster schreiben.
**F30** Audio-CD: `audioSpurLesen` blockiert den Kern synchron je Spur (`disc.ts:200-220`) — Abbrechen wirkt erst zwischen zwei Spuren.
---
## 3. Auffälligkeiten (keine Fehler, aber wissenswert)
**A1 · Der „Datenbank-Befund vom 01.09." ist gelöst — ein Messartefakt.**
Das Terminal der Claude-Desktop-App läuft in einem MSIX-Paket; alles,
was daraus startet, sieht `%APPDATA%`, `%LOCALAPPDATA%` und `HKCU`
virtualisiert (Schattenkopie unter
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\…`). Deshalb
zeigte `%APPDATA%\Rippy\rippy.db` hier Schema 3 vom 01.09. — die ECHTE
Datei (über `\\localhost\C$\…` gelesen) ist Schema 4, 36 864 Bytes, Stand
04.09. 22:02, mit allen Einstellungen. Rippy ist unschuldig. Folge: die
Lizenzzeilen 5054/5051 in `beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt`
kamen aus dem Paket-Register ohne Schlüssel — kein Beleg für deinen
echten Zustand. (Als Gedächtnis-Notiz für künftige Sitzungen hinterlegt.)
**A2 · Rippy schreibt in MakeMKVs eigene Einstellungen** (`HKCU\Software\MakeMKV`:
`app_Key`, `app_DefaultSelectionString`, `app_UpdateEnable`). Damit
nimmt auch das MakeMKV-GUI seitdem ALLE Spuren mit (größere Dateien beim
Handbetrieb) — beabsichtigt, aber nirgends im Fenster gesagt.
**A3 · Das Rettungs-Abbild ist bei AACS-Discs (Blu-ray/4K) ungeprüft.**
Ein Sektor-Abbild bleibt verschlüsselt; ob makemkvcon es als `iso:` ohne
Laufwerk öffnet, hängt an MakeMKVs Schlüsseldaten. Bei DVDs (CSS) sollte
es gehen. Am Gerät messen, bevor es jemand im Ernstfall braucht.
**A4 · 4K wird standardmäßig komprimiert** — mit dem 8-Bit-Preset
„H.265 … 2160p 4K" bzw. auf CPU mit „H.265 MKV 1080p30" (`presets.ts:23-49`,
`preset-empfehlung.ts:321-335`). HDR-Metadaten und 10 Bit gehen dabei
verloren, der Encode dauert Stunden. Das Docker-Rippy hatte „4K
verlustfrei" als Regel. Entscheid nötig: Standard „keine" für UHD oder
ein 10-Bit-Preset.
**A5 · Toter Schlüssel `transcodePreset`** in `presets.ts:47-48` — seit
5.5.0 nicht mehr in `PIPELINE_EINSTELLUNGEN`, kann nie greifen.
**A6 · Titel-Lesen: 300 s fest verdrahtet**, kein Regler, Meldung nennt
die Disc „beschädigt" (`erkennung.ts:354-356`) — siehe Kandidat 2.
**A7 · Der Klick-Beweis und die 450 Tests sind gut, aber blind für genau
die Funde oben:** kein Test mit Bestandsdateien im Season-Ordner, keiner
für mehrfaches `runde()`, keiner mit einem Key mit `@`, keiner für den
Rip-Start ohne Ablage, keiner für den Kern-Neustart.
**A8 · Sicherheit/Härtung ist sonst gut:** contextIsolation + sandbox im
Fenster, CSP im Bau, keine Shell-Aufrufe mit Nutzerdaten, PowerShell-
Pfad wird korrekt gequotet, Update nur über HTTPS.
---
## 4. Vorschläge zur Verbesserung — in dieser Reihenfolge
1. **F1 sofort** (eine Zeile plus Test, plus rote Ampel) — vor dem 30.09.
ist das der einzige Fund, der beide PCs betrifft.
2. **F2 + F4 + F6** — die drei „stillen" Fehler; jeder ist klein, alle
drei kosten Vertrauen („tut nichts, sagt nichts").
3. **F3** — Serien mit mehr als einer Disc gehen sonst kaputt; Test mit
vorhandenen Dateien im Ordner dazu.
4. **F8 + F9** — Aufräumen, das nicht zu viel und nicht zu wenig löscht.
5. **Kandidat 2** — Titel-Lesen-Grenze auf 1015 min, Meldung ehrlich
(„die Disc braucht länger — läuft weiter / abbrechen"), Grenze in den
Einstellungen.
6. **F7** — Fortschritt von Vorgangs-Stand trennen; spürbar auf NAS.
7. **F14** — Originaltitel + Akzente: schlägt sich direkt in der
Trefferquote der Automatik nieder.
8. **F10/F11/F12/F13** — sichtbare Kleinigkeiten, je ein halber Nachmittag.
9. **F15/F17** — zwei Rückfragen, die Daten retten.
10. **A4** — dein Entscheid zu 4K.
11. **F31** (Nachtrag) — ist in 5.7.0 behoben; Filme mit wichtigem
HD-Ton, deren Roh-Dateien schon weg sind, noch einmal rippen.
---
## 5. Was gemessen wurde (nachvollziehbar)
* Typprüfung und Tests: `npm run typecheck`, `npx vitest run` — grün
(450 bestanden, 12 übersprungen).
* Sonden gegen den echten Code (Vitest, außerhalb des Repos):
S1 Akzente/Originaltitel · S2 Episoden-Umbenennung mit Bestand ·
S3 Wache-Takt-Ketten (9 statt ~5 Runden) · S4 Typ-Zuordnung ·
S5 UTC-Anzeige · S6 Labels der drei Filme · S7 99-Titel-Muster ·
S8 `halbeDateienEntfernen` · S9 Beta-Key von der echten Forum-Seite ·
S10 Rip-Start mit unerreichbarer Ablage.
* Electron 44: utilityProcess überlebt unbehandelte Promise-Ablehnung
(zwei Läufe, mit und ohne Horcher).
* Forum-Seite t=1053 heute geladen: Key mit `@`, „valid until end of
September 2026".
* Echte `rippy.db` dieses PCs über `\\localhost\C$`: Schema 4,
`makemkvAppKey` 68 Zeichen mit `@`, `ablage E:\Rippy`, `automatik aus`,
Jellyfin, TMDb- und OMDb-Schlüssel gesetzt, keine Vorgänge (Verlauf
leer), Bibliothek leer.
* Protokolle dieses PCs (02.05.09.): Rippy 5.5.0 → 5.6.0 → 5.6.1,
Kizuna erkannt (95 %), Titel-Lauf 2292 s, Laufwerk G am 02.09. mit
„2 Controllerfehler, 112 fehlerhafte Blöcke" (siehe F11 — ob das dein
BU40N war oder eine Platte, weiß der Bericht nicht).
* `makemkvcon64 -r --noscan info disc:9999` heute: nur 5042/5010 — ohne
Laufwerk keine Lizenzauskunft (F5).
Nichts davon hat etwas am Repo, an deinen Einstellungen oder am Register
verändert; die Sonden liegen im Sitzungs-Scratchpad.
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

+109
View File
@@ -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.');
+114
View File
@@ -0,0 +1,114 @@
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
//
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
// electron-updater ihn per spawn(…, { detached: true }) startet und
// `detached` unter Windows KEIN Breakaway setzt.
//
// Aufbau (drei Läufe, der äußere ruft die inneren):
// node beweise/leine-breakaway.js
// -> startet zweimal sich selbst als „innen":
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
// welcher Enkel noch lebt.
//
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
//
// Ausführen: node beweise/leine-breakaway.js
const { execFileSync, spawnSync } = require('node:child_process')
const path = require('node:path')
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
const KILL_ON_JOB_CLOSE = 0x2000
const BREAKAWAY_OK = 0x0800
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
const CREATE_NO_WINDOW = 0x08000000
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
)
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
)
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
const job = CreateJobObjectW(null, null)
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
AssignProcessToJobObject(job, GetCurrentProcess())
const si = Buffer.alloc(STARTUPINFO_GROESSE)
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
if (!ok) {
console.log(`PID=0 FEHLER=${GetLastError()}`)
return
}
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
}
function lebt(pid) {
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
return (ergebnis.stdout || '').includes(String(pid))
}
function aussen() {
const faelle = [
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
]
for (const [name, modus, erwartetLebend] of faelle) {
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
// zugeschnappt, falls sie zuschnappt.
const nochDa = pid > 0 ? lebt(pid) : false
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
console.log(
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
)
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
}
}
const modus = process.argv[2] ?? ''
if (modus === 'alt') innen(false, false)
else if (modus === 'erlaubt-normal') innen(true, false)
else if (modus === 'erlaubt-aus') innen(true, true)
else aussen()
+50
View File
@@ -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,375 @@
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
DRV:1,256,999,0,"","",""
DRV:2,256,999,0,"","",""
DRV:3,256,999,0,"","",""
DRV:4,256,999,0,"","",""
DRV:5,256,999,0,"","",""
DRV:6,256,999,0,"","",""
DRV:7,256,999,0,"","",""
DRV:8,256,999,0,"","",""
DRV:9,256,999,0,"","",""
DRV:10,256,999,0,"","",""
DRV:11,256,999,0,"","",""
DRV:12,256,999,0,"","",""
DRV:13,256,999,0,"","",""
DRV:14,256,999,0,"","",""
DRV:15,256,999,0,"","",""
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
TCOUNT:2
CINFO:1,6209,"Blu-ray-Disk"
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
CINFO:28,0,"eng"
CINFO:29,0,"English"
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
CINFO:33,0,"0"
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
TINFO:0,8,0,"6"
TINFO:0,9,0,"0:08:51"
TINFO:0,10,0,"1.1 GB"
TINFO:0,11,0,"1219086336"
TINFO:0,16,0,"00011.mpls"
TINFO:0,25,0,"6"
TINFO:0,26,0,"9,10,11,12,13,14"
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
TINFO:0,28,0,"eng"
TINFO:0,29,0,"English"
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
TINFO:0,33,0,"0"
SINFO:0,0,1,6201,"Video"
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
SINFO:0,0,6,0,"Mpeg4"
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
SINFO:0,0,19,0,"1920x1080"
SINFO:0,0,20,0,"16:9"
SINFO:0,0,21,0,"23.976 (24000/1001)"
SINFO:0,0,22,0,"0"
SINFO:0,0,28,0,"eng"
SINFO:0,0,29,0,"English"
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,0,33,0,"0"
SINFO:0,0,38,0,""
SINFO:0,0,42,5088,"( Lossless conversion )"
SINFO:0,1,1,6202,"Audio"
SINFO:0,1,2,5091,"Stereo"
SINFO:0,1,3,0,"deu"
SINFO:0,1,4,0,"German"
SINFO:0,1,5,0,"A_DTS"
SINFO:0,1,6,0,"DTS-HD MA"
SINFO:0,1,7,0,"DTS-HD Master Audio"
SINFO:0,1,14,0,"2"
SINFO:0,1,17,0,"48000"
SINFO:0,1,18,0,"16"
SINFO:0,1,22,0,"1024"
SINFO:0,1,28,0,"eng"
SINFO:0,1,29,0,"English"
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,1,33,0,"100"
SINFO:0,1,38,0,""
SINFO:0,1,40,0,"stereo"
SINFO:0,1,42,5088,"( Lossless conversion )"
SINFO:0,2,1,6202,"Audio"
SINFO:0,2,2,5091,"Stereo"
SINFO:0,2,3,0,"deu"
SINFO:0,2,4,0,"German"
SINFO:0,2,5,0,"A_DTS"
SINFO:0,2,6,0,"DTS"
SINFO:0,2,7,0,"DTS"
SINFO:0,2,13,0,"768 Kb/s"
SINFO:0,2,14,0,"2"
SINFO:0,2,17,0,"48000"
SINFO:0,2,22,0,"2304"
SINFO:0,2,28,0,"eng"
SINFO:0,2,29,0,"English"
SINFO:0,2,30,0,"DTS Stereo German"
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,2,33,0,"100"
SINFO:0,2,38,0,"d"
SINFO:0,2,39,0,"Default"
SINFO:0,2,40,0,"stereo"
SINFO:0,2,42,5088,"( Lossless conversion )"
SINFO:0,3,1,6202,"Audio"
SINFO:0,3,2,5091,"Stereo"
SINFO:0,3,3,0,"deu"
SINFO:0,3,4,0,"German"
SINFO:0,3,5,0,"A_DTS"
SINFO:0,3,6,0,"DTS-HD MA"
SINFO:0,3,7,0,"DTS-HD Master Audio"
SINFO:0,3,14,0,"2"
SINFO:0,3,17,0,"48000"
SINFO:0,3,18,0,"16"
SINFO:0,3,22,0,"1024"
SINFO:0,3,28,0,"eng"
SINFO:0,3,29,0,"English"
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,3,33,0,"100"
SINFO:0,3,38,0,""
SINFO:0,3,40,0,"stereo"
SINFO:0,3,42,5088,"( Lossless conversion )"
SINFO:0,4,1,6202,"Audio"
SINFO:0,4,2,5091,"Stereo"
SINFO:0,4,3,0,"deu"
SINFO:0,4,4,0,"German"
SINFO:0,4,5,0,"A_DTS"
SINFO:0,4,6,0,"DTS"
SINFO:0,4,7,0,"DTS"
SINFO:0,4,13,0,"768 Kb/s"
SINFO:0,4,14,0,"2"
SINFO:0,4,17,0,"48000"
SINFO:0,4,22,0,"2304"
SINFO:0,4,28,0,"eng"
SINFO:0,4,29,0,"English"
SINFO:0,4,30,0,"DTS Stereo German"
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,4,33,0,"100"
SINFO:0,4,38,0,""
SINFO:0,4,40,0,"stereo"
SINFO:0,4,42,5088,"( Lossless conversion )"
SINFO:0,5,1,6203,"Untertitel"
SINFO:0,5,3,0,"deu"
SINFO:0,5,4,0,"German"
SINFO:0,5,5,0,"S_HDMV/PGS"
SINFO:0,5,6,0,"PGS"
SINFO:0,5,7,0,"HDMV PGS Subtitles"
SINFO:0,5,22,0,"0"
SINFO:0,5,28,0,"eng"
SINFO:0,5,29,0,"English"
SINFO:0,5,30,0,"PGS German"
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,5,33,0,"100"
SINFO:0,5,38,0,""
SINFO:0,5,42,5088,"( Lossless conversion )"
SINFO:0,6,1,6203,"Untertitel"
SINFO:0,6,3,0,"deu"
SINFO:0,6,4,0,"German"
SINFO:0,6,5,0,"S_HDMV/PGS"
SINFO:0,6,6,0,"PGS"
SINFO:0,6,7,0,"HDMV PGS Subtitles"
SINFO:0,6,22,0,"6144"
SINFO:0,6,28,0,"eng"
SINFO:0,6,29,0,"English"
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,6,33,0,"100"
SINFO:0,6,38,0,"d"
SINFO:0,6,39,0,"Default"
SINFO:0,6,42,5088,"( Lossless conversion )"
SINFO:0,7,1,6203,"Untertitel"
SINFO:0,7,3,0,"deu"
SINFO:0,7,4,0,"German"
SINFO:0,7,5,0,"S_HDMV/PGS"
SINFO:0,7,6,0,"PGS"
SINFO:0,7,7,0,"HDMV PGS Subtitles"
SINFO:0,7,22,0,"0"
SINFO:0,7,28,0,"eng"
SINFO:0,7,29,0,"English"
SINFO:0,7,30,0,"PGS German"
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,7,33,0,"100"
SINFO:0,7,38,0,""
SINFO:0,7,42,5088,"( Lossless conversion )"
SINFO:0,8,1,6203,"Untertitel"
SINFO:0,8,3,0,"deu"
SINFO:0,8,4,0,"German"
SINFO:0,8,5,0,"S_HDMV/PGS"
SINFO:0,8,6,0,"PGS"
SINFO:0,8,7,0,"HDMV PGS Subtitles"
SINFO:0,8,22,0,"6144"
SINFO:0,8,28,0,"eng"
SINFO:0,8,29,0,"English"
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,8,33,0,"100"
SINFO:0,8,38,0,""
SINFO:0,8,42,5088,"( Lossless conversion )"
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
TINFO:1,8,0,"13"
TINFO:1,9,0,"1:34:45"
TINFO:1,10,0,"16.4 GB"
TINFO:1,11,0,"17668571136"
TINFO:1,16,0,"00003.mpls"
TINFO:1,25,0,"1"
TINFO:1,26,0,"7"
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
TINFO:1,28,0,"eng"
TINFO:1,29,0,"English"
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
TINFO:1,33,0,"0"
SINFO:1,0,1,6201,"Video"
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
SINFO:1,0,6,0,"Mpeg4"
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
SINFO:1,0,19,0,"1920x1080"
SINFO:1,0,20,0,"16:9"
SINFO:1,0,21,0,"23.976 (24000/1001)"
SINFO:1,0,22,0,"0"
SINFO:1,0,28,0,"eng"
SINFO:1,0,29,0,"English"
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,0,33,0,"0"
SINFO:1,0,38,0,""
SINFO:1,0,42,5088,"( Lossless conversion )"
SINFO:1,1,1,6202,"Audio"
SINFO:1,1,2,0,"Surround 5.1"
SINFO:1,1,3,0,"deu"
SINFO:1,1,4,0,"German"
SINFO:1,1,5,0,"A_DTS"
SINFO:1,1,6,0,"DTS-HD MA"
SINFO:1,1,7,0,"DTS-HD Master Audio"
SINFO:1,1,14,0,"6"
SINFO:1,1,17,0,"48000"
SINFO:1,1,18,0,"16"
SINFO:1,1,22,0,"1024"
SINFO:1,1,28,0,"eng"
SINFO:1,1,29,0,"English"
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,1,33,0,"100"
SINFO:1,1,38,0,""
SINFO:1,1,40,0,"5.1(side)"
SINFO:1,1,42,5088,"( Lossless conversion )"
SINFO:1,2,1,6202,"Audio"
SINFO:1,2,2,0,"Surround 5.1"
SINFO:1,2,3,0,"deu"
SINFO:1,2,4,0,"German"
SINFO:1,2,5,0,"A_DTS"
SINFO:1,2,6,0,"DTS"
SINFO:1,2,7,0,"DTS"
SINFO:1,2,13,0,"1.5 Mb/s"
SINFO:1,2,14,0,"6"
SINFO:1,2,17,0,"48000"
SINFO:1,2,22,0,"2304"
SINFO:1,2,28,0,"eng"
SINFO:1,2,29,0,"English"
SINFO:1,2,30,0,"DTS Surround 5.1 German"
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,2,33,0,"100"
SINFO:1,2,38,0,"d"
SINFO:1,2,39,0,"Default"
SINFO:1,2,40,0,"5.1(side)"
SINFO:1,2,42,5088,"( Lossless conversion )"
SINFO:1,3,1,6202,"Audio"
SINFO:1,3,2,5091,"Stereo"
SINFO:1,3,3,0,"jpn"
SINFO:1,3,4,0,"Japanese"
SINFO:1,3,5,0,"A_DTS"
SINFO:1,3,6,0,"DTS-HD MA"
SINFO:1,3,7,0,"DTS-HD Master Audio"
SINFO:1,3,14,0,"2"
SINFO:1,3,17,0,"48000"
SINFO:1,3,18,0,"16"
SINFO:1,3,22,0,"1024"
SINFO:1,3,28,0,"eng"
SINFO:1,3,29,0,"English"
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,3,33,0,"100"
SINFO:1,3,38,0,""
SINFO:1,3,40,0,"stereo"
SINFO:1,3,42,5088,"( Lossless conversion )"
SINFO:1,4,1,6202,"Audio"
SINFO:1,4,2,5091,"Stereo"
SINFO:1,4,3,0,"jpn"
SINFO:1,4,4,0,"Japanese"
SINFO:1,4,5,0,"A_DTS"
SINFO:1,4,6,0,"DTS"
SINFO:1,4,7,0,"DTS"
SINFO:1,4,13,0,"768 Kb/s"
SINFO:1,4,14,0,"2"
SINFO:1,4,17,0,"48000"
SINFO:1,4,22,0,"2304"
SINFO:1,4,28,0,"eng"
SINFO:1,4,29,0,"English"
SINFO:1,4,30,0,"DTS Stereo Japanese"
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,4,33,0,"100"
SINFO:1,4,38,0,""
SINFO:1,4,40,0,"stereo"
SINFO:1,4,42,5088,"( Lossless conversion )"
SINFO:1,5,1,6203,"Untertitel"
SINFO:1,5,3,0,"deu"
SINFO:1,5,4,0,"German"
SINFO:1,5,5,0,"S_HDMV/PGS"
SINFO:1,5,6,0,"PGS"
SINFO:1,5,7,0,"HDMV PGS Subtitles"
SINFO:1,5,22,0,"0"
SINFO:1,5,28,0,"eng"
SINFO:1,5,29,0,"English"
SINFO:1,5,30,0,"PGS German"
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,5,33,0,"100"
SINFO:1,5,38,0,""
SINFO:1,5,42,5088,"( Lossless conversion )"
SINFO:1,6,1,6203,"Untertitel"
SINFO:1,6,3,0,"deu"
SINFO:1,6,4,0,"German"
SINFO:1,6,5,0,"S_HDMV/PGS"
SINFO:1,6,6,0,"PGS"
SINFO:1,6,7,0,"HDMV PGS Subtitles"
SINFO:1,6,22,0,"6144"
SINFO:1,6,28,0,"eng"
SINFO:1,6,29,0,"English"
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,6,33,0,"100"
SINFO:1,6,38,0,"d"
SINFO:1,6,39,0,"Default"
SINFO:1,6,42,5088,"( Lossless conversion )"
SINFO:1,7,1,6203,"Untertitel"
SINFO:1,7,3,0,"deu"
SINFO:1,7,4,0,"German"
SINFO:1,7,5,0,"S_HDMV/PGS"
SINFO:1,7,6,0,"PGS"
SINFO:1,7,7,0,"HDMV PGS Subtitles"
SINFO:1,7,22,0,"0"
SINFO:1,7,28,0,"eng"
SINFO:1,7,29,0,"English"
SINFO:1,7,30,0,"PGS German"
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,7,33,0,"100"
SINFO:1,7,38,0,""
SINFO:1,7,42,5088,"( Lossless conversion )"
SINFO:1,8,1,6203,"Untertitel"
SINFO:1,8,3,0,"deu"
SINFO:1,8,4,0,"German"
SINFO:1,8,5,0,"S_HDMV/PGS"
SINFO:1,8,6,0,"PGS"
SINFO:1,8,7,0,"HDMV PGS Subtitles"
SINFO:1,8,22,0,"6144"
SINFO:1,8,28,0,"eng"
SINFO:1,8,29,0,"English"
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,8,33,0,"100"
SINFO:1,8,38,0,""
SINFO:1,8,42,5088,"( Lossless conversion )"
+12
View File
@@ -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 });
}
+20
View File
@@ -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: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

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

+13 -5
View File
@@ -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.")
+11
View File
@@ -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/
+114
View File
@@ -0,0 +1,114 @@
# 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). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
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 57) und
Vitest 4 (verträgt 68), 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

+384
View File
@@ -0,0 +1,384 @@
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
// Ablage → Roh-Dateien → löschen.
//
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
// (Doku »Electron« bei playwright.dev, _electron.launch).
//
// npm run klick (baut vorher)
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
import { _electron as electron } from 'playwright'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage')
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
const arbeit = join(profil, 'arbeit')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
{
const db = new DatabaseSync(join(profil, 'rippy.db'))
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
setzen.run('setup.done', 'true')
setzen.run('ablage', ablage)
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
setzen.run('audioSprachen', 'deu,eng')
setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false')
setzen.run('arbeitsordner', arbeit)
// Die Automatik bleibt aus: Der Beweis klickt selbst.
setzen.run('automatik', 'aus')
db.close()
}
const start = (env) =>
electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
timeout: 60_000,
})
let app = await start({})
async function fenster() {
const s = await app.firstWindow()
s.setDefaultTimeout(30_000)
s.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
return s
}
let seite = await fenster()
let nr = 0
async function bild(name) {
nr += 1
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
await seite.screenshot({ path: pfad, fullPage: true })
console.log(`KLICK bild: ${pfad}`)
}
async function schritt(name, tun) {
try {
await tun()
console.log(`KLICK ✓ ${name}`)
} catch (fehler) {
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
await app.close().catch(() => {})
process.exit(2)
}
}
const dialog = () => seite.locator('div.fixed.inset-0').last()
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
await seite.getByText('LAUFWERK Q:').waitFor()
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
})
await bild('uebersicht')
await schritt('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
await seite.getByText('SPEICHER UND BILANZ').waitFor()
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
await seite.getByText('ABLAGE (SERVER)').waitFor()
await seite.getByText(/^FREI [0-9]/).waitFor()
})
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
})
await schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor()
})
await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 0', { exact: true }).waitFor()
await dialog().getByText('Titel 1', { exact: true }).waitFor()
await dialog().getByText('Titel 2', { exact: true }).waitFor()
})
await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
})
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
})
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
await dialog().getByText(/2 von 3 gewählt/).waitFor()
})
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('hauptfilm')
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
await rollen.nth(1).selectOption('extra')
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
const vorwahl = dialog().locator('select').nth(2)
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
await vorwahl.selectOption('5')
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
})
await bild('dialog-vorwahl')
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge')
await rollen.nth(1).selectOption('folge')
await dialog().getByText('AB FOLGE').waitFor()
await dialog().getByPlaceholder('automatisch').fill('3')
})
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Serien\\/).waitFor()
await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).waitFor()
})
await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
})
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
})
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
})
await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 })
})
await bild('fertig')
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
const serien = join(ablage, 'Serien')
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
const gefunden = []
const gehen = (o) => {
for (const e of readdirSync(o, { withFileTypes: true })) {
const p = join(o, e.name)
if (e.isDirectory()) gehen(p)
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
}
}
gehen(serien)
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
throw new Error(`gefunden: ${namen.join(', ')}`)
}
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
})
await schritt('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
const roh = join(arbeit, 'roh')
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
const fertig = join(arbeit, 'fertig')
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
const rohInAblage = join(ablage, 'roh')
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
})
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
await seite.getByText('VERLAUF', { exact: true }).waitFor()
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
})
await schritt('Bilanz zählt eine Disc', async () => {
await seite.getByText('DISCS', { exact: true }).waitFor()
await seite.getByText('1 fertig', { exact: true }).waitFor()
})
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor()
await seite.getByText(/Belegung jetzt/).waitFor()
await seite.getByText(/Roh: \d/).first().waitFor()
})
await bild('roh-dateien')
await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
await seite.locator('#kompression').locator('select').first().waitFor()
})
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.locator('#allgemein').waitFor()
await seite.locator('#update').waitFor()
await seite.locator('#automatik').waitFor()
await seite.locator('#benachrichtigung').waitFor()
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
})
await bild('einstellungen')
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen …' }).first().click()
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
await seite.locator('html[data-design="hell"]').waitFor()
})
await bild('hell-modus')
await schritt('zurück in den Kinosaal (dunkel)', async () => {
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
await seite.locator('html[data-design="dunkel"]').waitFor()
})
await schritt('5.7.0: „Titel lesen — ohne Lebenszeichen höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — ohne Lebenszeichen höchstens')
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
await regler.selectOption('30')
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
// zeigt das (kontrollierte) Feld 30.
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
})
await bild('ereignisse')
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
await app.close().catch(() => {})
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
seite = await fenster()
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
await seite.getByText('Willkommen bei Rippy').waitFor()
const spalten = await seite.locator('.poster-spalte').count()
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
})
await bild('einrichtung-willkommen')
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
await weiter()
await seite.getByText(/MakeMKV gefunden/).waitFor()
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
})
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
await weiter()
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.getByText(/frei von/).first().waitFor()
})
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
await weiter()
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
await seite.getByText('4K-UHD', { exact: true }).waitFor()
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
})
await bild('einrichtung-qualitaet')
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
await weiter()
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
await weiter()
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
await weiter()
await seite.getByText('Countdown', { exact: true }).waitFor()
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
})
await schritt('Fertig: der Knopf Los gehts', async () => {
await weiter()
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
})
await bild('einrichtung-fertig')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
+103
View File
@@ -0,0 +1,103 @@
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
// HandBrake 1.11 gemessen haben:
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert;
// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das
// Binary sie formatiert (5.6.0, Restzeit).
const fs = require('node:fs')
const args = process.argv.slice(2)
const out = (zeile) => process.stdout.write(zeile + '\n')
const wert = (schalter) => {
const i = args.indexOf(schalter)
return i >= 0 ? args[i + 1] : undefined
}
if (args.includes('--help') || args.includes('-h')) {
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
out('')
out('Video Options -----------------------------------------------------------------')
out('')
out(' -e, --encoder <string> Select video encoder:')
out(' x264')
out(' x265')
out(' nvenc_h264')
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
out('')
process.exit(0)
}
if (args.includes('--preset-list')) {
out('General/')
out(' Very Fast 1080p30')
out(' Fast 1080p30')
out(' HQ 1080p30 Surround')
out('')
process.exit(0)
}
if (args.includes('--scan')) {
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 1, 100.00 %')
out('JSON Title Set: {')
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
// 5.7.0: CodecName und ChannelCount wie das echte 1.11.2 (gemessen
// 12.09.2026) — dieselben Spuren, die fake-makemkvcon meldet, ohne den
// DTS-Kern (den nimmt MakeMKVs Regel nicht mit).
out('"AudioList": [')
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },')
out('{ "ChannelCount": 2, "ChannelLayout": 3, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 3 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }')
out(']')
out('}')
out(']')
out('}')
out('HandBrake has exited.')
process.exit(0)
}
const eingabe = wert('--input') ?? wert('-i')
const ausgabe = wert('--output') ?? wert('-o')
if (eingabe === undefined || ausgabe === undefined) {
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
process.exit(1)
}
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0
const stufen = [5, 30, 55, 80, 100]
const tick = () => {
const prozent = stufen[schritt].toFixed(2)
if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`)
else {
// Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)"
const rest = (stufen.length - 1 - schritt) * 300
out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`)
}
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 500)
return
}
fs.copyFileSync(eingabe, ausgabe)
out('[12:00:05] libhb: work result = 0')
out('')
out('Encode done!')
out('HandBrake has exited.')
process.exit(0)
}
setTimeout(tick, 200)
@@ -0,0 +1,88 @@
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
//
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
//
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
const fs = require('node:fs')
const path = require('node:path')
const args = process.argv.slice(2)
const befehl = args.find((a) => a === 'info' || a === 'mkv')
const out = (zeile) => process.stdout.write(zeile + '\n')
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
const TITEL = [
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
]
if (befehl === 'info') {
out(`TCOUNT:${TITEL.length}`)
for (const t of TITEL) {
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
out(`SINFO:${t.nr},${nr},22,0,"${flags}"`)
out(`SINFO:${t.nr},${nr},30,0,"${text}"`)
}
spur(1, 6202, 'Audio', 'jpn', 'Japanese', 'DTS-HD MA', '6', 'Surround 5.1', 1024, 'DTS-HD MA Surround 5.1 Japanese')
spur(2, 6202, 'Audio', 'jpn', 'Japanese', 'DTS', '6', 'Surround 5.1', 2304, 'DTS Surround 5.1 Japanese')
spur(3, 6202, 'Audio', 'deu', 'German', 'DD', '6', 'Surround 5.1', 0, 'DD Surround 5.1 German')
spur(4, 6202, 'Audio', 'deu', 'German', 'DD', '2', 'Stereo', 0, 'DD Stereo German (Kommentar)')
spur(5, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 0, 'PGS German')
spur(6, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 6144, 'PGS German (nur erzwungene)')
spur(7, 6203, 'Untertitel', 'eng', 'English', 'PGS', '', '', 0, 'PGS English')
}
}
process.exit(0)
}
if (befehl === 'mkv') {
const i = args.indexOf('mkv')
const nr = Number(args[i + 2])
const ziel = args[i + 3]
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
out('MSG:5010,0,0,"Falscher Aufruf"')
process.exit(1)
}
let schritt = 0
const stufen = [10, 35, 60, 85, 100]
const tick = () => {
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 250)
return
}
fs.mkdirSync(ziel, { recursive: true })
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
process.exit(0)
}
setTimeout(tick, 200)
} else {
out('MSG:5010,0,0,"Unbekannter Befehl"')
process.exit(1)
}
@@ -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.
+35
View File
@@ -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.
+266
View File
@@ -0,0 +1,266 @@
Version 5.7.1
Titel lesen: Rippy wartet, solange MakeMKV arbeitet.
"Der Patriot" (DVD mit beschaedigter IFO-Datei) scheiterte in 5.7.0 nach
15 Minuten, waehrend das MakeMKV-Programm die Disc fertig las: MakeMKV
liest bei so einer Disc die ganze VOB-Datei durch, und das dauert so lange
wie das Lesen der Disc. Die Grenze aus den Einstellungen gilt jetzt fuer
Stille - solange MakeMKV Fortschritt oder Meldungen schickt, wartet Rippy
(kuendigt MakeMKV den VOB-Scan an, bis zu vier Stunden). Der Dialog zeigt
dabei, was MakeMKV gerade tut: Schritt, Prozent und letzte Meldung.
"Abbrechen" beendet den Lauf jederzeit. Bricht Rippy doch ab, stehen die
letzten Zeilen von MakeMKV im Protokoll.
Version 5.7.0
Der Beta-Schluessel kommt ganz an.
Der aktuelle MakeMKV-Beta-Schluessel enthaelt ein "@" - Rippy nahm nur
62 der 68 Zeichen, MakeMKV lief danach ohne Schluessel, und Blu-ray-Rips
scheiterten nach der Gnadenfrist. Das ist die wahrscheinlichste Ursache
fuer "Der Patriot", "Deja Vu" und "Training Day" auf dem anderen PC. Die
Schluessel-Karte sagt jetzt ehrlich, was Sache ist: gruen (angenommen),
grau (nicht pruefbar - ohne eingelegte Disc gibt MakeMKV keine Auskunft),
rot (abgelehnt - Blu-rays laufen dann nur noch in der Gnadenfrist). Ein
vorhandener, angenommener Schluessel wird nie ueberschrieben.
Jede Spur einzeln anhaken.
Der Auswahl-Dialog zeigt je Titel jede Tonspur mit Codec und Kanaelen
("German - DTS-HD MA - Surround 5.1", "German - DD - Stereo (Kommentar)")
und jede Untertitel-Spur ("German - PGS", "German - PGS (nur
erzwungene)"). Vorbelegt wie bisher - je Wunschsprache die erste Tonspur,
alle Untertitel der Wunschsprachen - aber angehakt wird einzeln. Die
Untertitel-Vorwahl bei Extras ist jetzt eine Spur. MakeMKV nimmt dafuer
auch Stereo-Fassungen und Kommentare in die Roh-Datei (nur die
abgeleiteten DTS-Kerne nicht). Was in der Roh-Datei fehlt, steht im
Hinweis.
Ton bleibt verlustfrei.
HandBrake hat seit 5.0 jede Tonspur in AAC umgerechnet - die Kopiermaske
war nicht ausdruecklich gesetzt, und die Kommandozeile verengt sie dann
auf AAC. Jetzt werden AC3, E-AC3, DTS, DTS-HD, TrueHD, FLAC, MP3 und AAC
durchgereicht. Filme, bei denen der HD-Ton wichtig ist und deren
Roh-Dateien schon weg sind, noch einmal rippen.
Titel lesen bis 15 Minuten - einstellbar.
Einstellungen -> Allgemein -> "Titel lesen - hoechstens": 5 bis 60
Minuten, Vorgabe 15 (vorher fest 5). Ab der zweiten Minute steht im
Dialog, wie lange MakeMKV schon liest; "Abbrechen" beendet den Lauf auch
im Kern, das Laufwerk wird frei. Laeuft die Zeit ab, nennt die Meldung
Kopierschutz und viele Playlisten als moegliche Gruende.
4K-UHD nach der Leistung des PCs.
Ist eine Grafikkarte mit 4K-Preset da (VCN, NVENC, QSV), wird 4K damit
komprimiert (8 Bit); nur mit CPU bleibt der Rip verlustfrei, statt
tagelang auf 1080p zu rechnen. Eigene Wahl schlaegt beides. AV1 10 Bit
steht als eigene Wahl in der Liste (nur fuer AV1-faehige Abspielgeraete).
Disc-Typ aus dem Dateisystem.
BDMV/index.bdmv mit "INDX0300" ist eine 4K-UHD, "0200"/"0100" eine
Blu-ray, VIDEO_TS eine DVD - auch wenn der Windows-Steuercode "unbekannt"
sagt. Vorher wurde eine 4K-Disc mit 48 GB als Blu-ray behandelt
(falsches Preset) und "unbekannt" direkt zum Rettungs-Abbild.
Kein stilles Scheitern mehr.
Der Rip-Start prueft, ob die Ablage schreibbar ist, und sagt es im
Fenster, wenn nicht ("NAS verbunden?"); Fehler im Kern landen im
Protokoll und im Fenster; nach einem Kern-Neustart bleibt das Fenster
nicht mehr taub; jedes Laufwerk hat genau eine Wache; das Rettungs-Abbild
ueberlebt das Aufraeumen; ein Titel-Auftrag bei Typ "unbekannt" rippt
statt ein ISO zu sichern.
Serien ueber mehrere Discs.
Disc 2 benennt nur ihre eigenen Folgen (S01E03, S01E04) und ueberschreibt
nie, was Disc 1 abgelegt hat; eine vorhandene Datei gleichen Namens
bekommt "(2)". HandBrake schreibt erst auf einen Zwischennamen - eine
halbe Datei liegt nie unter dem endgueltigen Namen in der Ablage.
Erkennung: Originaltitel und Akzente.
"THE_PATRIOT" trifft "Der Patriot", "DEJA_VU" trifft "Deja Vu" - TMDb
antwortet auf Deutsch, die Disc traegt den Originaltitel; verglichen
werden jetzt beide, Akzente zaehlen nicht.
MakeMKV-Installer nur nach Rueckfrage.
Ist MakeMKV nicht da und die Herstellerseite nicht erreichbar, fragt
Rippy, bevor eine Kopie aus dem Internet Archive geladen wird - mit
Groesse und SHA-256 im Bericht.
Dazu: Tray "Beenden" fragt nach, wenn ein Rip oder eine Kompression
laeuft; "Roh-Dateien loeschen" und "Ordner loeschen" fragen im Fenster
nach; Eingabefelder speichern auch bei Enter und beim Bereichswechsel;
Escape schliesst Dialoge; "Disc erkannt" einmal je Disc statt dreimal;
Laufwerks-Gesundheit zaehlt nur das eigene Laufwerk, nie eine Festplatte;
der Kompressions-Fortschritt belastet NAS und Fenster nicht mehr jede
Sekunde; Zeiten in Ortszeit; NFO mit TMDb/IMDb-Kennung; doppelter
Zielordner heisst "(2)"; Umlaute in drei Knopftexten; "Neu komprimieren"
nur, wo es Roh-Dateien gibt; die Automatik sagt bei Audio-CD und
unbekannter Disc, warum sie nichts tut; Audio-CDs lassen sich mitten in
der Spur abbrechen.
Version 5.6.1
Untertitel-Spuren werden wieder erkannt.
MakeMKV schreibt den Typ eines Streams in seiner Anzeigesprache - auf einem
deutsch eingestellten MakeMKV "Untertitel" statt "Subtitles". Rippy prueft
seit 5.4.0 auf das englische Wort und fand deshalb keine Untertitel mehr
(Ton heisst in beiden Sprachen "Audio", darum fiel es dort nicht auf).
Jetzt zaehlt der Typ-Code (6202 Ton, 6203 Untertitel), der ist
sprachunabhaengig. Gemessen an der Blu-ray "Digimon Adventure: Last
Evolution Kizuna": drei deutsche PGS-Spuren, darunter eine nur fuer
erzwungene Untertitel.
Version 5.6.0
Mehrere Filme auf einer Disc.
Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche
(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen
eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der
Verlauf zeigt beide.
Ordner und Dateien tragen den Filmnamen.
Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit
Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988)
- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner
extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie
benennt; unerkannte Discs behalten den Zeitstempel-Namen.
Untertitel-Vorwahl je Extra.
Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest,
welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur,
nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton ->
Wunschsprache) bleibt die Vorgabe.
Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung".
Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen,
Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor).
Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der
aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet,
danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch
~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur
noch eine Zeile, wo es weitergeht.
Hell-Modus.
Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal),
Hell (Papier und Gold) oder wie Windows.
Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim
Scrollen stehen.
Version 5.5.0
Uebersicht und Einstellungen neu sortiert.
Die Bibliothek ist weg (Jellyfin IST die Bibliothek). Dafuer zeigt die
Uebersicht den Verlauf der letzten Vorgaenge, den freien Platz auf
Arbeitsordner und Ablage samt Bilanz (Discs, gesicherte GB, Dauer je Disc)
und eine Fussleiste mit Kern, Datenbank, Werkzeugen, Schluessel, freiem
Platz und Update-Stand. Der MakeMKV-Kopf ist in die Einstellungen gezogen.
Dort gibt es eigene Karten fuer Automatik, Benachrichtigung, Allgemein und
Update; "Allgemeines Preset" ist weg, Ton- und Untertitelsprachen sind
Auswahl-Chips mit Dropdown in Wunschreihenfolge; Werkzeuge haben
"Durchsuchen ...".
Arbeitsordner lokal, Ablage auf dem Server.
Roh-Dateien und Kompression liegen im Arbeitsordner (lokale Platte), nur
das fertige Ergebnis wandert in die Ablage. Vor jedem Rip prueft Rippy den
Platz: zu wenig fuer den verlustfreien Rip heisst kein Start, knapp heisst
Hinweis.
Automatik mit Countdown.
Ist Rippy sicher (ab 90 %) und die Disc noch nicht gerippt, liest es die
Titel und startet nach 30 s (einstellbar) mit seinem Vorschlag - mit
"Stopp" und "Jetzt" in der Kachel. Bei Unsicherheit wartet es und sagt
warum.
Serien-Merker ueber Discs hinweg.
Disc 1 endet mit Folge 4, Disc 2 zaehlt ab 5 weiter - der Dialog schlaegt
es vor, aenderbar.
Rettungs-Abbild fuer defekte Discs.
Hat Rippy Lesefehler gesehen (Defekt-Waechter, Lesefehler-Meldung), bietet
die Kachel "Rettungs-Abbild" an: Rippys eigener Sektor-Leser liest die Disc
Stueck fuer Stueck, wiederholt in immer kleineren Stuecken und fuellt nur
das wirklich Unlesbare mit Nullen; danach laeuft derselbe Rip aus dem
Abbild, an dem MakeMKV nicht mehr haengen bleibt. Ein Bericht daneben
nennt jede genullte Stelle. Bei einer heilen Disc passiert das nie.
Warteschlange pausieren, Benachrichtigung, Protokoll.
"Pausieren" laesst den laufenden Film fertig rechnen und haelt die Schlange
an. Discord-Webhook oder Telegram-Bot melden fertige Rips, Kompressionen
und Fehler aufs Handy (Testknopf). Haupt und Kern schreiben eine
Protokoll-Datei je Tag (14 Tage), "Protokoll oeffnen" unter Allgemein;
Ordner oeffnen sich aus Verlauf, Roh-Dateien und Speicher-Karte im Explorer.
Erst-Einrichtung mit Poster-Kulisse.
Acht Schritte mit denselben Bausteinen wie die Einstellungen (Ordner,
Qualitaet mit Sprachen, Titel, Meldungen, Automatik) vor einem langsam
laufenden Poster-Raster - Platzhalter bis zum TMDb-Schluessel, danach
echte Poster.
Dazu: Das Fenster oeffnet mit 80 % des Bildschirms, mittig, mindestens
1100 x 720, und merkt sich Lage und Groesse (nur, wenn sie noch auf einem
Bildschirm liegt). Oberflaeche und Kern sind in Bereiche aufgeteilt.
Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
Roh-Dateien raeumen sich auf.
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
Sprachen je Titel.
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
dass alle Sprachen mitkommen.
Staffel und Folge von Hand.
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
Erkennung v2 und Kino-Banner.
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
Hardware?
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
ganzen Weg durch das echte Programm.
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
Version 5.3.2
Die Titelliste bleibt jetzt stehen.
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
auf derselben Disc gegenseitig aus.
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

+42
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
// 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'. font-src data: (5.4.0, im Klick-Beweis
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
// als data:-URI ein, und die blockte die CSP — als Fehler in der
// Konsole, unsichtbar im Fenster.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
)
},
}
}
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') },
},
},
},
})
+7403
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.7.1",
"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",
"klick": "electron-vite build && node bau/klick-smoke.mjs",
"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",
"playwright": "^1.62.1",
"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"
}
}
+86
View File
@@ -0,0 +1,86 @@
// 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
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
//
// 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>
},
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
dateiWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
},
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
ordnerOeffnen(pfad: string): Promise<string> {
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
},
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
protokollOeffnen(): Promise<string> {
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
},
/** 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>
},
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
* schliessen" versteckt nur Rippy lebt im Tray weiter. */
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
},
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
updatePruefen(): Promise<unknown> {
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
},
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
},
})
+196
View File
@@ -0,0 +1,196 @@
// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026,
// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz,
// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die
// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit
// großer Prozentzahl, Mono-Protokoll, LED-Fußleiste.
//
// Seit 5.5.0 ist diese Datei nur noch der RAHMEN (Separation of Concerns):
// Kopfzeile, Bereichswahl, Fußleiste — die Bereiche selbst liegen in
// uebersicht/, einstellungen/, einrichtung/, die Dialoge in dialoge/, das
// Gemeinsame in bausteine.tsx. Die Regeln bleiben: EINE Einstellungs-Quelle
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 (Fehler
// stehen im Fenster — bei Systemfehlern als rote Karte).
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../gemeinsam/nachrichten'
import {
FensterKnopf,
IconDisc,
IconMaximieren,
IconMinimieren,
IconSchliessen,
IconWiederherstellen,
LAUFENDE_PHASEN,
gbKurz,
} from './bausteine'
import { ErstEinrichtung } from './einrichtung/ErstEinrichtung'
import { Einstellungen } from './einstellungen/Einstellungen'
import { LEERE_VERBINDUNG, aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
import { SEHR_WENIG_FREI_BYTES, WENIG_FREI_BYTES } from './uebersicht/SpeicherKarte'
import { Uebersicht } from './uebersicht/Uebersicht'
type Bereich = 'uebersicht' | 'einstellungen'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return typeof wert === 'object' && wert !== null && typeof (wert as Record<string, unknown>).version === 'string'
}
/** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext
* statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */
function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) {
const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25'
return (
<span className="inline-flex items-center gap-1.5" title={titel}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${farbe}`} />
{text}
</span>
)
}
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
// #einstellungen als Startanker — der Smoke-Beweis fotografiert damit
// auch die anderen Bereiche. `start` springt in den Einstellungen zu
// einer Karte („alle unter Roh-Dateien →").
const [bereich, setBereich] = useState<{ name: Bereich; start?: string }>(() => {
const anker = window.location.hash.replace('#', '')
return { name: anker === 'einstellungen' ? 'einstellungen' : 'uebersicht' }
})
const [verbindung, setVerbindung] = useState<Verbindung>(LEERE_VERBINDUNG)
useEffect(() => {
window.rippy.aufStatus((status) => {
if (istHauptStatus(status)) setHaupt(status)
})
verbindungStarten()
return aufVerbindung(setVerbindung)
}, [])
// 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf
// <html> (stil.css überschreibt dann die Farb-Tokens); „system" folgt
// Windows.
const design = verbindung.einstellungen['design'] ?? 'dunkel'
useEffect(() => {
const anwenden = (): void => {
const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel'
}
anwenden()
if (design !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: light)')
mq.addEventListener('change', anwenden)
return () => mq.removeEventListener('change', anwenden)
}, [design])
const db = verbindung.kern?.datenbank ?? null
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
// R4: Systemfehler stehen als rote Karte in der Übersicht — die
// LED-Leiste unten zeigt nur den Daumenwert.
const systemFehler: string[] = []
if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`)
if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`)
if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.')
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH).
const setupOffen =
window.location.hash === '#einrichtung' || (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
<button
onClick={() => setBereich({ name: ziel })}
className={`rounded-full px-4 py-1.5 text-[13px] ${
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
}`}
>
{text}
</button>
)
// ── Fußleiste (5.5.0): der Daumenwert des Systems in sechs LEDs ─────
const w = verbindung.werkzeuge
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
const s = verbindung.schluessel
// 5.7.0 (F1/F5): abgelehnt oder fehlend = ROT (Blu-rays laufen nicht),
// nicht prüfbar (kein Laufwerk) = grau — nie mehr ein gelbes „läuft".
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.angenommen === null ? 'grau' : 'rot'
const arbeit = verbindung.speicher?.arbeit ?? null
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
return (
<div className="relative flex min-h-screen flex-col bg-buehne text-schnee">
<div className="buehnenlicht pointer-events-none fixed inset-0" />
<div className="filmkorn pointer-events-none fixed inset-0" />
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
{/* 5.6.0: Die Kopfzeile bleibt beim Scrollen stehen (Commander)
sticky statt relative, mit Bühnen-Hintergrund und Weichzeichner. */}
<header className="ziehbar sticky top-0 z-40 flex-none border-b border-tinte/8 bg-buehne/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
<div className="flex min-w-0 items-center gap-3">
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
<span className="hidden rounded-md border border-tinte/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
{einRipLaeuft && (
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
REC
</span>
)}
</div>
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
{knopf('uebersicht', 'Übersicht')}
{knopf('einstellungen', 'Einstellungen')}
</nav>
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
<IconMinimieren />
</FensterKnopf>
<FensterKnopf titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'} aufKlick={() => void window.rippy.fensterSteuern('maximieren')}>
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
</FensterKnopf>
{/* Schließen versteckt nur Rippy lebt im Tray weiter, ein
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
<FensterKnopf titel="In den Infobereich (Rippy läuft weiter)" rot aufKlick={() => void window.rippy.fensterSteuern('schliessen')}>
<IconSchliessen />
</FensterKnopf>
</div>
</div>
</header>
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
{!setupOffen && bereich.name === 'uebersicht' && (
<Uebersicht verbindung={verbindung} haupt={haupt} systemFehler={systemFehler} aufRohDateien={() => setBereich({ name: 'einstellungen', start: 'roh' })} />
)}
{!setupOffen && bereich.name === 'einstellungen' && <Einstellungen verbindung={verbindung} haupt={haupt} start={bereich.start} />}
</main>
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-tinte/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
<Led ton={verbindung.kern !== null ? 'gruen' : haupt?.kern.laeuft === true ? 'warn' : 'rot'} text="KERN" titel={verbindung.kern !== null ? `läuft (PID ${verbindung.kern.pid})` : 'wartet'} />
<Led ton={db === null ? 'grau' : db.ok ? 'gruen' : 'rot'} text="DATENBANK" titel={db !== null && db.ok ? db.pfad : db?.fehler} />
<Led
ton={werkzeugTon}
text="WERKZEUGE"
titel={w === null ? 'werden gesucht' : `makemkvcon ${w.makemkvcon.length > 0 ? 'da' : 'FEHLT'} · HandBrake ${w.handbrake.length > 0 ? 'da' : 'fehlt'}`}
/>
<Led ton={schluesselTon} text="SCHLÜSSEL" titel={s?.text} />
<Led
ton={speicherTon}
text={arbeit !== null && arbeit.ok ? `FREI ${gbKurz(arbeit.freiBytes)}` : 'SPEICHER'}
titel={arbeit === null ? 'wird gemessen' : arbeit.ok ? `${verbindung.speicher?.getrennt === true ? 'Arbeitsordner' : 'Ablage'}: ${arbeit.pfad}` : arbeit.fehler}
/>
<Led
ton={updateTon}
text={haupt !== null && haupt.update.neueVersion.length > 0 ? `UPDATE ${haupt.update.neueVersion}` : `v${haupt?.version ?? '…'}${haupt !== null && haupt.update.letzteErfolgreichePruefung.length > 0 ? ' AKTUELL' : ''}`}
titel={haupt?.update.text}
/>
<span className="ml-auto">Rippy v5 · LucyAI · Claude · KrBrZ</span>
</footer>
</div>
)
}
+514
View File
@@ -0,0 +1,514 @@
// Die Bausteine der Oberfläche (5.5.0, Separation of Concerns): alles,
// was mehr als ein Bereich braucht — Formatierer, Symbole, Badge, Kacheln,
// Segment-Anzeige, Schalter, Felder, Karten. Der Kino-Mix (Design-Entscheid
// des Commanders, 31.08.2026) lebt in den Tokens von stil.css; hier sind
// nur die Klassen, die ihn tragen.
import { useEffect, useRef, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, RipStatus, SprachEintrag } from '../gemeinsam/nachrichten'
// ── Formatierer ─────────────────────────────────────────────────────────
export function gb(bytes: number): string {
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
}
/** Größen mit passender Einheit: 412 GB, 2,2 TB. */
export function gbKurz(bytes: number): string {
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1).replace('.', ',')} TB`
if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`
return `${(bytes / 1e6).toFixed(0)} MB`
}
export function dauerText(s: number): string {
const m = Math.floor(s / 60)
return m >= 60
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
}
/** „1 h 42 min" für Statistik und Verlauf. */
export function dauerGrob(s: number): string {
const m = Math.round(s / 60)
if (m < 60) return `${m} min`
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
}
/** ISO-Zeit 2026-09-12 21:30" in DEINER Uhrzeit (5.7.0, Durchsicht F12):
* vorher wurde der UTC-Text abgeschnitten 21:30 Uhr stand als 19:30 da. */
export function datumKurz(iso: string | null): string {
if (iso === null || iso.length < 16) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ')
const z = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`
}
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
* noch keine Angabe. */
export function restText(restS: number | undefined): string {
if (restS === undefined || restS < 0) return 'Restzeit wird berechnet …'
if (restS < 60) return `noch ${Math.max(1, Math.round(restS))} s`
if (restS < 3600) return `noch ~${Math.ceil(restS / 60)} min`
const h = Math.floor(restS / 3600)
const m = Math.round((restS - h * 3600) / 60)
return `noch ~${h} h ${String(m).padStart(2, '0')} min`
}
export function restzeitText(prozent: number, startMs: number | undefined): string {
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
const verstrichen = (Date.now() - startMs) / 1000
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
if (restS < 90) return `REST ~${restS} S`
return `REST ~${Math.round(restS / 60)} MIN`
}
// ── TMDb-Bilder: Größen aus /configuration gemessen (01.09.2026) ────────
export function posterUrlKlein(pfad: string): string {
return bildUrl(pfad, 'w154')
}
export function posterUrlGross(pfad: string): string {
return bildUrl(pfad, 'w342')
}
export function backdropUrl(pfad: string): string {
return bildUrl(pfad, 'w780')
}
/** Freigestellter Schriftzug — SVG-Logos zeigt Chromium direkt. */
export function logoUrl(pfad: string): string {
return bildUrl(pfad, 'w300')
}
export function profilUrl(pfad: string): string {
return bildUrl(pfad, 'w185')
}
function bildUrl(pfad: string, groesse: string): string {
if (pfad.length === 0) return ''
if (pfad.startsWith('http')) return pfad
return `https://image.tmdb.org/t/p/${groesse}${pfad}`
}
/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */
export function metaZeile(info: DiscInfoStand): string {
const teile: string[] = []
if (info.jahr !== null) teile.push(String(info.jahr))
if (info.fsk.length > 0) teile.push(`FSK ${info.fsk}`)
if (info.laufzeitMinuten > 0) teile.push(`${info.laufzeitMinuten} Min`)
if (info.genres.length > 0) teile.push(info.genres.slice(0, 3).join(' · '))
if (info.bewertung > 0) teile.push(`${info.bewertung.toFixed(1).replace('.', ',')}`)
return teile.join(' · ')
}
export function sicherheitsWort(confidence: number): string {
if (confidence >= 0.9) return 'sehr wahrscheinlich'
if (confidence >= 0.8) return 'wahrscheinlich'
if (confidence >= 0.6) return 'Vorschlag'
return 'unsicher'
}
/** 'Staffel 1 · Disc 2 · 2 Folgen' was die Disc über ihre Lage in der
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
* Disc nichts davon verrät. */
export function discHerkunft(info: DiscInfoStand): string {
const teile: string[] = []
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
return teile.join(' · ')
}
/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel
* sie hat sonst alles, was er hat (japanischer Ton statt gar keiner). */
export function sprachenVorbelegt(vorhanden: readonly SprachEintrag[], einstellung: string): string[] {
const da = vorhanden.map((s) => s.lang)
const wunsch = einstellung
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
const treffer = wunsch.filter((w) => da.includes(w))
return treffer.length > 0 ? treffer : da
}
export const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
cd: 'Audio-CD',
dvd: 'DVD',
bluray: 'Blu-ray',
uhd: '4K-UHD',
unknown: 'unbekannt',
}
export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
'liest-info': 'LIEST TITEL-INFO',
rettet: 'RETTUNGS-ABBILD — LIEST SEKTOR FÜR SEKTOR',
rippt: 'RIPPT (VERLUSTFREI)',
komprimiert: 'KOMPRIMIERT',
'legt-ab': 'LEGT AB',
fertig: 'FERTIG',
fehler: 'FEHLER',
abgebrochen: 'ABGEBROCHEN',
}
export const LAUFENDE_PHASEN: readonly string[] = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab']
export const FELD_KLASSE =
'mt-1 w-full rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none'
export const KNOPF_LEISE = 'rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35'
export const KNOPF_GOLD =
'rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35'
// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ──
export function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) {
return (
<svg width={groesse} height={groesse} viewBox="0 0 24 24" fill="none" stroke={farbe} strokeWidth="1.6">
<circle cx="12" cy="12" r="9"></circle>
<circle cx="12" cy="12" r="2.6"></circle>
<path d="M12 3v3.4M12 17.6V21"></path>
</svg>
)
}
export function IconPlay() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 4.5v15l13-7.5z"></path>
</svg>
)
}
export function IconStop() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
</svg>
)
}
export function IconEject() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 17h14M12 4l6 9H6z"></path>
</svg>
)
}
export function IconStift() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 20l4.5-1L20 7.5 16.5 4 5 15.5 4 20z"></path>
</svg>
)
}
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
export const IconMinimieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconMaximieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconWiederherstellen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconSchliessen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export function FensterKnopf({
titel,
aufKlick,
rot = false,
children,
}: {
titel: string
aufKlick: () => void
rot?: boolean
children: React.ReactNode
}) {
return (
<button
onClick={aufKlick}
title={titel}
aria-label={titel}
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
rot ? 'hover:bg-rot hover:text-[#fff]' : 'hover:bg-tinte/10 hover:text-schnee'
}`}
>
{children}
</button>
)
}
// ── Kleine Bausteine des Schaltpults ────────────────────────────────────
export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
const farben = {
gruen: 'border-gruen/40 bg-gruen/10 text-gruen',
gelb: 'border-warn/40 bg-warn/10 text-warn',
blau: 'border-gold/40 bg-gold/10 text-gold-hell',
grau: 'border-tinte/15 bg-tinte/5 text-dunst',
}
return (
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
{text}
</span>
)
}
/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */
export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) {
return (
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2 min-w-0">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className={`mt-0.5 truncate text-[13px] font-semibold ${farbe ?? 'text-schnee'}`} title={wert}>
{wert}
</div>
</div>
)
}
/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */
export function SegmentBalken({ prozent }: { prozent: number }) {
const segmente = 20
const anteil = Math.max(0, Math.min(100, prozent)) / 100
const voll = Math.floor(anteil * segmente)
const halb = anteil * segmente - voll >= 0.3
return (
<div className="flex gap-1">
{Array.from({ length: segmente }, (_, i) => (
<div
key={i}
className={`h-3.5 flex-1 rounded-[3px] ${
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-tinte/8'
}`}
/>
))}
</div>
)
}
/** Ein schmaler Füllbalken (Speicher): Anteil belegt, Farbe nach Lage. */
export function FuellBalken({ anteil, ton }: { anteil: number; ton: 'gold' | 'warn' | 'rot' }) {
const farbe = ton === 'gold' ? 'from-gold-tief to-gold-hell' : ton === 'warn' ? 'from-warn to-warn' : 'from-rot to-rot'
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-tinte/8">
<div className={`h-full rounded-full bg-gradient-to-r ${farbe} transition-all`} style={{ width: `${Math.max(0, Math.min(100, anteil * 100))}%` }} />
</div>
)
}
export function Kicker({ text }: { text: string }) {
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
}
/** Ein richtiger Schalter statt einer nackten Checkbox. */
export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
return (
<button onClick={() => aufWechsel(!an)} className="flex items-center gap-3 text-left" role="switch" aria-checked={an}>
<span
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-tinte/12'
}`}
>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`} />
</span>
<span className="text-xs leading-5 text-dunst">{label}</span>
</button>
)
}
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
export function SektionsKarte({
id,
titel,
intro,
children,
}: {
id: string
titel: string
intro: string
children: React.ReactNode
}) {
return (
<section id={id} className="scroll-mt-4 rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<Kicker text={titel} />
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
<div className="mt-4 space-y-3">{children}</div>
</section>
)
}
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
export function HinterlegtPunkt({ wert }: { wert: string }) {
return (
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
</span>
)
}
export function Feld({
name,
wert,
hinweis,
geheim,
platzhalter,
aufAendern,
}: {
name: string
wert: string
hinweis?: string
geheim?: boolean
platzhalter?: string
aufAendern: (wert: string) => void
}) {
const [entwurf, setEntwurf] = useState(wert)
useEffect(() => setEntwurf(wert), [wert])
// 5.7.0 (Durchsicht F18): Gespeichert wird beim Verlassen, bei Enter UND
// wenn das Feld verschwindet (Bereichswechsel, Dialog zu) — vorher ging
// Tipparbeit verloren, wenn man statt zu klicken Enter drückte oder in
// einen anderen Bereich wechselte. Die Refs tragen den jüngsten Stand in
// die Aufräum-Funktion des Effekts.
const juengster = useRef({ entwurf, wert, aufAendern })
juengster.current = { entwurf, wert, aufAendern }
useEffect(
() => () => {
const j = juengster.current
if (j.entwurf !== j.wert) j.aufAendern(j.entwurf)
},
[],
)
const speichern = (): void => {
if (entwurf !== wert) aufAendern(entwurf)
}
return (
<label className="block">
<span className="text-xs font-semibold text-dunst">{name}</span>
{hinweis !== undefined && <span className="ml-2 text-xs text-nebel">{hinweis}</span>}
<input
type={geheim === true ? 'password' : 'text'}
value={entwurf}
placeholder={platzhalter}
onChange={(e) => setEntwurf(e.target.value)}
onBlur={speichern}
onKeyDown={(e) => {
if (e.key === 'Enter') speichern()
}}
className={FELD_KLASSE}
/>
</label>
)
}
/** Ein Pfad-Feld mit Durchsuchen (Ordner oder Programm) die echten
* Windows-Dialoge, keine nachgebauten (§ 7). */
export function PfadFeld({
name,
wert,
hinweis,
art,
dialogTitel,
aufAendern,
}: {
name: string
wert: string
hinweis?: string
art: 'ordner' | 'programm'
dialogTitel: string
aufAendern: (wert: string) => void
}) {
const waehlen = async (): Promise<void> => {
const gewaehlt = art === 'ordner' ? await window.rippy.ordnerWaehlen(dialogTitel) : await window.rippy.dateiWaehlen(dialogTitel)
if (gewaehlt !== null) aufAendern(gewaehlt)
}
return (
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<Feld name={name} wert={wert} hinweis={hinweis} aufAendern={aufAendern} />
</div>
<button onClick={() => void waehlen()} className={`${KNOPF_LEISE} py-2`}>
Durchsuchen
</button>
</div>
)
}
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
* einen Dialog.
*
* Commander-Fund (01.09.2026): es gibt einen kleinen bug im UI wenn man
* selbst den titel sucht das suchfenster schließt sich dann einfach."
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
* Vorfahren die Hintergrundfläche. Ein reiner onClick-Horcher darauf
* schließt den Dialog also mitten im Tippen.
*
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
* Fläche selbst passierten, ist es ein Klick daneben. */
export function useHintergrundKlick(schliessen: () => void): {
onMouseDown: (e: React.MouseEvent) => void
onClick: (e: React.MouseEvent) => void
} {
const begannDraussen = useRef(false)
return {
onMouseDown: (e) => {
begannDraussen.current = e.target === e.currentTarget
},
onClick: (e) => {
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
},
}
}
/** Der Rahmen jedes Dialogs: abgedunkelte Bühne, Karte in der Mitte. */
export function DialogRahmen({
schliessen,
breite,
children,
}: {
schliessen: () => void
breite: string
children: React.ReactNode
}) {
const hintergrund = useHintergrundKlick(schliessen)
// 5.7.0 (Durchsicht F19): Escape schließt, der Dialog ist als solcher
// ausgezeichnet (role/aria-modal) und bekommt den Fokus — ohne das blieb
// die Tastatur auf der Seite dahinter, und Escape tat nichts.
const karte = useRef<HTMLDivElement>(null)
useEffect(() => {
const taste = (e: KeyboardEvent): void => {
if (e.key === 'Escape') schliessen()
}
window.addEventListener('keydown', taste)
// Fokus in den Dialog — aber nicht aus einem Eingabefeld heraus, das
// ein Kind schon per autoFocus genommen hat.
if (document.activeElement === null || document.activeElement === document.body) karte.current?.focus()
return () => window.removeEventListener('keydown', taste)
}, [schliessen])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
<div
ref={karte}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60 focus:outline-none`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
}
@@ -0,0 +1,82 @@
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
import { useEffect, useState } from 'react'
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { DialogRahmen, IconDisc, posterUrlKlein } from '../bausteine'
import { metadatenSuchen, zuordnungSetzen } from '../kernverbindung'
export function AendernDialog({
id,
startText,
vorschlaege,
schliessen,
}: {
id: string
startText: string
vorschlaege: MetaVorschlag[]
schliessen: () => void
}) {
const [text, setText] = useState(startText)
useEffect(() => {
metadatenSuchen(id, startText)
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<DialogRahmen schliessen={schliessen} breite="max-w-lg">
<h3 className="mb-3 font-display text-lg font-bold text-schnee">Was ist das wirklich?</h3>
<div className="mb-3 flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') metadatenSuchen(id, text)
}}
placeholder="Titel suchen …"
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
/>
<button
onClick={() => metadatenSuchen(id, text)}
className="rounded-lg border border-gold/50 bg-gold/10 px-4 py-2 text-xs font-semibold text-gold-hell hover:bg-gold/20"
>
Suchen
</button>
</div>
{vorschlaege.length === 0 ? (
<p className="font-mono text-xs text-nebel">Keine Treffer anderen Suchtext probieren.</p>
) : (
<div className="space-y-1.5">
{vorschlaege.map((v) => (
<button
key={`${v.typ}-${v.tmdbId}`}
onClick={() => {
zuordnungSetzen(id, v)
schliessen()
}}
className="flex w-full items-center gap-3 rounded-xl border border-tinte/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded-md object-cover" />
) : (
<span className="flex h-16 w-11 items-center justify-center rounded-md bg-samt">
<IconDisc groesse={20} farbe="oklch(0.55 0.015 80)" />
</span>
)}
<div>
<p className="text-sm font-medium text-schnee">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</p>
<p className="font-mono text-[10px] tracking-wider text-nebel">{v.typ === 'tv' ? 'SERIE' : 'FILM'}</p>
</div>
</button>
))}
</div>
)}
<div className="mt-4 text-right">
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:bg-tinte/5">
Schließen
</button>
</div>
</DialogRahmen>
)
}
@@ -0,0 +1,663 @@
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20120 s, deshalb
// beginnt er erst hier und nicht schon beim Einlegen.
// 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau.
// 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1
// zählt auf Disc 2 weiter).
// 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
// eigenen Roh-Ordner, Vorgang und Zielordner.
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
// einzelnen Spuren meldet.
import { useState } from 'react'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
SpeicherStand,
SprachEintrag,
SpurAngabe,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
/** Sprach-Chips eines Titels anklickbar, außerhalb des Auswahl-Labels
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
function SprachChips({
name,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
vorhanden: readonly SprachEintrag[]
gewaehlt: readonly string[]
aufWechsel: (lang: string) => void
}) {
if (vorhanden.length === 0) return null
return (
<span className="inline-flex flex-wrap items-center gap-1">
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{vorhanden.map((s) => {
const an = gewaehlt.includes(s.lang)
return (
<button
key={s.lang}
type="button"
onClick={() => aufWechsel(s.lang)}
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst'
}`}
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
>
{an ? '✓ ' : ''}
{s.lang}
{s.spuren > 1 ? `×${s.spuren}` : ''}
</button>
)
})}
</span>
)
}
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
function SpurZeilen({
name,
art,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
art: 'audio' | 'untertitel'
vorhanden: readonly SpurAngabe[]
gewaehlt: readonly number[]
aufWechsel: (nr: number) => void
}) {
const zeilen = vorhanden.filter((s) => angeboten(s, art))
if (zeilen.length === 0) return null
return (
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{zeilen.map((s) => {
const an = gewaehlt.includes(s.nr)
return (
<label
key={s.nr}
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
>
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
{spurLabel(s)}
</label>
)
})}
</div>
)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
* der Wunschsprache sonst nichts. */
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts sprachwahl) '' wenn nichts. */
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return ''
return untertitel.includes(wunsch) ? wunsch : ''
}
export function TitelWahlDialog({
id,
typ,
stand,
info,
einstellungen,
regel,
speicher,
vorschlaege,
schliessen,
}: {
id: string
typ: GeraeteInfo['typ']
stand: TitelStand | undefined
info: DiscInfoStand | undefined
einstellungen: Record<string, string>
/** Klartext der Aufbewahrungs-Regel damit klar ist, was mit dem
* Roh-Material passiert. */
regel: string
speicher: SpeicherStand | null
/** Treffer der Metadaten-Suche (für „anderer Film …"). */
vorschlaege: MetaVorschlag[]
schliessen: () => void
}) {
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
// Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt
// (Einstellung, soweit der Titel sie hat — sonst alles, was er hat).
const [sprachen, setSprachen] = useState<Record<number, { audio: string[]; untertitel: string[] }>>({})
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
// sonst die Sprache. Immer optional, nie eingebrannt.
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
// 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
// Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der
// Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser
// Serie) oder vom Nutzer („auf Disc 2 sind 34").
const merker = info?.serienMerker ?? null
const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1)
const [staffel, setStaffel] = useState<string>(
info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '',
)
const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '')
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } =>
sprachen[t.nr] ?? {
audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''),
untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''),
}
const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => {
const jetzt = sprachenVon(t)
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
}
const wunschListe = (schluessel: string): string[] =>
(einstellungen[schluessel] ?? '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
spuren[t.nr] ?? {
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
}
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
const jetzt = spurenVon(t)
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
}
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
const nrn = spurenVon(t)
return {
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
}
}
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
else neu.add(nr)
setGewaehlt(neu)
}
const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? ''
const alle = stand?.titel ?? []
const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr))
const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0)
const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge')
const eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined)
const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length
const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length
// Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau.
const andereFilme = new Map<number, { film: MetaVorschlag; titel: number }>()
for (const t of gewaehlteTitel) {
const f = filme[t.nr]
if (f === undefined) continue
const e = andereFilme.get(f.tmdbId)
if (e !== undefined) e.titel += 1
else andereFilme.set(f.tmdbId, { film: f, titel: 1 })
}
const zahlOderNull = (text: string, mindestens: number): number | null => {
const n = Number(text.trim())
return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null
}
const staffelZahl = zahlOderNull(staffel, 0)
const folgeZahl = zahlOderNull(ersteFolge, 1)
const ablage = einstellungen['ablage'] ?? 'Videos\\Rippy'
const vorschau = zielVorschau({
ablage,
alsSerie,
titel: info?.titel ?? '',
jahr: info?.jahr ?? null,
staffel: staffelZahl,
ersteFolge: folgeZahl,
hauptTitel,
extras,
vorgangsName: `${typ}-<Zeitstempel>`,
})
// Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode.
const komprimiert = einstellungen['transcodeEnabled'] !== 'false'
const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false }
const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie')
return (
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
{stand === undefined || stand.laeuft ? (
<div className="mt-4">
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange
es schon läuft und wo die Grenze liegt. */}
{stand !== undefined && stand.einordnung.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
</div>
) : stand.fehler.length > 0 ? (
<p className="mt-4 font-mono text-xs text-rot"> {stand.fehler}</p>
) : (
<>
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
{stand.unsicher ? '⚠ ' : ''}
{stand.einordnung}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<button
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
)
}
className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Hauptinhalt + Extras
</button>
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Alles
</button>
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Nichts
</button>
</div>
<div className="mt-3 space-y-1">
{alle.map((t) => {
const an = aktiv.has(t.nr)
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
const film = filme[t.nr]
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
const spurNrn = spurenVon(t)
const spurWahl = gewaehlteSpuren(t)
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
return (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
{an ? (
<select
value={rolle}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
<option value="extra">Extra</option>
<option value="folge">Folge</option>
</select>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
der Info-Lauf an DIESEM Titel gemessen hat einzeln
anhaken, was in die Datei soll. */}
{an && spurenDa(t) && (
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
<SpurZeilen
name="UNTERTITEL"
art="untertitel"
vorhanden={t.spuren.untertitel}
gewaehlt={spurNrn.untertitel}
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
/>
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. Hier als SPUR. */}
{rolle === 'extra' && spurWahl.untertitel.length > 0 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlSpurWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahlSpur }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : Number(w)
setVorwahlSpur(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{spurWahl.untertitel.map((u) => (
<option key={u.nr} value={String(u.nr)}>
{spurLabel(u)}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
Titel gemessen hat anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
{an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. */}
{rolle === 'extra' && wahl.untertitel.length > 0 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahl }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : w
setVorwahl(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{wahl.untertitel.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* 5.6.0: anderer Film " Double Feature, Trilogie-Disc,
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
{an && rolle !== 'folge' && (
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
{film !== undefined ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
eigener Film: {film.titel}
{film.jahr !== null ? ` (${film.jahr})` : ''}
<button
type="button"
onClick={() => {
const neu = { ...filme }
delete neu[t.nr]
setFilme(neu)
}}
className="ml-1 text-nebel hover:text-rot"
title="wieder zur Disc-Zuordnung"
>
×
</button>
</span>
</>
) : (
<button
type="button"
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
>
anderer Film
</button>
)}
{filmSuche?.nr === t.nr && (
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
<div className="flex gap-2">
<input
value={filmSuche.text}
autoFocus
onChange={(e) => setFilmSuche({ nr: t.nr, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text)
}}
placeholder="Filmtitel suchen …"
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
/>
<button
type="button"
onClick={() => metadatenSuchen(id, filmSuche.text)}
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
>
Suchen
</button>
</div>
{filmTreffer.length === 0 ? (
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
) : (
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
{filmTreffer.map((v) => (
<button
key={v.tmdbId}
type="button"
onClick={() => {
setFilme({ ...filme, [t.nr]: v })
setFilmSuche(null)
}}
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
) : (
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
</span>
)}
<span className="text-xs text-schnee">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
</div>
{alsSerie && (
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
<input
type="number"
min={0}
value={staffel}
onChange={(e) => setStaffel(e.target.value)}
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
<input
type="number"
min={1}
value={ersteFolge}
onChange={(e) => setErsteFolge(e.target.value)}
placeholder="automatisch"
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
{merkerGilt && merker !== null
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
</p>
</div>
)}
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
einiges zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0:
jeder weitere Film bekommt seine Zeile. */}
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
{eigeneTitel.length > 0 && (
<>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
</>
)}
{[...andereFilme.values()].map(({ film, titel }) => (
<p key={film.tmdbId} className="break-all text-gold-hell">
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
<span className="text-dunst">
{' '}
{titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
</span>
</p>
))}
</div>
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
vollen Laufwerk scheitern. */}
{speicher !== null && speicher.arbeit.ok && (
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 0 ? 'text-warn' : 'text-nebel'}`}>
{platz.text.length > 0
? `${platz.hart ? '✗' : '⚠'} ${platz.text}`
: `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}
</p>
)}
<p className="mt-3 font-mono text-[11px] text-nebel">
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
{regel.length > 0 ? ` · ${regel}` : ''}
</p>
<p className="mt-1 text-[11px] text-nebel">
{alsSerie
? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '}
Das Laufwerk ist frei, sobald die Roh-Dateien liegen die Kompression läuft danach im Hintergrund.
</p>
</>
)}
<div className="mt-5 flex justify-between gap-2">
<button
onClick={() => {
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
schliessen()
}}
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
>
Abbrechen
</button>
<button
onClick={() => {
ripStarten(
id,
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
// scheitert; und die alten Hinweise bleiben stimmig).
if (spurenDa(t)) {
const spurWahl = gewaehlteSpuren(t)
return {
nr: t.nr,
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: sprachenDerSpuren(spurWahl.audio),
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
audioSpuren: spurWahl.audio,
untertitelSpuren: spurWahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}
return {
nr: t.nr,
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}),
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
)
schliessen()
}}
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
className={KNOPF_GOLD}
title={platz.hart ? platz.text : ''}
>
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
</button>
</div>
</DialogRahmen>
)
}
@@ -0,0 +1,262 @@
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
import { useEffect, useState } from 'react'
import { DEFAULT_HB_PRESET, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
import { PosterHintergrund } from './PosterHintergrund'
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
const [schritt, setSchritt] = useState(0)
const werte = verbindung.einstellungen
const werkzeuge = verbindung.werkzeuge
const speicher = verbindung.speicher
const setzen = einstellungSetzen
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
// eingetragen wurde.
useEffect(() => {
if (tmdbDa) posterLaden()
}, [tmdbDa])
useEffect(() => {
speicherLaden()
}, [schritt])
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
{
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
pflicht: true,
text:
werkzeuge === null
? 'MakeMKV wird gesucht …'
: werkzeuge.makemkvcon.length > 0
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
},
{
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
pflicht: false,
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
},
{
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
pflicht: false,
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
},
{
ok: verbindung.laufwerke.length > 0 ? true : null,
pflicht: false,
text:
verbindung.laufwerke.length > 0
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
},
]
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
const backends = werkzeuge?.backends ?? []
const presets = werkzeuge?.presets ?? []
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
['DVD', 'dvd'],
['Blu-ray', 'bluray'],
['4K-UHD', 'uhd'],
]
return (
<>
<PosterHintergrund pfade={verbindung.poster} />
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-tinte/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
<div className="flex flex-wrap gap-2">
{SCHRITTE.map((name, i) => (
<span
key={name}
className={`rounded-full px-3 py-1 text-xs ${i === schritt ? 'border border-gold/40 bg-gold/15 text-gold-hell' : i < schritt ? 'text-gruen' : 'text-nebel'}`}
>
{i + 1} {name}
</span>
))}
</div>
{schritt === 0 && (
<div className="space-y-3 text-sm text-dunst">
<h2 className="font-display text-3xl font-bold text-schnee">Willkommen bei Rippy</h2>
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs erst verlustfrei, dann komprimiert.</p>
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.</p>
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
<p>Rippy prüft gleich seine Werkzeuge und fragt dich danach ein paar Dinge: wohin, wie schnell, woher die Titel kommen, wie es sich meldet. Dauert zwei Minuten ändern kannst du alles später.</p>
</div>
)}
{schritt === 1 && (
<div className="space-y-3">
<div className="space-y-2">
{pruefzeilen.map((zeile, i) => (
<p key={i} className={`font-mono text-xs ${zeile.ok === true ? 'text-gruen' : zeile.ok === null ? 'text-nebel' : zeile.pflicht ? 'text-rot' : 'text-warn'}`}>
{zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text}
</p>
))}
</div>
{makemkvFehlt && (
<PfadFeld
name="makemkvcon.exe von Hand wählen"
hinweis="falls MakeMKV an einem ungewöhnlichen Ort liegt"
wert={werte['werkzeug.makemkv'] ?? ''}
art="programm"
dialogTitel="makemkvcon.exe wählen"
aufAendern={(w) => setzen('werkzeug.makemkv', w)}
/>
)}
</div>
)}
{schritt === 2 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht Rippy baut darin selbst die Struktur.</p>
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
{speicher !== null && speicher.ablage.ok && <p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}</p>}
<p className="text-sm text-dunst">
Liegt die Ablage auf einem Server, wähle einen <span className="text-schnee">Arbeitsordner auf einer lokalen Platte</span>: Dort liegen die Roh-Dateien
(30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz.
</p>
<PfadFeld
name="Arbeitsordner (lokal, optional)"
hinweis="leer = unter der Ablage"
wert={werte['arbeitsordner'] ?? ''}
art="ordner"
dialogTitel="Arbeitsordner wählen (lokale Platte)"
aufAendern={(w) => setzen('arbeitsordner', w)}
/>
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
<p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.</p>
)}
</div>
)}
{schritt === 3 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen die kostet Zeit, spart aber viel Platz.</p>
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
{werkzeuge === null ? (
<p className="font-mono text-xs text-nebel">Encoder werden gemessen </p>
) : backends.length === 0 ? (
<p className="font-mono text-xs text-warn">Keine Encoder gemessen ohne HandBrakeCLI bleibt es beim verlustfreien Rip.</p>
) : (
<>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">AUF DIESER MASCHINE GEMESSEN:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst Grafikkarte vor CPU, wo es eine gibt:</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{EMPFEHLUNGEN.map(([name, typ]) => (
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
<p className="mt-0.5 font-mono text-xs text-gold-hell">{empfehlungText(typ, presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET)}</p>
</div>
))}
</div>
{nurCpu && <p className="mt-2 text-xs text-warn">Nur CPU-Encoder gefunden das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.</p>}
</>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<SprachAuswahl name="Tonspuren" hinweis="die erste ist deine Sprache" wert={werte['audioSprachen'] ?? ''} aufAendern={(w) => setzen('audioSprachen', w)} />
<SprachAuswahl name="Untertitel" hinweis="vorgewählt bei fremdem Ton" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
</div>
<p className="text-xs text-nebel">Je Disc-Typ ein eigenes Preset geht später unter Einstellungen Kompression" deine Wahl schlägt die Empfehlung immer.</p>
</div>
)}
{schritt === 4 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.</p>
<Feld name="TMDb-API-Key" hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt" wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
{tmdbDa && verbindung.poster.length > 0 && <p className="font-mono text-xs text-gruen"> Der Schlüssel funktioniert die Poster im Hintergrund kommen von TMDb.</p>}
<p className="text-xs text-nebel">Ohne Schlüssel rippt Rippy trotzdem die Filme heißen dann so, wie die Disc sich selbst nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter Einstellungen Metadaten".</p>
</div>
)}
{schritt === 5 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer das hier ist freiwillig.</p>
<Feld name="Discord-Webhook" hinweis="Servereinstellungen → Integrationen → Webhooks" geheim wert={werte['discordWebhook'] ?? ''} aufAendern={(w) => setzen('discordWebhook', w)} />
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
<Feld name="Telegram-Chat-ID" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
</div>
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
Testnachricht schicken
</button>
</div>
)}
{schritt === 6 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wie selbstständig soll Rippy sein?</p>
<Schalter an={werte['automatik'] !== 'aus'} aufWechsel={(an) => setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" />
<label className="block max-w-xs">
<span className="text-xs font-semibold text-dunst">Countdown</span>
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} className={FELD_KLASSE}>
<option value="10">10 Sekunden</option>
<option value="30">30 Sekunden (Vorgabe)</option>
<option value="60">1 Minute</option>
<option value="120">2 Minuten</option>
</select>
</label>
<Schalter an={werte['autoAuswurf'] !== 'false'} aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" />
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Roh-Dateien nach gelungener Kompression</span>
<select value={werte['rohAufbewahrung'] ?? '7'} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
<option value="sofort">sofort löschen</option>
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
<option value="14">nach 14 Tagen löschen</option>
<option value="30">nach 30 Tagen löschen</option>
<option value="behalten">behalten nur von Hand löschen</option>
</select>
</label>
<p className="text-xs text-nebel">Die Roh-Dateien sind der Grund, warum ein Preset-Wechsel kein Neulesen kostet und gelöscht wird nur, wenn das fertige Ergebnis nachweislich da ist.</p>
</div>
)}
{schritt === 7 && (
<div className="space-y-3 text-sm text-dunst">
<p>Das war's. Disc einlegen Rippy meldet sich.</p>
<p className="text-xs text-nebel">Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.</p>
{makemkvFehlt && <p className="text-xs text-rot">Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen Werkzeuge zeigt den Stand, sobald es installiert ist).</p>}
</div>
)}
<div className="flex justify-between">
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
Zurück
</button>
{schritt < SCHRITTE.length - 1 ? (
<button onClick={() => setSchritt((s) => s + 1)} className={KNOPF_GOLD}>
Weiter
</button>
) : (
<button onClick={() => setzen('setup.done', 'true')} className={KNOPF_GOLD}>
Los geht's
</button>
)}
</div>
</div>
</>
)
}
@@ -0,0 +1,51 @@
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
import { IconDisc, posterUrlGross } from '../bausteine'
const SPALTEN = 7
const JE_SPALTE = 6
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
return (
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute inset-0 flex gap-4 px-4 opacity-[0.5]">
{spalten.map((spalte, i) => {
// Auffüllen mit leeren Karten, damit jede Spalte voll ist.
const karten: Array<string | null> = [...spalte]
while (karten.length < JE_SPALTE) karten.push(null)
const doppelt = [...karten, ...karten]
return (
<div
key={i}
className="poster-spalte flex flex-1 flex-col gap-4"
style={{
animationDuration: `${70 + (i % 3) * 25}s`,
animationDelay: `${-i * 9}s`,
marginTop: `${(i % 2) * -120}px`,
}}
>
{doppelt.map((p, j) =>
p !== null ? (
<img key={j} src={posterUrlGross(p)} alt="" className="aspect-[2/3] w-full rounded-xl object-cover ring-1 ring-gold/25" loading="lazy" />
) : (
<div key={j} className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-gradient-to-br from-[oklch(0.27_0.03_75)] to-[oklch(0.19_0.02_75)] ring-1 ring-gold/30">
<IconDisc groesse={28} farbe="oklch(0.6 0.08 75)" />
</div>
),
)}
</div>
)
})}
</div>
{/* Der Verlauf ins Bühnen-Schwarz die Poster bleiben Kulisse, der
Assistent bleibt lesbar. */}
<div className="absolute inset-0 bg-gradient-to-b from-buehne/35 via-buehne/55 to-buehne/90" />
</div>
)
}
@@ -0,0 +1,83 @@
// Die Einstellungen — Sektionswahl links (oder oben im schmalen Fenster),
// die Karten rechts. Jede Karte ist eine Datei in Karten.tsx (5.5.0).
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import type { Verbindung } from '../kernverbindung'
import {
AblageKarte,
AllgemeinKarte,
AutomatikKarte,
BenachrichtigungKarte,
KompressionKarte,
MakemkvKarte,
MedienserverKarte,
MetadatenKarte,
RohDateienKarte,
UpdateKarte,
WerkzeugeKarte,
} from './Karten'
const SEKTIONEN: Array<[string, string]> = [
['ablage', 'Ablage'],
['kompression', 'Kompression'],
['roh', 'Roh-Dateien'],
['automatik', 'Automatik'],
['metadaten', 'Metadaten'],
['makemkv', 'MakeMKV'],
['medienserver', 'Medienserver'],
['benachrichtigung', 'Benachrichtigung'],
['allgemein', 'Allgemein'],
['update', 'Update'],
['werkzeuge', 'Werkzeuge'],
]
export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbindung; haupt: HauptStatus | null; start?: string }) {
const [aktiv, setAktiv] = useState(start ?? 'ablage')
const springe = (id: string): void => {
setAktiv(id)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// „alle unter Roh-Dateien →" aus der Übersicht springt direkt zur Karte.
useEffect(() => {
if (start !== undefined) setTimeout(() => springe(start), 50)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [start])
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
// 01.09.2026: „alles gequetscht").
return (
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-[76px] lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
{SEKTIONEN.map(([id, name]) => (
<button
key={id}
onClick={() => springe(id)}
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
}`}
>
{name}
</button>
))}
</nav>
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
breiten Fenster alles links klebte und rechts nichts stand. */}
<div className="min-w-0 flex-1 space-y-5">
<AblageKarte verbindung={verbindung} />
<KompressionKarte verbindung={verbindung} />
<RohDateienKarte verbindung={verbindung} aktivFuer={start === 'roh'} />
<AutomatikKarte verbindung={verbindung} />
<MetadatenKarte verbindung={verbindung} />
<MakemkvKarte verbindung={verbindung} />
<MedienserverKarte verbindung={verbindung} />
<BenachrichtigungKarte verbindung={verbindung} />
<AllgemeinKarte verbindung={verbindung} haupt={haupt} />
<UpdateKarte verbindung={verbindung} haupt={haupt} />
<WerkzeugeKarte verbindung={verbindung} />
</div>
</div>
)
}
@@ -0,0 +1,734 @@
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
// einstellungSetzen in die EINE Quelle (§ 6.7).
import { useState } from 'react'
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
FELD_KLASSE,
Feld,
HinterlegtPunkt,
KNOPF_LEISE,
LAUFENDE_PHASEN,
PfadFeld,
Schalter,
SektionsKarte,
TYP_NAMEN,
datumKurz,
gb,
gbKurz,
} from '../bausteine'
import {
benachrichtigungTesten,
beschaffungAntworten,
einstellungSetzen,
fremdenOrdnerLoeschen,
kompressionAbbrechen,
makemkvUpdate,
rohAufraeumen,
schluesselPruefen,
vorgaengeLaden,
vorgangNeuKomprimieren,
vorgangRohLoeschen,
type Verbindung,
} from '../kernverbindung'
import { phasenTon } from '../uebersicht/VerlaufKarte'
import { SprachAuswahl } from './SprachAuswahl'
const setzen = einstellungSetzen
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const speicher = verbindung.speicher
return (
<SektionsKarte
id="ablage"
titel="ABLAGE"
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
>
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
{speicher !== null && speicher.ablage.ok && (
<p className="font-mono text-[10.5px] text-nebel">
{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}
</p>
)}
<PfadFeld
name="Arbeitsordner (lokal, optional)"
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
wert={werte['arbeitsordner'] ?? ''}
art="ordner"
dialogTitel="Arbeitsordner wählen (lokale Platte)"
aufAendern={(w) => setzen('arbeitsordner', w)}
/>
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
<p className="font-mono text-[10.5px] text-nebel">
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.
</p>
)}
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p className="text-dunst">So legt Rippy ab:</p>
<p>
Filme\Akira (1988)\ <span className="text-nebel/70"> Film, Poster und Infodatei beieinander</span>
</p>
<p>
Serien\Spartacus\Season 01\ <span className="text-nebel/70"> Folgen, wie Jellyfin sie erwartet</span>
</p>
<p>
Musik\ <span className="text-nebel/70"> Audio-CDs als FLAC, benannt nach MusicBrainz</span>
</p>
<p>
ISO\ <span className="text-nebel/70"> unerkannte Discs als 1:1-Abbild</span>
</p>
<p>
roh\ <span className="text-nebel/70"> im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter Roh-Dateien"</span>
</p>
</div>
<Schalter
an={werte['autoAuswurf'] !== 'false'}
aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')}
label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft"
/>
</SektionsKarte>
)
}
function PresetWahl({
name,
schluessel,
wert,
presets,
empfehlung,
}: {
name: string
schluessel: string
wert: string
presets: string[]
empfehlung: string
}) {
return (
<label className="block">
<span className="text-xs font-semibold text-dunst">{name}</span>
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
{presets.map((preset) => (
<option key={preset} value={preset}>
{preset}
</option>
))}
</select>
</label>
)
}
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const presets = verbindung.werkzeuge?.presets ?? []
const backends = verbindung.werkzeuge?.backends ?? []
const empfehlungFuer = (typ: string): string => empfehlungText(typ, presetEmpfehlung(typ, backends, presets))
return (
<SektionsKarte
id="kompression"
titel="KOMPRESSION"
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
>
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
<p className="font-mono text-xs text-warn"> HandBrakeCLI fehlt Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
)}
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
{backends.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
</div>
<p className="text-[11px] leading-4 text-nebel">
automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
schlägt die Empfehlung immer. 4K-UHD wird nur mit Grafikkarten-Encoder komprimiert (8 Bit); ohne bleibt es verlustfrei. Wer HDR in 10 Bit will, wählt
AV1 VCN 2160p 4K" die Abspielgeräte müssen dann AV1 können.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<SprachAuswahl
name="Tonspuren"
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
wert={werte['audioSprachen'] ?? ''}
aufAendern={(w) => setzen('audioSprachen', w)}
/>
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
</div>
</SektionsKarte>
)
}
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const an = werte['automatik'] !== 'aus'
return (
<SektionsKarte
id="automatik"
titel="AUTOMATIK"
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
>
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
<label className="block max-w-xs">
<span className="text-xs font-semibold text-dunst">Countdown</span>
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} disabled={!an} className={FELD_KLASSE}>
<option value="10">10 Sekunden</option>
<option value="30">30 Sekunden (Vorgabe)</option>
<option value="60">1 Minute</option>
<option value="120">2 Minuten</option>
</select>
</label>
<p className="text-[11px] leading-4 text-nebel">
Der Countdown steht in der Laufwerks-Kachel, mit Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
</p>
</SektionsKarte>
)
}
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
const werte = verbindung.einstellungen
const v = verbindung.vorgaenge
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
const presets = verbindung.werkzeuge?.presets ?? []
const regelWert = werte['rohAufbewahrung'] ?? '7'
const phasenText: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
wartet: 'wartet auf Kompression',
komprimiert: 'komprimiert',
'legt-ab': 'legt ab',
fertig: 'fertig',
fehler: 'Fehler',
abgebrochen: 'abgebrochen',
}
const liste = v?.vorgaenge ?? []
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
return (
<SektionsKarte
id="roh"
titel="ROH-DATEIEN"
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
>
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<label className="block">
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
<select value={regelWert} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
<option value="sofort">sofort löschen</option>
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
<option value="14">nach 14 Tagen löschen</option>
<option value="30">nach 30 Tagen löschen</option>
<option value="behalten">behalten nur von Hand löschen</option>
</select>
</label>
<div className="flex gap-2">
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
Jetzt aufräumen
</button>
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
Aktualisieren
</button>
</div>
</div>
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
{v === null ? (
<p>Belegung wird gemessen </p>
) : (
<>
<p>
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
</p>
<p>{v.regel}</p>
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier sie sind nie betroffen.</p>
</>
)}
</div>
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang der erste Rip legt hier einen Eintrag an.</p>}
<div className="space-y-2">
{sichtbar.map((e) => {
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
return (
<div key={e.id} className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-[14px] font-bold text-schnee">
{e.titel}
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
</span>
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
{e.preset.length > 0 && ` · ${e.preset}`}
</span>
</div>
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
{' · '}
<span className="text-dunst">Fertig:</span>{' '}
{e.phase === 'fertig' ? (
<span className="break-all">
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
{e.zielOrdner}
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
</span>
) : (
'—'
)}
</p>
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn"> {e.fehler}</p>}
<div className="mt-2 flex flex-wrap items-center gap-2">
{e.neuKomprimierbar && (
<>
<select
value={presetWahl[e.id] ?? ''}
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
className="rounded-lg border border-tinte/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
title="Preset für die Wiederholung — leer: wie eingestellt"
>
<option value="">Preset wie eingestellt</option>
{presets.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<button
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
>
Neu komprimieren
</button>
</>
)}
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
Roh-Dateien löschen
</button>
)}
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
<button
onClick={() => {
setLoeschfrage(null)
vorgangRohLoeschen(e.id)
}}
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
>
Ja, löschen
</button>
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
Nein
</button>
</span>
)}
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Ordner öffnen
</button>
)}
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
Kompression abbrechen
</button>
)}
</div>
</div>
)
})}
</div>
{liste.length > sichtbar.length && (
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
alle {liste.length} Vorgänge zeigen
</button>
)}
{v !== null && v.fremde.length > 0 && (
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
<div className="mt-2 space-y-1">
{v.fremde.map((f) => (
<div key={f.ordner} className="flex items-center justify-between gap-3">
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
{f.ordner} · {gb(f.bytes)}
</span>
{loeschfrage !== f.ordner ? (
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
Löschen
</button>
) : (
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
wirklich?
<button
onClick={() => {
setLoeschfrage(null)
fremdenOrdnerLoeschen(f.ordner)
}}
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
>
Ja
</button>
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
Nein
</button>
</span>
)}
</div>
))}
</div>
</div>
)}
</SektionsKarte>
)
}
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="metadaten"
titel="METADATEN"
intro="TMDb erkennt Titel, Jahr, Poster, Logo und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.themoviedb.org Einstellungen API
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
</p>
</div>
<div>
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.omdbapi.com/apikey.aspx
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
</p>
</div>
</div>
<p className="text-[11px] leading-4 text-nebel">
Ohne Schlüssel rippt Rippy trotzdem die Filme heißen dann wie das Disc-Label, und Ändern " an der Laufwerks-Kachel bleibt ohne Suche.
</p>
</SektionsKarte>
)
}
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const s = verbindung.schluessel
const b = verbindung.beschaffung
return (
<SektionsKarte
id="makemkv"
titel="MAKEMKV"
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
>
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
{s === null ? (
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft </p>
) : (
<>
{/* 5.7.0 (F1/F5): Ein abgelehnter oder fehlender Schlüssel ist
ROT Blu-rays laufen dann nur noch in MakeMKVs Gnadenfrist.
Nicht prüfbar" (kein Laufwerk) ist grau, kein Erfolg. */}
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.angenommen === null ? 'bg-tinte/25' : 'bg-rot'}`} />
<span className={s.angenommen === true ? 'text-gruen' : s.angenommen === null ? 'text-nebel' : 'text-rot'}>
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.angenommen === null ? 'NICHT PRÜFBAR — KEIN LAUFWERK' : 'KEIN GÜLTIGER SCHLÜSSEL — BLU-RAY-RIPS LAUFEN NICHT'}
</span>
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
</p>
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
<p className="mt-1 font-mono text-[10px] text-nebel">
zuletzt geprüft: {datumKurz(s.geholtAm)}
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
</p>
</>
)}
</div>
<div className="flex flex-wrap gap-2">
{s !== null && s.angenommen === false && (
<button
onClick={() => makemkvUpdate()}
disabled={b !== null && b.laeuft}
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
>
MakeMKV aktualisieren
</button>
)}
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
Schlüssel neu prüfen
</button>
</div>
{b !== null && (
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
{b.frage !== undefined && b.frage.length > 0 && (
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
Archiv-Kopie laden
</button>
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
Lieber nicht
</button>
</div>
</div>
)}
{b.laeuft && b.anteil !== null && (
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
</div>
)}
</div>
)}
<Feld
name="Eigener Schlüssel (Dauerlizenz)"
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
<p className="text-[11px] leading-4 text-nebel">
Rippy schreibt in MakeMKVs eigene Einstellungen: den Schlüssel, die Auswahlregel alle Spuren behalten" und den Kanal für 4K-Disc-Schlüssel. Deshalb nimmt auch das MakeMKV-Programm seitdem alle Tonspuren und Untertitel mit die Dateien dort werden größer, verloren geht nichts.
</p>
</SektionsKarte>
)
}
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="medienserver"
titel="MEDIENSERVER"
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="text-xs font-semibold text-dunst">System</span>
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
<option value="none">keiner</option>
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
<option value="kodi">Kodi</option>
<option value="plex">Plex (nur Benennung)</option>
</select>
</label>
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
</div>
<p className="text-[11px] leading-4 text-nebel">Bei Plex benennt Rippy nur nach dessen Regeln angestoßen wird dort nichts. Die Ordnerstruktur aus Ablage" passt für alle vier.</p>
</SektionsKarte>
)
}
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="benachrichtigung"
titel="BENACHRICHTIGUNG"
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht — über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
>
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
<Feld
name="Discord-Webhook"
hinweis="Discord → Servereinstellungen → Integrationen → Webhooks → URL kopieren"
geheim
wert={werte['discordWebhook'] ?? ''}
aufAendern={(w) => setzen('discordWebhook', w)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
<Feld name="Telegram-Chat-ID" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
</div>
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
Testnachricht schicken
</button>
</SektionsKarte>
)
}
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
const werte = verbindung.einstellungen
const kern = verbindung.kern
const db = kern?.datenbank ?? null
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
return (
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Titel lesen ohne Lebenszeichen höchstens</span>
<select value={werte['titelLesenMinuten'] ?? '15'} onChange={(e) => setzen('titelLesenMinuten', e.target.value)} className={FELD_KLASSE}>
<option value="5">5 Minuten</option>
<option value="10">10 Minuten</option>
<option value="15">15 Minuten (Vorgabe)</option>
<option value="30">30 Minuten</option>
<option value="60">60 Minuten</option>
</select>
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV still sein (kein Fortschritt, keine Meldung), bevor Rippy das Titel-Lesen beendet. Solange MakeMKV arbeitet und Fortschritt meldet etwa beim Durchlesen einer DVD mit beschädigter IFO-Datei, was 2060 Minuten dauern kann wartet Rippy; Abbrechen" im Dialog geht jederzeit.</span>
</label>
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
<option value="dunkel">Dunkel der Kinosaal (Vorgabe)</option>
<option value="hell">Hell Papier und Gold</option>
<option value="system">Wie Windows eingestellt</option>
</select>
</label>
<div className="flex flex-wrap gap-2">
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben — Haupt und Kern schreiben hinein">
Protokoll öffnen
</button>
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
Ablage im Explorer
</button>
{(werte['arbeitsordner'] ?? '').length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
Arbeitsordner im Explorer
</button>
)}
</div>
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
Rippy v{haupt?.version ?? '…'}
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
</p>
<p className="break-all">Datenbank: {dbText}</p>
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
</div>
</SektionsKarte>
)
}
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea — geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
{/* Das Feld Update-Adresse" ist am 01.09.2026 auf Zuruf des
Commanders verschwunden: Updates kommen immer vom Gitea, ein
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
`updateUrl` gilt weiterhin wer ihn wirklich braucht, setzt
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
https://). */}
{haupt !== null && (
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
<span className="text-dunst">Stand:</span> {haupt.update.text}
</p>
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {datumKurz(haupt.update.letzteErfolgreichePruefung)}</p>}
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden die Karte in der Übersicht zeigt, was neu ist.</p>}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => void window.rippy.updatePruefen()}
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
className={KNOPF_LEISE}
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
>
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
</button>
</div>
</SektionsKarte>
)
}
function WerkzeugZeile({
name,
pfad,
fehlt,
pflicht,
schluessel,
wert,
}: {
name: string
pfad: string | null
fehlt: string
pflicht: boolean
schluessel: string
wert: string
}) {
return (
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
</p>
<PfadFeld
name="Eigener Pfad"
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
wert={wert}
art="programm"
dialogTitel={`${name} wählen`}
aufAendern={(w) => setzen(schluessel, w)}
/>
</div>
)
}
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const backends = verbindung.werkzeuge?.backends ?? []
return (
<SektionsKarte
id="werkzeuge"
titel="WERKZEUGE"
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
>
<WerkzeugZeile
name="makemkvcon"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
pflicht={true}
schluessel="werkzeug.makemkv"
wert={werte['werkzeug.makemkv'] ?? ''}
/>
<WerkzeugZeile
name="HandBrakeCLI"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
pflicht={false}
schluessel="werkzeug.handbrake"
wert={werte['werkzeug.handbrake'] ?? ''}
/>
<WerkzeugZeile
name="flac"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
fehlt="nicht gefunden — nur für Audio-CDs nötig"
pflicht={false}
schluessel="werkzeug.flac"
wert={werte['werkzeug.flac'] ?? ''}
/>
{verbindung.werkzeuge !== null && (
<p className="font-mono text-[10.5px] text-nebel">
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
</p>
)}
</SektionsKarte>
)
}
@@ -0,0 +1,68 @@
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
export function SprachAuswahl({
name,
hinweis,
wert,
aufAendern,
}: {
name: string
hinweis: string
wert: string
aufAendern: (wert: string) => void
}) {
const gewaehlt = sprachlisteAus(wert)
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
const verschieben = (code: string, richtung: -1 | 1): void => {
const i = gewaehlt.indexOf(code)
const j = i + richtung
if (i < 0 || j < 0 || j >= gewaehlt.length) return
const neu = [...gewaehlt]
;[neu[i], neu[j]] = [neu[j], neu[i]]
setzen(neu)
}
return (
<div>
<span className="text-xs font-semibold text-dunst">{name}</span>
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-tinte/12 bg-samt px-2 py-1.5">
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
{gewaehlt.map((code, i) => (
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
<span>{sprachName(code)}</span>
<span className="font-mono text-[10px] text-nebel">{code}</span>
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
</button>
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
</button>
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
×
</button>
</span>
))}
<select
value=""
onChange={(e) => {
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
}}
className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
>
<option value="">+ Sprache </option>
{frei.map((s) => (
<option key={s.code} value={s.code}>
{s.name} ({s.code})
</option>
))}
</select>
</div>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
// 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>
dateiWaehlen(titel: string): Promise<string | null>
ordnerOeffnen(pfad: string): Promise<string>
protokollOeffnen(): Promise<string>
autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
}
}
}
+19
View File
@@ -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>,
)
+12
View File
@@ -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>
+450
View File
@@ -0,0 +1,450 @@
// 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 AutomatikStand,
type BeschaffungStand,
type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo,
type KernNachricht,
type LaufwerkBericht,
type MetaVorschlag,
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus,
type SpeicherStand,
type TitelStand,
type VorgaengeStand,
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[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk das aufklappbare
* 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
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
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) null,
* solange der Kern noch nichts geschickt hat. */
vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht>
/** Freier Platz an Arbeitsordner und Ablage (5.5.0). */
speicher: SpeicherStand | null
/** Automatik-Countdown je Laufwerk (5.5.0). */
automatik: Record<string, AutomatikStand>
/** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */
poster: string[]
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 30
export const LEERE_VERBINDUNG: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
speicher: null,
automatik: {},
poster: [],
meldungen: [],
}
const stand: Verbindung = { ...LEERE_VERBINDUNG }
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
gesundheit: { ...stand.gesundheit },
automatik: { ...stand.automatik },
poster: [...stand.poster],
meldungen: [...stand.meldungen],
}
}
function melden(): void {
for (const h of horcher) h(kopie())
}
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':
// 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das
// steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren.
if (stand.kern !== null && stand.kern.pid !== nachricht.pid) {
meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`)
}
stand.kern = nachricht
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 'kern-hinweis':
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}`)
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
stand.automatik = rest
} 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': {
const vorher = stand.discInfos[nachricht.info.id]
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
if (neuErkannt) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
for (const h of nachricht.info.hinweise) {
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
}
}
break
}
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
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 'vorgaenge':
stand.vorgaenge = nachricht.stand
break
case 'kompression-stand':
// 5.7.0 (F7): nur die Warteschlange frisch — der Rest bleibt stehen.
if (stand.vorgaenge !== null) stand.vorgaenge = { ...stand.vorgaenge, kompression: nachricht.kompression }
break
case 'kompression-ergebnis':
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
break
case 'laufwerk-gesundheit':
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
}
break
case 'speicher':
stand.speicher = nachricht.stand
break
case 'automatik':
stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand }
break
case 'poster-vorschau':
stand.poster = nachricht.pfade
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rettet', '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
}
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
// Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte
// ist tot und wird geschlossen, damit nichts mehr ins Leere geht.
if (port !== null && port !== neuerPort) {
try {
port.close()
} catch {
// Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg.
}
}
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'vorgaenge-laden' })
senden({ art: 'speicher-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(kopie())
return () => horcher.delete(h)
}
function senden(nachricht: FensterNachricht): void {
port?.postMessage(nachricht)
}
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
function anKern(nachricht: FensterNachricht, 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}`)
}
/** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** 5.7.0: den laufenden Titel-Lauf beenden — makemkvcon gibt das Laufwerk frei. */
export function titelAbbrechen(id: string): void {
anKern({ art: 'titel-abbruch', id }, `Titel-Lauf abbrechen ${id}`)
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
export function ripUeberspringen(id: string): void {
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function rettungStarten(id: string): void {
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
senden({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
senden({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
}
export function schluesselPruefen(): void {
senden({ art: 'schluessel-pruefen' })
}
/** 5.7.0 (F16): Ja/Nein auf eine Frage der MakeMKV-Beschaffung. */
export function beschaffungAntworten(ja: boolean): void {
anKern({ art: 'beschaffung-antwort', ja }, 'Antwort an die Beschaffung')
}
export function einstellungSetzen(schluessel: string, wert: string): void {
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
}
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
export function vorgaengeLaden(): void {
senden({ art: 'vorgaenge-laden' })
}
export function vorgangRohLoeschen(vorgangId: string): void {
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
}
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
}
export function kompressionAbbrechen(vorgangId: string): void {
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
}
export function rohAufraeumen(): void {
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
}
export function fremdenOrdnerLoeschen(ordner: string): void {
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
}
export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
}
// ── 5.5.0 ───────────────────────────────────────────────────────────────
export function kompressionPause(an: boolean): void {
anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter')
}
export function automatikStopp(id: string): void {
senden({ art: 'automatik-stopp', id })
}
export function automatikJetzt(id: string): void {
anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt')
}
export function benachrichtigungTesten(): void {
anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht')
}
export function posterLaden(): void {
senden({ art: 'poster-laden' })
}
export function speicherLaden(): void {
senden({ art: 'speicher-laden' })
}
+126
View File
@@ -0,0 +1,126 @@
@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);
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
--color-tinte: oklch(1 0 0);
}
: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);
}
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
Windows-Rahmen mehr (frame:false) gezogen wird an der Kopfzeile.
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
.ziehbar {
-webkit-app-region: drag;
}
.nicht-ziehbar {
-webkit-app-region: no-drag;
}
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
::-webkit-scrollbar-track { background: transparent; }
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
nach oben; die Liste ist verdoppelt, bei -50 % beginnt sie nahtlos von
vorn. Dauer und Versatz setzt die Komponente je Spalte. */
@keyframes poster-lauf {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
.poster-spalte {
animation-name: poster-lauf;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
/* Hell-Modus (5.6.0, Commander: White Mode")
Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als
dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet
über data-design auf <html> (App.tsx, Einstellung design": dunkel /
hell / system). */
:root[data-design="hell"] {
color-scheme: light;
--color-buehne: oklch(0.965 0.008 80);
--color-karte: oklch(0.99 0.004 80);
--color-samt: oklch(0.94 0.01 80);
--color-gold-hell: oklch(0.6 0.12 72);
--color-gold: oklch(0.66 0.13 70);
--color-gold-tief: oklch(0.56 0.13 58);
--color-rec: oklch(0.6 0.19 40);
--color-schnee: oklch(0.2 0.012 60);
--color-dunst: oklch(0.4 0.015 60);
--color-nebel: oklch(0.55 0.015 60);
--color-gruen: oklch(0.52 0.15 150);
--color-warn: oklch(0.58 0.13 80);
--color-rot: oklch(0.55 0.2 25);
--color-tinte: oklch(0.15 0.01 60);
}
:root[data-design="hell"] .buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%);
}
:root[data-design="hell"] .filmkorn {
opacity: 0.03;
}
@@ -0,0 +1,81 @@
// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und
// Kompression sind entkoppelt — hier steht, was gerade rechnet und was
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
// wird fertig, der nächste wartet.
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste'
import { restText } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
const l = stand.laeuft
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-tinte/25'}`} />
<span className="text-dunst">KOMPRESSION</span>
<span>·</span>
<span>
{l !== null ? 'läuft' : stand.pausiert ? 'angehalten' : 'wartet'}
{stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`}
</span>
<button
onClick={() => kompressionPause(!stand.pausiert)}
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-tinte/15 text-dunst hover:text-schnee'
}`}
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
>
{stand.pausiert ? '▶ Weiter' : '⏸ Pausieren'}
</button>
</div>
{l !== null && (
<div className="mt-3 rounded-xl border border-tinte/10 bg-samt/80 p-4">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
<div className="mt-2">
<VorgangsLeiste stand={leisteAusKompression(l)} restText={restText(l.restS)} />
</div>
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
&gt; {l.text}
</p>
{stand.pausiert && <p className="mt-1 font-mono text-[10.5px] text-warn">Pause: Dieser Film wird fertig, danach wartet die Schlange.</p>}
</div>
{l.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{l.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
<button
onClick={() => kompressionAbbrechen(l.vorgangId)}
className="mt-3 rounded-lg border border-rot/50 bg-rot/10 px-3 py-1 text-[11px] font-semibold text-rot hover:bg-rot/20"
title="Die Roh-Dateien bleiben liegen — unter Einstellungen → Roh-Dateien geht es später weiter"
>
Kompression abbrechen
</button>
</div>
)}
{stand.wartend.length > 0 && (
<div className="mt-3 space-y-1">
{stand.wartend.map((w, i) => (
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-1.5">
<VorgangsLeiste stand={leisteAusKompression(w)} kompakt />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-dunst">
Platz {i + 1} · {w.titel}
{stand.pausiert ? ' · wartet auf Weiter' : ''}
</span>
<button onClick={() => kompressionAbbrechen(w.vorgangId)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-rot">
aus der Schlange nehmen
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,478 @@
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
// 5.4.0: Kino-Banner v2 (Logo, Tagline, Meta-Zeile, Darsteller), Laufwerks-
// Gesundheit, Stand der Kompression nach dem Rip. 5.5.0: Automatik-Countdown
// und das Rettungs-Abbild — nur, wenn Rippy Lesefehler gesehen hat.
import { useState } from 'react'
import type {
AutomatikStand,
DiscInfoStand,
GeraeteInfo,
KompressionStand,
LaufwerkBericht,
RipStatus,
VorgangEintrag,
} from '../../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
IconDisc,
IconEject,
IconPlay,
IconStift,
IconStop,
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
TYP_NAMEN,
backdropUrl,
datumKurz,
discHerkunft,
gb,
logoUrl,
metaZeile,
posterUrlGross,
profilUrl,
restzeitText,
sicherheitsWort,
} from '../bausteine'
import {
automatikJetzt,
automatikStopp,
auswerfen,
laufwerkPruefen,
rettungStarten,
ripAbbrechen,
ripUeberspringen,
type Verbindung,
} from '../kernverbindung'
import { leisteAusRip } from '../../gemeinsam/vorgangsleiste'
import { VorgangsLeiste } from './VorgangsLeiste'
/** Welches Preset diese Disc bekäme dieselbe Kette wie im Kern
* (eigene Wahl Empfehlung Default). */
export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
if (typ === 'cd') return 'FLAC (verlustfrei)'
if (typ === 'unknown') return 'ISO-Abbild'
const werte = verbindung.einstellungen
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
const eigene = werte[schluessel] ?? ''
if (eigene === PRESET_KEINE) return 'ohne Kompression'
if (eigene.length > 0) return eigene
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
if (empfehlung === PRESET_KEINE) return typ === 'uhd' ? 'verlustfrei (nur CPU-Encoder)' : 'ohne Kompression'
return empfehlungText(typ, empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET)
}
export function LaufwerkKachel({
geraet,
rip,
info,
ripStartMs,
protokoll,
verbindung,
bericht,
vorgang,
kompression,
wartePlatz,
automatik,
aufRippen,
aufAendern,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
ripStartMs: number | undefined
protokoll: string[]
verbindung: Verbindung
/** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */
bericht: LaufwerkBericht | undefined
/** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */
vorgang: VorgangEintrag | undefined
kompression: KompressionStand | null
wartePlatz: number
/** Der Automatik-Countdown (5.5.0). */
automatik: AutomatikStand | undefined
/** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache
* räumte sonst Kachel UND Dialog ab. */
aufRippen: () => void
aufAendern: () => void
}) {
const [protokollOffen, setProtokollOffen] = useState(false)
const [berichtOffen, setBerichtOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const discDa = geraet.status === 'ready'
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
const erkannt = discDa && info !== undefined && info.titel.length > 0
const logo = erkannt ? logoUrl(info.logoPfad) : ''
const rettungMoeglich = discDa && !ripLaeuft && rip !== undefined && rip.rettungMoeglich === true
const berichtTon =
bericht === undefined
? ''
: bericht.urteil === 'ruhig'
? 'text-gruen border-gruen/40 bg-gruen/10'
: bericht.urteil === 'disc'
? 'text-warn border-warn/45 bg-warn/10'
: bericht.urteil === 'hardware'
? 'text-rot border-rot/45 bg-rot/10'
: 'text-dunst border-tinte/12 bg-samt/60'
return (
<div className="relative overflow-hidden rounded-2xl border border-tinte/10 bg-karte/50">
{backdrop.length > 0 && (
<>
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-buehne via-buehne/85 to-buehne/45" />
<div className="absolute inset-0 bg-gradient-to-t from-buehne via-transparent to-transparent" />
</>
)}
<div className="relative p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span
className={`inline-block h-2 w-2 rounded-full ${
ripLaeuft
? 'animate-pulse bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]'
: discDa
? 'bg-gruen'
: geraet.status === 'empty'
? 'bg-tinte/25'
: 'bg-rot'
}`}
/>
<span className="text-dunst">LAUFWERK {geraet.id}:</span>
<span>·</span>
<span className="truncate">{geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'}</span>
</div>
<div className="flex flex-none gap-2">
{ripLaeuft ? (
<button
onClick={() => ripAbbrechen(geraet.id)}
className="inline-flex items-center gap-2 rounded-xl border border-rot/60 bg-rot/10 px-4 py-1.5 text-xs font-semibold text-rot hover:bg-rot/20"
>
<IconStop />
Abbrechen
</button>
) : (
<button
onClick={aufRippen}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
</button>
)}
<button
onClick={() => auswerfen(geraet.id)}
disabled={!discDa || ripLaeuft}
className="inline-flex items-center gap-2 rounded-xl border border-tinte/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
<IconEject />
Auswerfen
</button>
</div>
</div>
{!discDa && (
<div className="mt-5 flex items-center gap-5 py-5">
<IconDisc groesse={52} farbe={geraet.status === 'empty' ? 'oklch(0.4 0.02 75)' : 'oklch(0.68 0.19 25)'} />
<p className={`max-w-2xl font-mono text-xs leading-5 ${geraet.status === 'empty' ? 'text-nebel' : 'text-rot'}`}>
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund}
</p>
</div>
)}
{discDa && (
<div className="mt-4 flex gap-5">
{poster.length > 0 ? (
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
) : (
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-tinte/10">
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
</div>
)}
<div className="min-w-0 flex-1">
{/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat
der größte optische Sprung. Titel und Jahr bleiben lesbar. */}
{erkannt ? (
logo.length > 0 ? (
<div className="flex flex-wrap items-end gap-x-3 gap-y-1">
<img src={logo} alt={info.titel} className="max-h-16 max-w-[380px] object-contain object-left drop-shadow-[0_2px_12px_rgba(0,0,0,0.75)]" />
<span className="pb-0.5 text-sm text-dunst">
{info.titel}
{info.jahr !== null && ` (${info.jahr})`}
</span>
</div>
) : (
<h3 className="truncate font-display text-[26px] font-bold leading-tight text-schnee drop-shadow">
{info.titel}
{info.jahr !== null && <span className="ml-2 font-medium text-dunst">({info.jahr})</span>}
</h3>
)
) : (
<h3 className="font-display text-[26px] font-bold leading-tight text-schnee">{TYP_NAMEN[geraet.typ]}</h3>
)}
{erkannt && info.tagline.length > 0 && <p className="mt-1 font-display text-[15px] italic text-gold-hell/90">{info.tagline}</p>}
{erkannt && metaZeile(info).length > 0 && <p className="mt-1 font-mono text-[11px] tracking-wide text-dunst">{metaZeile(info)}</p>}
{erkannt && info.beschreibung.length > 0 && <p className="mt-1.5 line-clamp-2 max-w-2xl text-xs leading-5 text-dunst">{info.beschreibung}</p>}
{!erkannt && (
<p className="mt-1.5 font-mono text-xs text-nebel">
{geraet.typ === 'cd' ? 'Titel kommen beim Rippen von MusicBrainz.' : 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
</p>
)}
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
breiten vorher immer vier, also im schmalen gequetscht. */}
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
<InfoKachel
label="ERKENNUNG"
wert={erkannt ? `${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}` : '—'}
farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined}
/>
<InfoKachel label="PRESET" wert={presetAnzeige(geraet.typ, verbindung)} />
</div>
{/* Darsteller-Streifen (5.4.0) */}
{erkannt && info.besetzung.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{info.besetzung.map((d) => (
<div key={`${d.name}-${d.rolle}`} className="flex items-center gap-2" title={d.rolle}>
{d.bildPfad.length > 0 ? (
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-tinte/10">
{d.name
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase()}
</span>
)}
<span className="leading-tight">
<span className="block text-[11px] text-schnee">{d.name}</span>
{d.rolle.length > 0 && <span className="block text-[10px] text-nebel">{d.rolle}</span>}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={aufAendern}
disabled={ripLaeuft}
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
<IconStift />
Ändern
</button>
<button
onClick={() => {
setBerichtOffen(true)
laufwerkPruefen(geraet.id)
}}
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
>
Laufwerk prüfen
</button>
{rettungMoeglich && (
<button
onClick={() => rettungStarten(geraet.id)}
className="inline-flex items-center gap-2 rounded-lg border border-warn/50 bg-warn/12 px-3 py-1 text-xs font-semibold text-warn hover:bg-warn/20"
title="Rippy hat an dieser Disc Lesefehler gesehen: erst ein Abbild Sektor für Sektor (Unlesbares mit Nullen), dann derselbe Rip aus dem Abbild — es kann nicht mehr hängen. Braucht doppelten Platz."
>
Rettungs-Abbild
</button>
)}
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${datumKurz(info.schonGerippt).slice(0, 10)}`} ton="gelb" />}
</div>
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
{erkannt && info.hinweise.length > 0 && (
<div className="mt-2 space-y-0.5">
{info.hinweise.map((h) => (
<p
key={h}
className={`font-mono text-[11px] ${h.includes('NICHT') ? 'text-warn' : h.includes('bestätigt') || h.includes('passt') ? 'text-gruen' : 'text-nebel'}`}
>
{h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '}
{h}
</p>
))}
</div>
)}
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{geraet.grund}</p>}
</div>
</div>
)}
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt oder der
Grund, warum Rippy wartet. */}
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-tinte/10 bg-samt/60'}`}>
{automatik.aktiv && (
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
{automatik.sekunden}
<span className="text-sm text-gold/70"> s</span>
</span>
)}
<p className={`min-w-0 flex-1 font-mono text-[11px] leading-5 ${automatik.aktiv ? 'text-gold-hell' : 'text-nebel'}`}>{automatik.text}</p>
{automatik.aktiv && (
<div className="flex gap-2">
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
Jetzt
</button>
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Stopp
</button>
</div>
)}
</div>
)}
{/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt
von selbst nach einem gescheiterten Rip oder auf Knopfdruck. */}
{bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && (
<div className={`mt-4 rounded-xl border p-3 ${berichtTon}`}>
<div className="flex items-start justify-between gap-3">
<p className="font-mono text-[11px] leading-5">
<span className="font-semibold">
{bericht.urteil === 'ruhig' ? 'LAUFWERK RUHIG' : bericht.urteil === 'disc' ? 'VERDACHT: DIE DISC' : bericht.urteil === 'hardware' ? 'VERDACHT: DIE HARDWARE' : 'PROTOKOLL'}
</span>{' '}
{bericht.text}
</p>
<button onClick={() => setBerichtOffen(false)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-dunst">
ausblenden
</button>
</div>
{bericht.letzte.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{bericht.letzte.map((z, i) => (
<p key={i} className="font-mono text-[10.5px] text-nebel">
{z}
</p>
))}
</div>
)}
</div>
)}
{rip !== undefined && (
<div className="mt-4 rounded-xl border border-tinte/10 bg-samt/80 p-4 backdrop-blur">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline justify-between gap-3">
<span
className={`font-mono text-[10.5px] tracking-[0.12em] ${
rip.phase === 'fehler' ? 'text-rot' : rip.phase === 'fertig' ? (rip.lesefehler ? 'text-warn' : 'text-gruen') : 'text-gold-hell'
}`}
>
{PHASEN_MONO[rip.phase]}
{rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''}
</span>
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
</div>
{/* 5.6.0: die Vorgangs-Leiste der ganze Weg, hier mit den
Details der Laufwerks-Stationen; nach dem Rip nur der Stand. */}
<VorgangsLeiste stand={leisteAusRip(rip, vorgang, wartePlatz)} />
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
</p>
)}
{rip.rettung !== undefined && (
<p className="mt-1.5 font-mono text-[10.5px] text-warn">
Rettungs-Abbild: {rip.rettung.unlesbareSektoren} unlesbare Sektoren an {rip.rettung.stellen} Stellen mit Nullen gefüllt
</p>
)}
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
wieder ist kein Fortschritt, sondern ein physischer
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
waehrend identische Zeilen durchs Protokoll rauschten. */}
{rip.haengt != null && (
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
<p className="font-mono text-[11px] font-semibold text-warn">Die Disc hat an einer Stelle einen Defekt.</p>
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht
besser der Rest der Disc ist davon nicht betroffen. Nach dem Abbruch bietet Rippy das Rettungs-Abbild an.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
>
Titel überspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
{datei}
</p>
))}
{/* Nach dem Rip (5.6.0, Commander: Nur in der Kompressions-Karte"):
hier nur noch EINE Zeile, wo es weitergeht Balken, Prozent,
Restzeit und Ziel stehen in der Kompressions-Karte, danach im
Verlauf. Fehler bleiben sichtbar (R4). */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<p className={`mt-2 font-mono text-[11px] ${vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? 'text-warn' : 'text-nebel'}`}>
{kompression !== null
? 'Kompression läuft — Stand in der Kompressions-Karte unten.'
: wartePlatz > 0
? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).`
: vorgang.phase === 'fertig'
? 'Kompression fertig — das Ergebnis steht im Verlauf.'
: vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen'
? `${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}`
: `Kompression: ${vorgang.phase}`}
</p>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{rip.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
{protokoll.length > 1 && (
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
</button>
)}
{protokollOffen && (
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
{protokoll.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
&gt; {zeile}
</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,86 @@
// Speicher und Statistik in der Übersicht (5.5.0, Commander: „Ist mir zu
// leer … wie viel Speicher noch auf dem Server oder auf dem Arbeitsordner
// frei ist"). Gemessen im Kern mit fs.statfs — lokal wie auf der Freigabe.
import type { SpeicherPlatz, SpeicherStand, Statistik } from '../../gemeinsam/nachrichten'
import { FuellBalken, dauerGrob, gbKurz } from '../bausteine'
import { speicherLaden } from '../kernverbindung'
/** Ab hier wird gewarnt: eine Blu-ray braucht rund 50 GB (Roh + Kompression). */
export const WENIG_FREI_BYTES = 60e9
export const SEHR_WENIG_FREI_BYTES = 20e9
function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; hinweis: string }) {
const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold'
const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0
return (
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[10px] tracking-[0.14em] text-nebel">{name}</span>
<span className={`font-mono text-[13px] font-semibold ${ton === 'rot' ? 'text-rot' : ton === 'warn' ? 'text-warn' : 'text-schnee'}`}>
{platz.ok ? `${gbKurz(platz.freiBytes)} frei` : 'nicht messbar'}
</span>
</div>
<div className="mt-2">
<FuellBalken anteil={belegt} ton={ton} />
</div>
<p className="mt-1.5 break-all font-mono text-[10.5px] text-nebel" title={platz.pfad}>
{platz.pfad}
{platz.ok && ` · ${gbKurz(platz.gesamtBytes)} gesamt`}
{!platz.ok && platz.fehler.length > 0 && ` · ${platz.fehler}`}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-[10.5px] text-nebel/80">{hinweis}</span>
{platz.ok && (
<button onClick={() => void window.rippy.ordnerOeffnen(platz.pfad)} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Im Explorer zeigen">
Ordner öffnen
</button>
)}
</div>
</div>
)
}
export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) {
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">SPEICHER UND BILANZ</span>
<button onClick={() => speicherLaden()} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Jetzt neu messen">
neu messen
</button>
</div>
{speicher === null ? (
<p className="mt-3 font-mono text-xs text-nebel">wird gemessen </p>
) : (
<div className={`mt-3 grid gap-3 ${speicher.getrennt ? 'sm:grid-cols-2' : ''}`}>
{speicher.getrennt ? (
<>
<Platz name="ARBEITSORDNER (LOKAL)" platz={speicher.arbeit} hinweis="Roh-Dateien und Kompression — eine Blu-ray braucht rund 50 GB." />
<Platz name="ABLAGE (SERVER)" platz={speicher.ablage} hinweis="Fertige Filme, Serien und Musik." />
</>
) : (
<Platz name="ABLAGE UND ARBEIT" platz={speicher.ablage} hinweis="Roh-Dateien, Kompression und Fertiges am selben Ort — ein eigener Arbeitsordner geht unter Einstellungen → Ablage." />
)}
</div>
)}
{statistik !== null && statistik.discs > 0 && (
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Zahl label="DISCS" wert={String(statistik.discs)} zusatz={`${statistik.fertige} fertig`} />
<Zahl label="VERLUSTFREI GESICHERT" wert={gbKurz(statistik.rohBytes)} zusatz="Summe aller Rips" />
<Zahl label="FERTIG ABGELEGT" wert={gbKurz(statistik.fertigBytes)} zusatz="komprimiert oder verschoben" />
<Zahl label="JE DISC" wert={statistik.fertige > 0 ? dauerGrob(statistik.dauerS / statistik.fertige) : '—'} zusatz="Rip bis Ablage, im Schnitt" />
</div>
)}
</div>
)
}
function Zahl({ label, wert, zusatz }: { label: string; wert: string; zusatz: string }) {
return (
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className="mt-0.5 font-display text-[19px] font-bold leading-tight text-gold-hell">{wert}</div>
<div className="font-mono text-[10px] text-nebel/80">{zusatz}</div>
</div>
)
}
@@ -0,0 +1,184 @@
// Die Übersicht — Laufwerke, Warteschlange, Speicher und Bilanz, Verlauf,
// Ereignisse. Und die Dialoge: Sie leben im Zustand DIESES Bereichs, nicht
// in der Kachel (5.4.0: ein leerer Takt der Wache räumte sonst Kachel UND
// Dialog ab).
import { useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import { IconDisc, LAUFENDE_PHASEN } from '../bausteine'
import { AendernDialog } from '../dialoge/AendernDialog'
import { TitelWahlDialog } from '../dialoge/TitelWahlDialog'
import { automatikStopp, titelLesen, type Verbindung } from '../kernverbindung'
import { KompressionsKarte } from './KompressionsKarte'
import { LaufwerkKachel } from './LaufwerkKachel'
import { SpeicherKarte } from './SpeicherKarte'
import { VerlaufKarte } from './VerlaufKarte'
export function Uebersicht({
verbindung,
haupt,
systemFehler,
aufRohDateien,
}: {
verbindung: Verbindung
haupt: HauptStatus | null
systemFehler: string[]
aufRohDateien: () => void
}) {
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
const [updateHinweis, setUpdateHinweis] = useState('')
const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null)
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<>
{systemFehler.length > 0 && (
<div className="rounded-2xl border border-rot/40 bg-rot/10 p-4">
{systemFehler.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-rot">
{zeile}
</p>
))}
</div>
)}
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
Satz in den Einstellungen. Der Commander musste es am
01.09.2026 suchen gefunden hat er es nicht. */}
{haupt !== null && haupt.update.neueVersion.length > 0 && (
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-lg text-gold-hell">Update verfügbar Version {haupt.update.neueVersion}</span>
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-tinte/10">
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
</div>
)}
{haupt.update.bereit && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setUpdateHinweis('')
void window.rippy.updateInstallieren().then((urteil) => {
if (!urteil.ok) setUpdateHinweis(urteil.grund)
})
}}
disabled={einRipLaeuft}
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
title={einRipLaeuft ? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.' : 'Rippy beendet sich, installiert und startet wieder.'}
>
Jetzt neu starten und installieren
</button>
<span className="font-mono text-[11px] text-nebel"> oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.</span>
</div>
)}
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
</div>
)}
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-tinte/10 bg-karte/50 p-6">
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
</div>
) : (
<div className="space-y-4">
{verbindung.laufwerke.map((geraet) => {
const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? ''
const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null
const wartend = verbindung.vorgaenge?.kompression.wartend ?? []
return (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
ripStartMs={verbindung.ripStart[geraet.id]}
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
verbindung={verbindung}
bericht={verbindung.gesundheit[geraet.id]}
vorgang={verbindung.vorgaenge?.vorgaenge.find((v) => v.id === ripVorgangId)}
kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null}
wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1}
automatik={verbindung.automatik[geraet.id]}
aufRippen={() => {
// Der Nutzer entscheidet selbst — ein Countdown endet.
automatikStopp(geraet.id)
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
setDialog({ art: 'titel', id: geraet.id })
titelLesen(geraet.id)
}}
aufAendern={() => {
automatikStopp(geraet.id)
setDialog({ art: 'aendern', id: geraet.id })
}}
/>
)
})}
</div>
)}
</section>
{verbindung.vorgaenge !== null && (
<section>
<KompressionsKarte stand={verbindung.vorgaenge.kompression} />
</section>
)}
<section>
<SpeicherKarte speicher={verbindung.speicher} statistik={verbindung.vorgaenge?.statistik ?? null} />
</section>
{verbindung.vorgaenge !== null && (
<section>
<VerlaufKarte vorgaenge={verbindung.vorgaenge.vorgaenge} aufAlle={aufRohDateien} />
</section>
)}
{dialog !== null && dialog.art === 'titel' && (
<TitelWahlDialog
id={dialog.id}
typ={verbindung.laufwerke.find((l) => l.id === dialog.id)?.typ ?? 'bluray'}
stand={verbindung.discTitel[dialog.id]}
info={verbindung.discInfos[dialog.id]}
einstellungen={verbindung.einstellungen}
regel={verbindung.vorgaenge?.regel ?? ''}
speicher={verbindung.speicher}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{dialog !== null && dialog.art === 'aendern' && (
<AendernDialog
id={dialog.id}
startText={verbindung.discInfos[dialog.id]?.titel ?? ''}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{verbindung.meldungen.length > 0 && (
<section>
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-tinte/10 bg-karte/50 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[11px] text-dunst">
{zeile}
</p>
))}
</div>
</section>
)}
</>
)
}
@@ -0,0 +1,64 @@
// Der Verlauf (5.5.0) — die letzten Vorgänge auf einen Blick, statt der
// Bibliothek (Commander: „Brauchen wir die wirklich?" — nein: Jellyfin IST
// die Bibliothek; die Wiedererkennung braucht keine Ansicht).
import type { GeraeteInfo, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { leisteAusVorgang } from '../../gemeinsam/vorgangsleiste'
import { Badge, TYP_NAMEN, datumKurz, gb } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
const PHASEN_TEXT: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
wartet: 'wartet',
komprimiert: 'komprimiert',
'legt-ab': 'legt ab',
fertig: 'fertig',
fehler: 'Fehler',
abgebrochen: 'abgebrochen',
}
export function phasenTon(p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' {
return p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau'
}
export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag[]; aufAlle: () => void }) {
if (vorgaenge.length === 0) return null
const letzte = vorgaenge.slice(0, 6)
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">VERLAUF</span>
<span>·</span>
<span>die letzten {letzte.length} Vorgänge</span>
<button onClick={aufAlle} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst">
alle unter Roh-Dateien
</button>
</div>
<div className="mt-3 space-y-1">
{letzte.map((v) => (
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-display text-[14px] font-bold text-schnee" title={v.titel}>
{v.titel}
{v.jahr !== null && <span className="ml-1 font-medium text-dunst">({v.jahr})</span>}
</span>
<VorgangsLeiste stand={leisteAusVorgang(v)} kompakt />
<Badge text={PHASEN_TEXT[v.phase]} ton={phasenTon(v.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[v.typ as GeraeteInfo['typ']] ?? v.typ).toUpperCase()} · {datumKurz(v.begonnen)}
{v.phase === 'fertig' && v.fertigBytes > 0 && ` · ${gb(v.fertigBytes)}`}
{!v.rohGeloescht && v.rohBytes > 0 && ` · Roh ${gb(v.rohBytes)}`}
</span>
{v.phase === 'fertig' && v.zielOrdner.length > 0 && (
<button
onClick={() => void window.rippy.ordnerOeffnen(v.zielOrdner)}
className="font-mono text-[10.5px] text-nebel hover:text-dunst"
title={v.zielOrdner}
>
Ordner öffnen
</button>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,90 @@
// Die Vorgangs-Leiste (5.6.0) — der ganze Weg einer Disc in Stationen, mit
// dem Segment-Balken in der aktiven Station. Logik in
// gemeinsam/vorgangsleiste.ts; hier nur das Zeichnen. `kompakt` = Punkte
// (Verlauf, Warteschlange).
import { Fragment } from 'react'
import { STATIONS_NAMEN, stationen, stationsZustand, type LeisteStand } from '../../gemeinsam/vorgangsleiste'
import { SegmentBalken } from '../bausteine'
export function VorgangsLeiste({ stand, restText, kompakt }: { stand: LeisteStand; restText?: string; kompakt?: boolean }) {
const liste = stationen(stand.rettung)
const zustaende = stationsZustand(liste, stand.station, stand.zustand)
const titel = `${STATIONS_NAMEN[stand.station]}: ${
stand.zustand === 'laeuft' ? 'läuft' : stand.zustand === 'wartet' ? 'wartet' : stand.zustand === 'fertig' ? 'fertig' : stand.zustand === 'fehler' ? 'Fehler' : 'abgebrochen'
}`
if (kompakt) {
return (
<span className="inline-flex flex-none items-center gap-1" title={titel} aria-label={titel}>
{liste.map((s, i) => (
<span
key={s}
className={`inline-block h-1.5 w-1.5 rounded-full ${
zustaende[i] === 'fertig'
? 'bg-gold'
: zustaende[i] === 'aktiv'
? stand.zustand === 'wartet'
? 'bg-gold/50'
: 'animate-pulse bg-gold-hell'
: zustaende[i] === 'fehler'
? 'bg-rot'
: 'bg-tinte/20'
}`}
/>
))}
</span>
)
}
return (
<div className="flex flex-wrap items-center gap-1.5" title={titel}>
{liste.map((s, i) => {
const z = zustaende[i]
const aktiv = z === 'aktiv'
const rahmen =
z === 'fehler'
? 'border-rot/50 bg-rot/10'
: aktiv
? 'min-w-[200px] flex-1 border-gold/50 bg-gold/10'
: z === 'fertig'
? 'border-gold/30 bg-gold/5'
: 'border-tinte/10'
return (
<Fragment key={s}>
{i > 0 && <span className={`h-px w-3 flex-none ${zustaende[i - 1] === 'fertig' ? 'bg-gold/60' : 'bg-tinte/15'}`} />}
<div className={`flex items-center gap-2 rounded-lg border px-2 py-1 ${rahmen}`}>
<span
className={`flex h-4 w-4 flex-none items-center justify-center rounded-full font-mono text-[9px] ${
z === 'fertig'
? 'bg-gold text-buehne'
: aktiv
? 'bg-gold-hell text-buehne'
: z === 'fehler'
? 'bg-rot text-buehne'
: 'border border-tinte/20 text-nebel'
}`}
>
{z === 'fertig' ? '✓' : z === 'fehler' ? '!' : String(i + 1)}
</span>
<span className={`font-mono text-[10px] tracking-[0.12em] ${aktiv ? 'text-gold-hell' : z === 'fertig' ? 'text-gold/80' : z === 'fehler' ? 'text-rot' : 'text-nebel'}`}>
{STATIONS_NAMEN[s].toUpperCase()}
</span>
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && (
<div className="min-w-[80px] flex-1">
<SegmentBalken prozent={stand.prozent} />
</div>
)}
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && <span className="font-mono text-[10.5px] text-gold-hell">{stand.prozent} %</span>}
{aktiv && stand.zustand === 'laeuft' && stand.prozent < 0 && stand.hinweis.length === 0 && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-gold-hell" />
)}
{aktiv && restText !== undefined && restText.length > 0 && <span className="font-mono text-[10px] text-nebel">{restText}</span>}
{aktiv && stand.hinweis.length > 0 && <span className="font-mono text-[10px] text-nebel">{stand.hinweis}</span>}
{z === 'fehler' && <span className="font-mono text-[10px] text-rot">{stand.zustand === 'abgebrochen' ? 'abgebrochen' : 'Fehler'}</span>}
</div>
</Fragment>
)
})}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
// baut auf diesen Namen ihre echten Ordner.
// 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
}
export function staffelOrdnerName(staffel: number): string {
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
}
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
const s = String(Math.trunc(staffel)).padStart(2, '0')
const e = String(Math.trunc(folge)).padStart(2, '0')
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
}
export interface VorschauEingabe {
ablage: string
alsSerie: boolean
titel: string
jahr: number | null
staffel: number | null
ersteFolge: number | null
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
hauptTitel: number
extras: number
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
vorgangsName: string
}
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
* weil die Vorschau eine Windows-Ablage beschreibt. */
export function zielVorschau(e: VorschauEingabe): [string, string] {
const wurzel = e.ablage.replace(/[\\/]+$/, '')
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
if (e.alsSerie) {
const staffel = e.staffel ?? 1
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
const n = Math.max(1, e.hauptTitel)
let inhalt: string
if (e.ersteFolge !== null && e.ersteFolge > 0) {
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
inhalt =
n === 1
? erste
: `${erste}${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
} else {
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
}
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
return [ordner, inhalt]
}
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
const teile: string[] = []
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
}
+805
View File
@@ -0,0 +1,805 @@
// 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)
//
// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union
// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die
// Nachricht, und niemand erfährt es.
/** 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'
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
| 'rettet'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) die Kompression läuft
* danach unter dieser Kennung in der Warteschlange weiter. */
vorgangId?: string
/** 5.5.0: Rippy hat an dieser Disc Lesefehler GESEHEN (Defekt-Wächter,
* Lesefehler-Meldung, Leseabbruch) erst dann wird das Rettungs-Abbild
* angeboten. Eine heile Disc braucht es nicht (Commander 01.09.2026). */
rettungMoeglich?: boolean
/** 5.5.0: Stand des Rettungs-Abbilds — unlesbare Sektoren und Stellen. */
rettung?: { unlesbareSektoren: number; stellen: number }
}
/** Was ein Titel IST davon hängt ab, WOHIN er abgelegt wird:
* hauptfilm Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
* extra Filme/<Titel> (Jahr)/extras/ (Jellyfin/Emby/Kodi)
* folge Serien/<Titel>/Season NN/ S01E02.mkv
* EINE Definition für Kern und Fenster. */
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat
* (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren
* dieser Sprache es gibt (Stereo + 5.1 = 2). */
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
/** EINE Spur eines Titels, wie der Info-Lauf sie gemessen hat (5.7.0,
* Commander-Entscheid 12.09.2026: jede Spur einzeln anhaken"). Die
* Attribute stammen aus SINFO (parser.ts): 3 Sprachcode, 4 Sprachname,
* 6 Codec, 14 Kanäle, 2 Anordnung, 30 Beschreibung, 22 Flags. */
export interface SpurAngabe {
/** MakeMKVs Stream-Nummer innerhalb des Titels. */
nr: number
lang: string
sprache: string
/** „DTS-HD MA", „TrueHD", „DD", „PGS", „VobSub" … */
codec: string
/** Kanalzahl als Text („6", „2"); leer bei Untertiteln. */
kanaele: string
/** „Surround 5.1", „Stereo" — Attribut 2. */
layout: string
/** Attribut 30, z. B. „DTS-HD MA Surround 5.1 German" oder „PGS German (nur erzwungene)". */
beschreibung: string
/** Bit 0x800 in Attribut 22 (an der Kizuna-Disc gemessen): eine von
* MakeMKV ABGELEITETE Spur der DTS-Kern einer HD-Spur (nicht in der
* Roh-Datei) oder die nur erzwungene"-Fassung eines Untertitels (in der
* Roh-Datei, wählbar). */
abgeleitet: boolean
}
/** Ein Titel im Rip-Auftrag: welcher, als was und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
* bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
nr: number
rolle: TitelRolle
dauerS: number
/** Größe laut Info-Lauf gewichtet den durchgehenden Fortschrittsbalken
* (5.4.0). Fehlt: alle Titel zählen gleich. */
groesseBytes?: number
/** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
audioSprachen?: string[]
untertitelSprachen?: string[]
/** 5.6.0, nur für Extras: WELCHE Untertitel-Sprache beim Abspielen
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
* fehlend = Rippys Regel (fremder Ton Wunschsprache). */
untertitelVorwahl?: string | null
/** 5.7.0: die GEWÄHLTEN Spuren mit allem, was die Ablage braucht, um sie
* in der Roh-Datei wiederzufinden. Fehlend: die Sprachlisten gelten. */
audioSpuren?: SpurAngabe[]
untertitelSpuren?: SpurAngabe[]
/** 5.7.0: die vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
untertitelVorwahlSpur?: number | null
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
* (Double Feature) er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
film?: MetaVorschlag
}
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
* Staffel und erste Folge von Hand Rippy rät bewusst nicht, aber der
* Nutzer weiß, dass auf Disc 2 die Folgen 34 sind. */
export interface RipAuftragOptionen {
/** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */
staffel?: number | null
/** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend:
* Zuordnung über die TMDb-Laufzeiten wie bisher. */
ersteFolge?: number | null
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
rolle: TitelRolle
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
/** 5.7.0: jede Spur einzeln — der Dialog zeigt sie zum Anhaken. */
spuren: { audio: SpurAngabe[]; untertitel: SpurAngabe[] }
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
* deshalb trägt der Stand ein eigenes `laeuft` das Fenster steht in der
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
export interface TitelStand {
id: string
laeuft: boolean
titel: DiscTitelVorschlag[]
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
einordnung: string
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
fehler: string
}
/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */
export interface Darsteller {
name: string
rolle: string
/** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */
bildPfad: string
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; 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
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst 0, wenn es keines
* gibt. Ab zwei gilt Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
/** 'movie' | 'tv' | '' — was die Zuordnung sagt. */
typ: string
// ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ──
/** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */
logoPfad: string
tagline: string
/** TMDb vote_average (0 = keine). */
bewertung: number
/** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */
fsk: string
laufzeitMinuten: number
genres: string[]
besetzung: Darsteller[]
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat (Laufzeit
* bestätigt: 124 min"), Klartext-Zeilen. */
hinweise: string[]
/** 5.5.0 Serien-Merker: Was Rippy von der VORIGEN Disc dieser Serie weiß
* Staffel und die Folge, mit der es weitergeht. null, wenn nichts. */
serienMerker: { staffel: number; naechsteFolge: number; wann: string } | null
}
/** Ein Eintrag der Bibliothek (§ 6.6) seit 5.5.0 nur noch die
* Wiedererkennung (schon gerippt am "), keine eigene Ansicht mehr. */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad — '' 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
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
/** 5.7.0 (F16): Rippy wartet auf ein Ja/Nein z. B. Archiv-Kopie laden?".
* Fehlt: keine Frage offen. */
frage?: string
}
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die
* Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER
* verschoben). */
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen Roh-Dateien,
* Übersicht Verlauf). */
export interface VorgangEintrag {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
/** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */
rohBytes: number
rohGeloescht: boolean
zielOrdner: string
fertigBytes: number
komprimiert: boolean
preset: string
fehler: string
titelZahl: number
/** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */
neuKomprimierbar: boolean
/** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */
faelligAm: string
}
export interface KompressionStand {
vorgangId: string
titel: string
/** 0100; -1 = keine Angabe. */
prozent: number
text: string
phase: VorgangPhase
/** 5.6.0: Restzeit der laufenden Datei in Sekunden, wie HandBrake sie
* meldet (ETA); fehlend/-1 = noch keine Angabe. */
restS?: number
}
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
* 5.4.0 oder von Hand hineingelegt) nur von Hand löschbar. */
export interface FremderOrdner {
ordner: string
bytes: number
}
/** 5.5.0: Was Rippy insgesamt geschafft hat (aus den Vorgängen). */
export interface Statistik {
/** Vorgänge insgesamt (Video-Discs). */
discs: number
/** Davon fertig abgelegt. */
fertige: number
/** Verlustfrei gesichert, Summe aller Rips (auch wenn Roh inzwischen weg ist). */
rohBytes: number
/** Fertig abgelegt, Summe. */
fertigBytes: number
/** Summe der Dauer je fertigem Vorgang (Rip-Beginn bis Ablage-Ende) in Sekunden. */
dauerS: number
}
export interface VorgaengeStand {
vorgaenge: VorgangEintrag[]
/** Summe aller Roh-Ordner, die noch da sind. */
rohGesamtBytes: number
fremde: FremderOrdner[]
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
regel: string
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[]; pausiert: boolean }
statistik: Statistik
}
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
export interface LaufwerkBericht {
/** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */
urteil: string
text: string
fehlerhafteBloecke: number
controllerfehler: number
sonstige: number
/** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */
letzte: string[]
/** Betrachteter Zeitraum in Stunden. */
stunden: number
}
/** 5.5.0: Freier Platz an EINEM Ort — gemessen mit fs.statfs (Node 24). */
export interface SpeicherPlatz {
pfad: string
ok: boolean
freiBytes: number
gesamtBytes: number
/** '' wenn ok — sonst, warum nicht messbar (Pfad fehlt, Netz weg). */
fehler: string
}
export interface SpeicherStand {
/** Wo Roh-Dateien und Kompression liegen (Arbeitsordner oder Ablage). */
arbeit: SpeicherPlatz
/** Wo Fertiges landet (die Ablage — beim Commander der Server). */
ablage: SpeicherPlatz
/** true, wenn ein eigener Arbeitsordner gesetzt ist. */
getrennt: boolean
}
/** 5.5.0 Automatik: Countdown bis zum Rip ohne Klick (KONZEPT § 5 Zero-Click). */
export interface AutomatikStand {
id: string
aktiv: boolean
sekunden: number
text: string
}
/** 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 }
/** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): Roh-
* Ordner von X gelöscht", „Kompression eingereiht" Auskunft, kein Alarm. */
| { art: 'kern-hinweis'; 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: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { 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
}
}
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
* (5.4.0) geht auch ans Haupt (Taskleiste, Updates warten). */
| { art: 'vorgaenge'; stand: VorgaengeStand }
/** 5.7.0 (F7): NUR die Warteschlange jeder Prozent-Tick der Kompression
* kommt so, ohne den teuren vollen Vorgangs-Stand (Ordnerläufe, statfs). */
| { art: 'kompression-stand'; kompression: VorgaengeStand['kompression'] }
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
/** 5.5.0: freier Platz an Arbeitsordner und Ablage. */
| { art: 'speicher'; stand: SpeicherStand }
/** 5.5.0: Automatik-Countdown je Laufwerk. */
| { art: 'automatik'; stand: AutomatikStand }
/** 5.5.0: Poster beliebter Filme für den Hintergrund der Erst-Einrichtung. */
| { art: 'poster-vorschau'; pfade: string[] }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
/** Titel-Lauf anstoßen (§ 6.2): dauert 20120 s, deshalb erst auf Zuruf
* und nicht schon beim Einlegen (der 30.08.-Fund das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** 5.7.0: den laufenden Titel-Lauf abbrechen (Kandidat 2). */
| { art: 'titel-abbruch'; id: string }
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-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' }
/** 5.7.0 (F16): Antwort auf eine Frage der MakeMKV-Beschaffung. */
| { art: 'beschaffung-antwort'; ja: boolean }
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
| { art: 'vorgaenge-laden' }
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
| { art: 'vorgang-roh-loeschen'; vorgangId: string }
/** Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel
* kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */
| { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string }
/** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */
| { art: 'kompression-abbrechen'; vorgangId: string }
/** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */
| { art: 'roh-aufraeumen' }
/** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */
| { art: 'fremden-ordner-loeschen'; ordner: string }
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
| { art: 'laufwerk-pruefen'; id: string }
// ── 5.5.0 ──
/** Die Kompressions-Warteschlange anhalten (an) oder weiterlaufen lassen. */
| { art: 'kompression-pause'; an: boolean }
/** Den Automatik-Countdown dieses Laufwerks stoppen bzw. sofort rippen. */
| { art: 'automatik-stopp'; id: string }
| { art: 'automatik-jetzt'; id: string }
/** Testnachricht über die eingerichteten Kanäle (Discord, Telegram). */
| { art: 'benachrichtigung-testen' }
/** Poster beliebter Filme laden (Erst-Einrichtung). */
| { art: 'poster-laden' }
/** Rettungs-Abbild für eine Disc mit GESEHENEN Lesefehlern. */
| { art: 'rettung-start'; id: string }
/** Freien Platz neu messen. */
| { art: 'speicher-laden' }
/** 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). */
update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
/** 5.5.0: Wo die Protokoll-Dateien liegen — für „Protokoll öffnen". */
protokollOrdner: string
}
/** Der Stand des Selbst-Updates (§ 6.8) EINE Definition, die sich Haupt
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
neueVersion: string
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
neuerungen: string
/** Geladen und wartet auf den Neustart. */
bereit: boolean
/** Eine Prüfung oder ein Download läuft gerade. */
laeuft: boolean
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
fortschritt: number
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
function istText(wert: unknown): wert is string {
return typeof wert === 'string'
}
function istKennung(wert: unknown): wert is string {
return typeof wert === 'string' && wert.length > 0
}
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':
case 'kern-hinweis':
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 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case '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)
case 'vorgaenge':
return (
istObjekt(wert.stand) &&
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
istObjekt((wert.stand as Record<string, unknown>).statistik)
)
case 'kompression-stand':
return istObjekt(wert.kompression) && Array.isArray((wert.kompression as Record<string, unknown>).wartend)
case 'kompression-ergebnis':
return (
istKennung(wert.vorgangId) &&
typeof wert.titel === 'string' &&
typeof wert.ok === 'boolean' &&
typeof wert.text === 'string'
)
case 'laufwerk-gesundheit':
return (
istKennung(wert.id) &&
istObjekt(wert.bericht) &&
typeof (wert.bericht as Record<string, unknown>).urteil === 'string' &&
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
)
case 'speicher':
return (
istObjekt(wert.stand) &&
istObjekt((wert.stand as Record<string, unknown>).arbeit) &&
istObjekt((wert.stand as Record<string, unknown>).ablage) &&
typeof (wert.stand as Record<string, unknown>).getrennt === 'boolean'
)
case 'automatik':
return (
istObjekt(wert.stand) &&
istKennung((wert.stand as Record<string, unknown>).id) &&
typeof (wert.stand as Record<string, unknown>).aktiv === 'boolean' &&
typeof (wert.stand as Record<string, unknown>).sekunden === 'number' &&
typeof (wert.stand as Record<string, unknown>).text === 'string'
)
case 'poster-vorschau':
return Array.isArray(wert.pfade) && wert.pfade.every(istText)
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
}
/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt die Sprachlisten
* sind optional, aber wenn sie da sind, müssen es Textlisten sein. */
export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (!istObjekt(wert)) return false
if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false
if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false
if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false
for (const feld of ['audioSprachen', 'untertitelSprachen']) {
const liste = wert[feld]
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false
}
for (const feld of ['audioSpuren', 'untertitelSpuren']) {
const liste = wert[feld]
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istSpurAngabe)) return false
}
if (wert.untertitelVorwahlSpur !== undefined && wert.untertitelVorwahlSpur !== null && typeof wert.untertitelVorwahlSpur !== 'number') return false
return true
}
export function istSpurAngabe(wert: unknown): wert is SpurAngabe {
if (!istObjekt(wert)) return false
return typeof wert.nr === 'number' && typeof wert.lang === 'string' && typeof wert.codec === 'string' && typeof wert.abgeleitet === 'boolean'
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'rip-start') {
if (!istKennung(wert.id)) return false
if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) {
return false
}
if (wert.optionen !== undefined) {
if (!istObjekt(wert.optionen)) return false
const o = wert.optionen
const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number'
if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false
}
return true
}
if (
wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' ||
wert.art === 'titel-abbruch' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen' ||
wert.art === 'laufwerk-pruefen' ||
wert.art === 'automatik-stopp' ||
wert.art === 'automatik-jetzt' ||
wert.art === 'rettung-start'
) {
return istKennung(wert.id)
}
if (wert.art === 'kompression-pause') {
return typeof wert.an === 'boolean'
}
if (wert.art === 'beschaffung-antwort') {
return typeof wert.ja === 'boolean'
}
if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return istKennung(wert.id) && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag)
}
if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') {
return istKennung(wert.vorgangId)
}
if (wert.art === 'vorgang-neu-komprimieren') {
return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string')
}
if (wert.art === 'fremden-ordner-loeschen') {
return istKennung(wert.ordner)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen' ||
wert.art === 'benachrichtigung-testen' ||
wert.art === 'poster-laden' ||
wert.art === 'speicher-laden'
)
}
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
}
+33
View File
@@ -0,0 +1,33 @@
// Die Platz-Prüfung vor dem Rip (5.5.0) — EINE Rechnung für Kern und
// Fenster: Roh plus geschätzt 25 % für die Kompression, plus 2 GB Luft.
// Der Kern urteilt beim Start (index.ts ripMitPlatz — auch für die
// Automatik), der Dialog zeigt dasselbe Urteil schon vor dem Klick
// (TitelWahlDialog).
export const PLATZ_LUFT_BYTES = 2e9
export const KOMPRESSION_ANTEIL = 0.25
export interface PlatzUrteil {
/** Leer, wenn der Platz reicht — sonst der Grund in Klartext. */
text: string
/** true: nicht einmal der verlustfreie Rip passt — der Start wird verweigert. */
hart: boolean
}
export function gbText(bytes: number): string {
return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
}
export function platzUrteil(freiBytes: number, rohBytes: number, komprimiert: boolean): PlatzUrteil {
const noetig = rohBytes + (komprimiert ? rohBytes * KOMPRESSION_ANTEIL : 0) + PLATZ_LUFT_BYTES
if (freiBytes >= noetig) return { text: '', hart: false }
if (freiBytes < rohBytes + PLATZ_LUFT_BYTES) {
return {
text: `Zu wenig Platz: ${gbText(freiBytes)} frei, der Rip braucht ${gbText(rohBytes)} verlustfrei. Das geht schief — erst Platz schaffen (Einstellungen → Roh-Dateien).`,
hart: true,
}
}
return {
text: `Knapp: ${gbText(freiBytes)} frei, gebraucht werden etwa ${gbText(noetig)} (Roh plus Kompression). Aufräumen unter Roh-Dateien hilft.`,
hart: false,
}
}
@@ -0,0 +1,71 @@
// 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'
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
// HandBrake-Preset heißt so (gegen alle 107 aus --preset-list geprüft).
// Lebt seit 5.7.0 hier, weil die Empfehlung ihn liefern kann.
export const PRESET_KEINE = 'keine'
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'],
['nvenc', 'NVENC'],
['qsv', 'QSV'],
]
/** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN
* Backends und Preset-Namen. DVD und Blu-ray fallen auf den CPU-Default
* zurück.
*
* 4K-UHD seit 5.7.0 nach der Leistung (Commander-Entscheid 12.09.2026):
* Gibt es einen Grafikkarten-Encoder UND dessen 4K-Preset, wird damit
* komprimiert; gibt es nur CPU-Encoder, bleibt UHD verlustfrei
* (PRESET_KEINE) ein 4K-Encode auf der CPU dauert Tage, und der alte
* CPU-Default 1080p30" rechnete 4K klein. Gemessen am 12.09.2026: Die
* 4K-Hardware-Presets (H.265 VCN 2160p 4K" usw.) sind 8-Bit; der
* 10-Bit-HEVC-Encoder der AMD-Karte (vce_h265_10bit) stürzt in HandBrake
* 1.11.2 beim Start ab, 10-Bit-AV1 (AV1 VCN 2160p 4K") läuft wer HDR
* in 10 Bit will, wählt das AV1-Preset selbst (Abspielgeräte müssen AV1
* können). */
export function presetEmpfehlung(
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
// Eine 4K-Disc ohne 4K-Preset der Familie: nicht klein rechnen —
// verlustfrei lassen (unten). Bei DVD/Blu-ray ist das 1080p-Pendant
// besser als CPU.
if (discTyp === 'uhd') continue
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner
}
return discTyp === 'uhd' ? PRESET_KEINE : DEFAULT_HB_PRESET
}
/** Klartext zur Empfehlung fürs Fenster (5.7.0). */
export function empfehlungText(discTyp: string, empfehlung: string): string {
if (empfehlung === PRESET_KEINE) return discTyp === 'uhd' ? 'verlustfrei lassen — nur CPU-Encoder gemessen' : 'verlustfrei lassen'
if (discTyp === 'uhd' && /2160p/.test(empfehlung)) return `${empfehlung} (8 Bit)`
return empfehlung
}
+84
View File
@@ -0,0 +1,84 @@
// Die Protokoll-Datei (5.5.0, aus ARM: LOGPATH/LOGLIFE) — reines Node, damit
// Haupt UND Kern sie schreiben können. Eine Datei je Tag unter
// <Profil>\logs\rippy-JJJJ-MM-TT.log, ältere als 14 Tage werden beim Start
// entfernt. Der Grund: Der Datenbank-Befund vom 01.09.2026 (eine Datenbank,
// in die seit dem 30.08. niemand geschrieben hatte) wäre mit einer Zeile
// „Datenbank geöffnet: <Pfad>" in einer Datei sofort sichtbar gewesen —
// die Konsole eines Tray-Programms sieht niemand.
//
// Schreibfehler werden NICHT geworfen (das Protokoll darf nie den Betrieb
// kippen), aber gezählt und beim nächsten Erfolg vermerkt.
import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs'
import { join } from 'node:path'
export const PROTOKOLL_TAGE = 14
export function protokollDateiname(datum: Date): string {
const j = datum.getFullYear()
const m = String(datum.getMonth() + 1).padStart(2, '0')
const t = String(datum.getDate()).padStart(2, '0')
return `rippy-${j}-${m}-${t}.log`
}
/** Zeitstempel lokal, sekundengenau — so, wie der Commander die Uhr liest. */
export function zeitstempel(datum: Date): string {
const z = (n: number): string => String(n).padStart(2, '0')
return `${datum.getFullYear()}-${z(datum.getMonth() + 1)}-${z(datum.getDate())} ${z(datum.getHours())}:${z(datum.getMinutes())}:${z(datum.getSeconds())}`
}
/** Welche Dateien älter als `tage` sind (pur, über den Dateinamen). */
export function veraltet(namen: readonly string[], jetzt: Date, tage = PROTOKOLL_TAGE): string[] {
const grenze = jetzt.getTime() - tage * 24 * 60 * 60 * 1000
return namen.filter((n) => {
const treffer = /^rippy-(\d{4})-(\d{2})-(\d{2})\.log$/.exec(n)
if (treffer === null) return false
const datum = new Date(Number(treffer[1]), Number(treffer[2]) - 1, Number(treffer[3]))
return datum.getTime() < grenze
})
}
export class Protokoll {
private fehlschlaege = 0
constructor(
readonly ordner: string,
private readonly quelle: string,
private readonly jetzt: () => Date = () => new Date(),
) {}
/** Eine Zeile anhängen: „2026-09-02 21:13:05 [kern] Text". */
schreiben(text: string): void {
try {
if (!existsSync(this.ordner)) mkdirSync(this.ordner, { recursive: true })
const datum = this.jetzt()
const nachtrag = this.fehlschlaege > 0 ? ` (davor ${this.fehlschlaege} Zeilen nicht schreibbar)` : ''
this.fehlschlaege = 0
appendFileSync(join(this.ordner, protokollDateiname(datum)), `${zeitstempel(datum)} [${this.quelle}] ${text}${nachtrag}\n`)
} catch {
this.fehlschlaege += 1
}
}
/** Dateien älter als PROTOKOLL_TAGE entfernen — beim Start. */
aufraeumen(): number {
let weg = 0
try {
if (!existsSync(this.ordner)) return 0
for (const name of veraltet(readdirSync(this.ordner), this.jetzt())) {
try {
const pfad = join(this.ordner, name)
if (statSync(pfad).isFile()) {
unlinkSync(pfad)
weg += 1
}
} catch {
// Eine gesperrte Datei bleibt bis zum nächsten Start liegen.
}
}
} catch {
// Nicht lesbar: nichts zu tun, nichts zu melden — das Protokoll ist
// Komfort.
}
return weg
}
}
+56
View File
@@ -0,0 +1,56 @@
// Sprachen für die Auswahl-Felder (5.5.0, Commander: „bei Tonspuren und
// Untertiteln können Dropdowns hin") — ISO-639-2-Codes, wie MakeMKV
// (SINFO-Attribut 3) und HandBrake (--audio-lang-list) sie sprechen, mit
// deutschen Namen. Die REIHENFOLGE der Wahl ist die Wunschreihenfolge: die
// erste Tonsprache ist die, deren Fehlen die Untertitel vorwählt
// (kern/ablauf/auftrag.ts sprachwahl).
export interface Sprache {
code: string
name: string
}
export const SPRACHEN: readonly Sprache[] = [
{ code: 'deu', name: 'Deutsch' },
{ code: 'eng', name: 'Englisch' },
{ code: 'jpn', name: 'Japanisch' },
{ code: 'fra', name: 'Französisch' },
{ code: 'ita', name: 'Italienisch' },
{ code: 'spa', name: 'Spanisch' },
{ code: 'por', name: 'Portugiesisch' },
{ code: 'nld', name: 'Niederländisch' },
{ code: 'pol', name: 'Polnisch' },
{ code: 'tur', name: 'Türkisch' },
{ code: 'rus', name: 'Russisch' },
{ code: 'kor', name: 'Koreanisch' },
{ code: 'zho', name: 'Chinesisch' },
{ code: 'swe', name: 'Schwedisch' },
{ code: 'dan', name: 'Dänisch' },
{ code: 'nor', name: 'Norwegisch' },
{ code: 'fin', name: 'Finnisch' },
{ code: 'ces', name: 'Tschechisch' },
{ code: 'hun', name: 'Ungarisch' },
{ code: 'ell', name: 'Griechisch' },
{ code: 'heb', name: 'Hebräisch' },
{ code: 'ara', name: 'Arabisch' },
{ code: 'hin', name: 'Hindi' },
{ code: 'tha', name: 'Thai' },
]
/** „deu, eng , ,DEU" → ['deu','eng'] — dieselbe Regel wie im Kern (presets.ts). */
export function sprachlisteAus(wert: string): string[] {
const sauber: string[] = []
for (const teil of wert.split(',')) {
const code = teil.trim().toLowerCase()
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
}
return sauber
}
export function sprachName(code: string): string {
return SPRACHEN.find((s) => s.code === code)?.name ?? code
}
/** Die Liste als Einstellung zurück: „deu,eng". */
export function sprachlisteText(codes: readonly string[]): string {
return codes.join(',')
}
+154
View File
@@ -0,0 +1,154 @@
// Spuren einzeln (5.7.0, Commander-Entscheid 12.09.2026: „Jede Spur einzeln
// anhaken — Deutsch · DTS-HD MA 5.1, Deutsch · Kommentar 2.0 getrennt").
// PUR und gemeinsam: Der Dialog zeigt die Zeilen und belegt sie vor, der
// Kern findet die gewählten Spuren nach dem Rip in der Roh-Datei wieder.
//
// Zwei Welten, ein Vergleich:
// * MakeMKV (SINFO, kern/rip/parser.ts): Sprachcode, Codec-Wort („DTS-HD
// MA"), Kanalzahl („6"), Disc-Reihenfolge.
// * HandBrake (--scan --json, kern/komprimieren/scan.ts; am 12.09.2026 am
// mitgelieferten HandBrake 1.11.2 gemessen): LanguageCode, CodecName
// („dtshd", „dca", „truehd", „ac3", „eac3", „aac", „flac", „pcm_…"),
// ChannelCount, TrackNumber — in Datei-Reihenfolge.
// MakeMKV schreibt die Spuren in Disc-Reihenfolge; Rippys Auswahlregel
// (werkzeuge/spurauswahl.ts) lässt nur die abgeleiteten DTS-Kerne weg.
// Deshalb reicht: je gewählter Spur die erste noch freie HandBrake-Spur
// gleicher Sprache, gleicher Codec-Familie und gleicher Kanalzahl — sonst
// gestuft weniger streng; findet sich nichts, sagt die Ablage es.
import type { SpurAngabe } from './nachrichten'
/** Codec-Familie aus MakeMKVs Codec-Wort (Attribut 6) oder HandBrakes
* CodecName beide auf denselben Schlüssel. '' wenn unbekannt. */
export function codecFamilie(text: string): string {
const t = (text ?? '').toLowerCase()
if (t.length === 0) return ''
if (t.includes('dts-hd') || t.includes('dtshd') || t.includes('dts:x') || t.includes('dts hd')) return 'dtshd'
if (t === 'dca' || t.startsWith('dts')) return 'dts'
if (t.includes('truehd') || t.includes('atmos') || t.includes('mlp')) return 'truehd'
if (t.includes('eac3') || t.includes('e-ac3') || t.includes('dd+') || t.includes('digital plus')) return 'eac3'
if (t === 'ac3' || t.startsWith('dd') || t.includes('dolby digital') || t === 'ac-3') return 'ac3'
if (t.includes('aac')) return 'aac'
if (t.includes('flac')) return 'flac'
if (t.includes('pcm') || t.includes('lpcm')) return 'pcm'
if (t.includes('mp3') || t.includes('mp2') || t.includes('mpeg')) return 'mp3'
if (t.includes('opus')) return 'opus'
if (t.includes('vorbis')) return 'vorbis'
if (t.includes('pgs') || t.includes('hdmv')) return 'pgs'
if (t.includes('vobsub') || t.startsWith('dvd')) return 'vobsub'
if (t.includes('srt') || t.includes('text') || t.includes('utf-8') || t.includes('ssa') || t.includes('ass')) return 'text'
return t
}
/** Die Zeile im Dialog: German · DTS-HD MA · Surround 5.1", German · DD ·
* Stereo (Kommentar)". MakeMKV nennt die Sprache englisch (Attribut 4,
* German"), auch bei deutscher Oberfläche gemessen an der Kizuna-Disc. */
export function spurLabel(s: SpurAngabe): string {
const teile: string[] = [s.sprache.length > 0 ? s.sprache : s.lang.length > 0 ? s.lang : 'ohne Sprache']
if (s.codec.length > 0) teile.push(s.codec)
if (s.kanaele.length > 0) {
const n = Number(s.kanaele)
const kurz = n === 6 ? '5.1' : n === 8 ? '7.1' : n === 2 ? '2.0' : n === 1 ? '1.0' : s.kanaele
// MakeMKVs Anordnung („Surround 5.1", „Stereo") sagt genug — die Zahl
// nur, wenn sie fehlt.
teile.push(s.layout.length > 0 ? s.layout : kurz)
}
let label = teile.join(' · ')
// Was MakeMKV in der Beschreibung dazusagt — „(nur erzwungene)",
// Kommentar-Hinweise — hängt in Klammern an.
const zusatz = /\(([^()]*)\)\s*$/.exec(s.beschreibung)
if (zusatz !== null && !label.toLowerCase().includes(zusatz[1].toLowerCase())) label += ` (${zusatz[1]})`
return label
}
/** Eine Zeile, die der Dialog ANBIETET: Abgeleitete TON-Spuren (DTS-Kerne)
* sind nicht in der Roh-Datei sie werden nicht gezeigt. Abgeleitete
* UNTERTITEL (nur erzwungene") sind drin und wählbar. */
export function angeboten(s: SpurAngabe, art: 'audio' | 'untertitel'): boolean {
return art === 'untertitel' || !s.abgeleitet
}
/** Rippys Vorbelegung (pur):
* * Ton: je Wunschsprache die ERSTE angebotene Spur das ist die Regel
* von 5.4.0 (eine Spur je Sprache", meist die beste); trifft keine
* Wunschsprache, die erste angebotene Spur überhaupt (japanischer Ton
* statt gar keiner).
* * Untertitel: ALLE Spuren der Wunschsprachen (auch nur erzwungene")
* wie bisher mit --all-subtitles; ohne Treffer nichts.
* Liefert MakeMKV-Stream-Nummern. */
export function spurenVorbelegt(spuren: readonly SpurAngabe[], wunsch: readonly string[], art: 'audio' | 'untertitel'): number[] {
const kandidaten = spuren.filter((s) => angeboten(s, art))
const wunschKlein = wunsch.map((w) => w.trim().toLowerCase()).filter((w) => w.length > 0)
if (art === 'audio') {
const gewaehlt: number[] = []
for (const w of wunschKlein) {
const erste = kandidaten.find((s) => s.lang.toLowerCase() === w)
if (erste !== undefined && !gewaehlt.includes(erste.nr)) gewaehlt.push(erste.nr)
}
if (gewaehlt.length === 0 && kandidaten.length > 0) gewaehlt.push(kandidaten[0].nr)
return gewaehlt
}
return kandidaten.filter((s) => wunschKlein.includes(s.lang.toLowerCase())).map((s) => s.nr)
}
/** Eine Spur, wie HandBrakes Scan sie in der Roh-Datei sieht (scan.ts). */
export interface HbSpurAngabe {
nr: number
lang: string
/** CodecName („dtshd") bzw. bei Untertiteln Source („PGS"). */
codec: string
kanaele: number
beschreibung: string
}
export interface SpurZuordnung {
/** HandBrake-Spurnummern in der Reihenfolge der gewählten Spuren. */
nummern: number[]
/** Gewählte Spuren, die in der Roh-Datei nicht zu finden waren. */
fehlend: SpurAngabe[]
}
/** Gewählte MakeMKV-Spuren HandBrake-Spurnummern (pur). Gestuft: Sprache
* + Familie + Kanäle Sprache + Familie Sprache + Kanäle Sprache
* (nur wenn die Datei GAR keine Sprachen kennt) Reihenfolge. Jede
* HandBrake-Spur nur einmal. */
export function spurenZuordnen(gewaehlt: readonly SpurAngabe[], vorhanden: readonly HbSpurAngabe[]): SpurZuordnung {
const frei = new Set(vorhanden.map((v) => v.nr))
const nummern: number[] = []
const fehlend: SpurAngabe[] = []
const ohneSprachen = vorhanden.every((v) => v.lang.length === 0 || v.lang === 'und')
for (const s of gewaehlt) {
const lang = s.lang.toLowerCase()
const familie = codecFamilie(s.codec)
const kanaele = Number(s.kanaele)
const stufen: Array<(v: HbSpurAngabe) => boolean> = [
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie && v.kanaele === kanaele,
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie,
(v) => v.lang.toLowerCase() === lang && v.kanaele === kanaele,
(v) => v.lang.toLowerCase() === lang,
]
if (ohneSprachen) stufen.push((v) => codecFamilie(v.codec) === familie && v.kanaele === kanaele, () => true)
let treffer: HbSpurAngabe | undefined
for (const passt of stufen) {
treffer = vorhanden.find((v) => frei.has(v.nr) && passt(v))
if (treffer !== undefined) break
}
if (treffer === undefined) {
fehlend.push(s)
continue
}
frei.delete(treffer.nr)
nummern.push(treffer.nr)
}
return { nummern, fehlend }
}
/** Sprachen der gewählten Spuren, ohne Doppel, in Wahl-Reihenfolge
* der Rückfall, wenn die Spuren in der Roh-Datei nicht zu finden sind. */
export function sprachenDerSpuren(spuren: readonly SpurAngabe[]): string[] {
const liste: string[] = []
for (const s of spuren) {
const l = s.lang.toLowerCase()
if (l.length > 0 && !liste.includes(l)) liste.push(l)
}
return liste
}
@@ -0,0 +1,143 @@
// Die Vorgangs-Leiste (5.6.0, Commander: „Phase 14 plus Komprimierung —
// irgendwie zu einem machen, der Phasen-Balken gefällt mir") — EIN Weg für
// jede Disc, vier Stationen: Titel lesen → Rippen → Komprimieren → Ablegen,
// bei Bedarf das Rettungs-Abbild als Zwischenstation vor dem Rippen. Die
// Leiste zeigt immer den GANZEN Weg, aber nur an einer Stelle die Details:
// in der Laufwerks-Kachel, solange das Laufwerk arbeitet (Stationen 12),
// in der Kompressions-Karte danach (34), im Verlauf als Punkte.
//
// Hier liegt die REINE Logik (welche Station ist fertig, aktiv, offen,
// gescheitert) — testbar ohne React; gezeichnet wird in
// fenster/uebersicht/VorgangsLeiste.tsx.
import type { KompressionStand, RipStatus, VorgangEintrag } from './nachrichten'
export type Station = 'titel' | 'rettung' | 'rip' | 'kompression' | 'ablage'
export type LeisteZustand = 'laeuft' | 'wartet' | 'fertig' | 'fehler' | 'abgebrochen'
export type StationsZustand = 'fertig' | 'aktiv' | 'offen' | 'fehler'
export interface LeisteStand {
station: Station
zustand: LeisteZustand
/** 0100 in der aktiven Station, -1 = keine Angabe. */
prozent: number
/** Das Rettungs-Abbild als Zwischenstation zeigen. */
rettung: boolean
/** Kurztext in der aktiven Station („Platz 2", „läuft — Karte unten"). */
hinweis: string
}
export const STATIONS_NAMEN: Record<Station, string> = {
titel: 'Titel lesen',
rettung: 'Rettungs-Abbild',
rip: 'Rippen',
kompression: 'Komprimieren',
ablage: 'Ablegen',
}
export function stationen(rettung: boolean): Station[] {
return rettung ? ['titel', 'rettung', 'rip', 'kompression', 'ablage'] : ['titel', 'rip', 'kompression', 'ablage']
}
/** Zustand je Station: alles vor der aktiven ist fertig, die aktive läuft
* (oder ist gescheitert), alles danach ist offen. `fertig` = alle gold. */
export function stationsZustand(liste: readonly Station[], station: Station, zustand: LeisteZustand): StationsZustand[] {
const i = liste.indexOf(station)
return liste.map((_, k) => {
if (zustand === 'fertig') return 'fertig'
if (k < i) return 'fertig'
if (k === i) return zustand === 'fehler' || zustand === 'abgebrochen' ? 'fehler' : 'aktiv'
return 'offen'
})
}
function stand(station: Station, zustand: LeisteZustand, prozent = -1, rettung = false, hinweis = ''): LeisteStand {
return { station, zustand, prozent, rettung, hinweis }
}
/** Die Leiste in der Laufwerks-Kachel: aus dem Rip-Stand und nach dem
* Rip aus dem Vorgang (ohne Prozent: die Details stehen in der
* Kompressions-Karte). */
export function leisteAusRip(
rip: Pick<RipStatus, 'phase' | 'prozent' | 'rettung'>,
vorgang: Pick<VorgangEintrag, 'phase'> | undefined,
wartePlatz: number,
): LeisteStand {
const rettung = rip.phase === 'rettet' || rip.rettung !== undefined
switch (rip.phase) {
case 'liest-info':
return stand('titel', 'laeuft', -1, rettung)
case 'rettet':
return stand('rettung', 'laeuft', rip.prozent, true)
case 'rippt':
return stand('rip', 'laeuft', rip.prozent, rettung)
case 'komprimiert':
return stand('kompression', 'laeuft', rip.prozent, rettung)
case 'legt-ab':
return stand('ablage', 'laeuft', rip.prozent, rettung)
case 'fehler':
return stand('rip', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('rip', 'abgebrochen', -1, rettung)
case 'fertig':
break
}
// Der Rip ist durch — wo steht der Vorgang?
if (vorgang === undefined) return stand('kompression', 'wartet', -1, rettung, 'eingereiht')
switch (vorgang.phase) {
case 'rippt':
return stand('rip', 'laeuft', -1, rettung)
case 'wartet':
return stand('kompression', 'wartet', -1, rettung, wartePlatz > 0 ? `Platz ${wartePlatz}` : 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'legt-ab':
return stand('ablage', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'fertig':
return stand('ablage', 'fertig', 100, rettung)
case 'fehler':
return stand('kompression', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('kompression', 'abgebrochen', -1, rettung)
}
}
/** Die Leiste eines Vorgangs im Verlauf (kompakt). Bei Fehlern ohne
* Roh-Bytes scheiterte schon der Rip, sonst die Kompression. */
export function leisteAusVorgang(v: Pick<VorgangEintrag, 'phase' | 'rohBytes'>): LeisteStand {
switch (v.phase) {
case 'rippt':
return stand('rip', 'laeuft')
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft')
case 'legt-ab':
return stand('ablage', 'laeuft')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'fehler')
case 'abgebrochen':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'abgebrochen')
}
}
/** Die Leiste in der Kompressions-Karte: Stationen 12 sind fertig. */
export function leisteAusKompression(k: Pick<KompressionStand, 'phase' | 'prozent'>): LeisteStand {
switch (k.phase) {
case 'legt-ab':
return stand('ablage', 'laeuft', k.prozent)
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand('kompression', 'fehler')
case 'abgebrochen':
return stand('kompression', 'abgebrochen')
case 'rippt':
return stand('rip', 'laeuft', k.prozent)
case 'komprimiert':
return stand('kompression', 'laeuft', k.prozent)
}
}
+13
View File
@@ -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
}
+92
View File
@@ -0,0 +1,92 @@
// 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.
//
// 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
// sofern sie noch auf einem Bildschirm liegt.
import { BrowserWindow, screen } from 'electron'
import { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(profilOrdner: string): BrowserWindow {
const lageDatei = join(profilOrdner, 'fenster-lage.json')
const lage = anfangsLage(
lageLesen(lageDatei),
screen.getPrimaryDisplay().workArea,
screen.getAllDisplays().map((d) => d.workArea),
)
const fenster = new BrowserWindow({
x: lage.x,
y: lage.y,
width: lage.width,
height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true,
autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
//
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
// Standard true) — die Kanten lassen sich also weiter ziehen, und
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
frame: false,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
backgroundColor: '#171412',
title: 'Rippy',
webPreferences: {
preload: join(__dirname, '../bruecke/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
if (lage.maximiert) fenster.maximize()
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
let zeiger: NodeJS.Timeout | null = null
const merken = (): void => {
if (zeiger !== null) clearTimeout(zeiger)
zeiger = setTimeout(() => {
if (fenster.isDestroyed()) return
const maximiert = fenster.isMaximized()
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
lageSchreiben(lageDatei, neu)
}, 400)
}
fenster.on('resize', merken)
fenster.on('move', merken)
fenster.on('maximize', merken)
fenster.on('unmaximize', merken)
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
// 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 /
// #einrichtung) — 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
}
+85
View File
@@ -0,0 +1,85 @@
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
// PUR, damit die Ampel die Rechnung prüft:
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
// Bildschirm.
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
// Fenster nicht ins Nirgendwo schieben).
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface Lage {
x: number
y: number
width: number
height: number
maximiert: boolean
}
export interface Bereich {
x: number
y: number
width: number
height: number
}
export const MINDEST_BREITE = 1100
export const MINDEST_HOEHE = 720
export const ANTEIL = 0.8
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
export function startLage(arbeitsbereich: Bereich): Lage {
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
return {
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
width: Math.min(width, arbeitsbereich.width),
height: Math.min(height, arbeitsbereich.height),
maximiert: false,
}
}
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
if (lage.width < 400 || lage.height < 300) return false
return bildschirme.some((b) => {
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
const links = Math.max(lage.x, b.x)
const unten = Math.min(lage.y + lage.height, b.y + b.height)
const oben = Math.max(lage.y, b.y)
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
return rechts - links >= 200 && unten - oben >= 100
})
}
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
return startLage(arbeitsbereich)
}
export function lageLesen(pfad: string): Lage | null {
try {
if (!existsSync(pfad)) return null
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
if (typeof roh !== 'object' || roh === null) return null
const l = roh as Record<string, unknown>
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
} catch {
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
return null
}
}
export function lageSchreiben(pfad: string, lage: Lage): void {
try {
writeFileSync(pfad, JSON.stringify(lage))
} catch {
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
}
}
+78
View File
@@ -0,0 +1,78 @@
// Den Windows-11-Fensterrand loswerden.
//
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
// Fensterinhalts.
//
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
// Windows 11 Build 22000):
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
//
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
// gibt es diesen Rand schlicht nicht.
//
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
// Haupt liegen, weil nur dort das Fensterhandle existiert.
import koffi from 'koffi'
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
const DWMWA_BORDER_COLOR = 34
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
const DWMWA_COLOR_NONE = 0xfffffffe
const DWMWCP_DONOTROUND = 1
export interface RandErgebnis {
ok: boolean
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
fehler: string
}
/**
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
* und Rippy sich nicht fremder anfühlen soll, als es muss.
*
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
*/
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
try {
const dwmapi = koffi.load('dwmapi.dll')
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
const DwmSetWindowAttribute = dwmapi.func(
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
)
const hwnd = handle.readBigUInt64LE(0)
const farbe = Buffer.alloc(4)
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
let eckenErgebnis = 0
if (eckenKantig) {
const ecken = Buffer.alloc(4)
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
}
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
return {
ok: false,
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
}
}
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
}
}
+484
View File
@@ -0,0 +1,484 @@
// 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 { Protokoll } from '../gemeinsam/protokoll'
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, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
const klickSmoke = process.argv.includes('--klick-smoke')
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
const ohneNebenwirkungen = smokeModus || klickSmoke
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
app.setPath('userData', profilArg.slice('--profil='.length))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
// Rippy nicht und wird von ihm nicht gestört.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
start()
}
function start(): void {
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
const protokollOrdner = join(app.getPath('userData'), 'logs')
const protokoll = new Protokoll(protokollOrdner, 'haupt')
protokoll.aufraeumen()
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
// 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}`)
protokoll.schreiben(`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}`)
protokoll.schreiben(`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 && !ohneNebenwirkungen) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
() => statusSenden(),
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
// der close-Horcher das Fenster nicht bloß versteckt.
() => beenden(),
)
}
} else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'vorgaenge') {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
} else if (nachricht.art === 'kompression-stand') {
// 5.7.0 (F7): der leichte Fortschritts-Tick — dieselbe Wirkung.
kompressionVerarbeiten(nachricht.kompression.laeuft)
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
// 5.7.0 (F20): einmal je Disc und Titel — die Nachprüfung nach dem
// Info-Lauf und „Ändern …" schicken denselben Stand erneut.
const schluessel = `${nachricht.info.id}:${nachricht.info.titel}`
if (letzterErkennungsToast.get(nachricht.info.id) === schluessel) return
letzterErkennungsToast.set(nachricht.info.id, schluessel)
toast(
'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)})`
: ''),
)
}
},
(zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus,
)
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null
/** Je Laufwerk der zuletzt gemeldete Titel — gegen doppelte Toasts (F20). */
const letzterErkennungsToast = new Map<string, string>()
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
else aktiveVorgaenge.add(KOMPRESSION)
anzeigeAktualisieren()
}
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
* die Kompression sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
// 5.7.0 (F26): Das Rettungs-Abbild ist ein laufender Vorgang wie der Rip.
const rip = letzterRip !== null && (letzterRip.phase === 'rippt' || letzterRip.phase === 'rettet') ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
fenster.setProgressBar(kompressionStand.prozent / 100)
} else {
fenster.setProgressBar(-1)
}
}
if (rip !== null) {
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
} else if (kompressionStand !== null) {
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
}
// § 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 === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id)
} else {
aktiveVorgaenge.delete(status.id)
}
letzterRip = status
anzeigeAktualisieren()
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy: Rip 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(),
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
}
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()
})
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
// maximiert wird, nicht nur über unseren Knopf.
fenster.on('maximize', () => statusSenden())
fenster.on('unmaximize', () => statusSenden())
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => {
if (beendenGewollt) return
ereignis.preventDefault()
fenster?.hide()
})
}
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
return
}
if (fenster.isMinimized()) fenster.restore()
fenster.show()
fenster.focus()
}
function beenden(): void {
beendenGewollt = true
app.quit()
}
/** Beenden" im Tray (5.7.0, Durchsicht F15): Läuft ein Rip oder eine
* Kompression, fragt Rippy nach app.quit() reißt über die Leine
* makemkvcon und HandBrake mit, und vierzig Minuten Rip wären weg. */
function beendenMitRueckfrage(): void {
if (aktiveVorgaenge.size === 0) {
beenden()
return
}
const laufend = kompressionStand !== null ? `die Kompression von „${kompressionStand.titel}"` : 'ein Rip'
const wahl = dialog.showMessageBoxSync({
type: 'warning',
title: 'Rippy beenden?',
message: `Gerade läuft ${laufend}.`,
detail: 'Beim Beenden bricht Rippy alles Laufende ab — der verlustfreie Rip und die halbe Kompression gehen verloren. Fenster schließen lässt Rippy im Infobereich weiterarbeiten.',
buttons: ['Weiterlaufen lassen', 'Trotzdem beenden'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
if (wahl === 1) beenden()
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// 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()
}
})
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
if (fenster === null || fenster.isDestroyed()) return false
if (was === 'minimieren') {
fenster.minimize()
} else if (was === 'maximieren') {
if (fenster.isMaximized()) fenster.unmaximize()
else fenster.maximize()
} else if (was === 'schliessen') {
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
// ein laufender Rip darf davon nichts merken.
fenster.close()
} else {
return false
}
return fenster.isDestroyed() ? false : fenster.isMaximized()
})
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
ipcMain.handle('update-pruefen', () => {
updatePruefenJetzt()
return updateStand()
})
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
// statt still nichts zu tun (R4).
ipcMain.handle('update-installieren', () => {
const urteil = updateInstallierenJetzt()
if (!urteil.ok) statusSenden()
return urteil
})
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
autostartSetzen(an === true)
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]
})
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
// Programme, sonst nichts.
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
properties: ['openFile'],
filters: [
{ name: 'Programme', extensions: ['exe'] },
{ name: 'Alle Dateien', extensions: ['*'] },
],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
return await shell.openPath(pfad)
})
ipcMain.handle('protokoll-oeffnen', async () => {
return await shell.openPath(protokollOrdner)
})
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
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden()
})
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beendenMitRueckfrage)
}
fenster = fensterErstellen(app.getPath('userData'))
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)
}
}
+119
View File
@@ -0,0 +1,119 @@
// 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[] = []
/** Das Fenster, das zuletzt einen Port bekam ein NEU gestarteter Kern
* bekommt es wieder (5.7.0, Durchsicht F4: vorher hielt das Fenster nach
* einem Kern-Neustart den toten Port und war taub, bis Rippy komplett neu
* gestartet wurde). */
private letztesFenster: WebContents | null = null
constructor(
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())
// Ein Neustart: Das Fenster hängt noch am toten Port des alten Kerns —
// frischen Port aufspannen, sonst tut kein Knopf mehr etwas.
if (this.neustarts > 0 && this.letztesFenster !== null && !this.letztesFenster.isDestroyed()) {
this.fensterVerbinden(this.letztesFenster)
}
})
kern.on('exit', (code) => {
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 {
this.letztesFenster = webContents
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
}
}
+49
View File
@@ -0,0 +1,49 @@
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
// die Ampel das auf Linux prüfen kann (BAUEN.md).
//
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
export function argumentQuoten(arg: string): string {
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
let ergebnis = '"'
let rueckstriche = 0
for (const zeichen of arg) {
if (zeichen === '\\') {
rueckstriche += 1
continue
}
if (zeichen === '"') {
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
// Anführungszeichen selbst bekommt einen eigenen davor.
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
rueckstriche = 0
continue
}
ergebnis += '\\'.repeat(rueckstriche) + zeichen
rueckstriche = 0
}
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
// davor zählen deshalb ebenfalls doppelt.
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
}
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
return [programm, ...argumente].map(argumentQuoten).join(' ')
}
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
* danach wieder startet. Gemessen an der installierten Bibliothek
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
* `/S` für still", `--force-run` für „danach starten". Genau diese drei
* benutzt electron-updater selbst wir starten den Installer nur auf
* einem anderen Weg (außerhalb der Prozess-Leine). */
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
+186
View File
@@ -0,0 +1,186 @@
// 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'
import { kommandozeileBauen } from './kommandozeile'
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
// denn, sie verlangen das Ausklinken beim Anlegen selbst
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
//
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
// electron-updater wird per `spawn(…, { detached: true })` gestartet
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
// beendet aber startet NICHT neu in der neuen version."
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
// zeigte deshalb erst ein falsches Negativ.
const CREATE_NO_WINDOW = 0x08000000
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
// bei Offset 16.
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 | JOB_OBJECT_LIMIT_BREAKAWAY_OK, 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
}
}
/**
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
*
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
* Kind, das Rippys eigenen Tod überleben MUSS er installiert ja gerade
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
*
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
* `update-info.json` steht `isAdminRightsRequired: false`) er liefe also
* wieder als Kind und stürbe wieder mit.
*/
export function ausserhalbDerLeineStarten(
programm: string,
argumente: readonly string[],
): { ok: boolean; fehler: string } {
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
)
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
const gestartet = CreateProcessW(
programm,
zeile,
null,
null,
false,
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
null,
null,
startupInfo,
prozessInfo,
)
if (!gestartet) {
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
}
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
// ohne Wirkung.
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
}
}
+52
View File
@@ -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
}
+61
View File
@@ -0,0 +1,61 @@
// 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).',
}
}
/** So viel Änderungstext zeigt das Fenster höchstens der Rest wird
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
export const NEUERUNGEN_HOECHSTENS = 4000
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
*
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
* `string | Array<ReleaseNoteInfo> | null` bei uns ein String
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
* ankommen können, sonst steht im Fenster [object Object]".
*
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
* weitergereicht, was wie Auszeichnung aussieht. */
export function neuerungenText(roh: unknown): string {
const saubern = (t: string): string =>
t
.replace(/<[^>]*>/g, ' ')
.split('\n')
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
if (Array.isArray(roh)) {
const teile: string[] = []
for (const eintrag of roh) {
if (eintrag === null || typeof eintrag !== 'object') continue
const e = eintrag as { version?: unknown; note?: unknown }
const version = typeof e.version === 'string' ? e.version : ''
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
if (notiz.length === 0) continue
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
}
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
}
return ''
}
+246
View File
@@ -0,0 +1,246 @@
// 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 prüfen, laden noch installieren.
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
// letzten erfolgreichen Prüfung steht im Status.
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
//
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
// Knopf Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
import { app } from 'electron'
import electronUpdater from 'electron-updater'
import { INSTALLER_ARGUMENTE } from './kommandozeile'
import { ausserhalbDerLeineStarten } from './leine'
import { neuerungenText, updateUrlPruefen } from './update-regeln'
export {
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
neuerungenText,
updateUrlPruefen,
} from './update-regeln'
const { autoUpdater } = electronUpdater
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
export type { UpdateStand } from '../gemeinsam/nachrichten'
import type { UpdateStand } from '../gemeinsam/nachrichten'
const ANFANG: UpdateStand = {
letzteErfolgreichePruefung: '',
text: 'noch nicht geprüft',
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
}
let stand: UpdateStand = { ...ANFANG }
let aenderungMelden: () => void = () => {
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
}
let ripLaeuftJetzt: () => boolean = () => false
let scharf = false
/** Der geladene Installer — leer, solange nichts bereitliegt. */
let installerPfad = ''
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
let installerGestartet = false
let rippyBeenden: () => void = () => app.quit()
export function updateStand(): UpdateStand {
return { ...stand }
}
/** Stand ändern UND das Fenster benachrichtigen nie das eine ohne das
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
* (der 26.07.-Fund ein still scheiternder Hintergrund-Prozess"). */
function setzen(teil: Partial<UpdateStand>): void {
stand = { ...stand, ...teil }
aenderungMelden()
}
export function updateEinrichten(
eigeneUrl: string,
ripLaeuft: () => boolean,
melden: (text: string) => void,
beiAenderung: () => void = () => {
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
},
beenden: () => void = () => app.quit(),
): void {
aenderungMelden = beiAenderung
ripLaeuftJetzt = ripLaeuft
rippyBeenden = beenden
if (!app.isPackaged) {
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
return
}
const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) {
setzen({ text: pruefung.grund })
melden(pruefung.grund)
return
}
if (eigeneUrl.trim().length > 0) {
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
}
autoUpdater.autoDownload = false
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
// Objekt bezahlt): Sein eigener Weg startet den Installer per
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
autoUpdater.autoInstallOnAppQuit = false
scharf = true
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
// diese Zeile zurückkommt.
app.on('will-quit', () => {
if (!stand.bereit || ripLaeuftJetzt()) return
installerAnwerfen()
})
autoUpdater.on('update-available', (info) => {
const neuerungen = neuerungenText(info.releaseNotes)
if (ripLaeuft()) {
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
// tägliche Takt versucht es wieder.
setzen({
neueVersion: info.version,
neuerungen,
laeuft: false,
fortschritt: -1,
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
})
melden(stand.text)
return
}
setzen({
neueVersion: info.version,
neuerungen,
laeuft: true,
fortschritt: 0,
text: `Version ${info.version} wird geladen …`,
})
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
void autoUpdater
.downloadUpdate()
.then((pfade: string[]) => {
// downloadUpdate liefert die abgelegten Dateien zurück
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
// ebenfalls heraus, ohne erneut zu laden.
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
if (exe.length > 0) installerPfad = exe
})
.catch((fehler: unknown) => {
setzen({
laeuft: false,
fortschritt: -1,
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
})
})
})
autoUpdater.on('download-progress', (fortschritt) => {
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
})
autoUpdater.on('update-downloaded', (info) => {
setzen({
neueVersion: info.version,
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
bereit: true,
laeuft: false,
fortschritt: 100,
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
})
melden(stand.text)
})
autoUpdater.on('update-not-available', () => {
setzen({
letzteErfolgreichePruefung: new Date().toISOString(),
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
text: 'Rippy ist aktuell.',
})
})
autoUpdater.on('error', (fehler) => {
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
})
pruefen()
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
}
function pruefen(): void {
if (ripLaeuftJetzt()) return
setzen({ laeuft: true, text: 'Suche nach Updates …' })
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
/** Der Knopf Jetzt prüfen" im Fenster. Antwortet IMMER etwas ein
* Knopf, der nichts sagt, ist schlimmer als keiner. */
export function updatePruefenJetzt(): void {
if (!scharf) {
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
aenderungMelden()
return
}
if (ripLaeuftJetzt()) {
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
return
}
pruefen()
}
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
* er wirklich läuft nichts passiert" darf nicht als Erfolg durchgehen. */
function installerAnwerfen(): { ok: boolean; grund: string } {
if (installerGestartet) return { ok: true, grund: '' }
if (installerPfad.length === 0) {
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
}
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
if (!urteil.ok) {
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
return { ok: false, grund: urteil.fehler }
}
installerGestartet = true
return { ok: true, grund: '' }
}
/** Der Knopf Jetzt neu starten und installieren". Nur sinnvoll, wenn
* wirklich etwas bereitliegt und nie mitten in einem Rip. */
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
if (!scharf || !stand.bereit) {
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
}
if (ripLaeuftJetzt()) {
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
}
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
// weg ist, und startet es hinterher wieder (`--force-run`).
const urteil = installerAnwerfen()
if (!urteil.ok) return urteil
rippyBeenden()
return { ok: true, grund: '' }
}
@@ -0,0 +1,67 @@
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
import { extname } from 'node:path'
import { sichererName } from '../../gemeinsam/ablagepfad'
import type { TitelRolle } from '../../gemeinsam/nachrichten'
export interface NamensEingabe {
titel: string
jahr: number | null
alsSerie: boolean
rolle: TitelRolle
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
nummer: number
/** Wie viele Dateien dieser Rolle es gibt. */
anzahl: number
/** MakeMKVs Dateiname (mit Endung). */
original: string
}
export function zielDateiname(e: NamensEingabe): string {
const endung = extname(e.original) || '.mkv'
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
if (e.titel.trim().length === 0 || name.length === 0) return e.original
const nn = String(e.nummer).padStart(2, '0')
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
if (e.alsSerie) return e.original
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
return e.original
}
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
const zaehler = new Map<TitelRolle, number>()
const gesamt = new Map<TitelRolle, number>()
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
return rollen.map((r) => {
const n = (zaehler.get(r) ?? 0) + 1
zaehler.set(r, n)
return [n, gesamt.get(r) ?? 1]
})
}
/** Ein Name, der im Ordner noch frei ist (5.7.0, Durchsicht F3): `x.mkv`,
* sonst `x (2).mkv`, `x (3).mkv` nie eine vorhandene Datei
* überschreiben. Der Fall: Disc 2 einer Staffel legt `title_t00.mkv` in
* denselben Season-Ordner, in dem Disc 1 ihr `title_t00.mkv` noch trägt
* (Umbenennung ausgefallen) HandBrake hätte sie still ersetzt. Pur:
* `existiert` kommt von außen; getrennt wird am letzten Punkt des Namens,
* Pfadtrenner beider Welten bleiben unangetastet. */
export function freierName(pfad: string, existiert: (p: string) => boolean): string {
if (!existiert(pfad)) return pfad
const trenner = Math.max(pfad.lastIndexOf('/'), pfad.lastIndexOf('\\'))
const punkt = pfad.lastIndexOf('.')
const stamm = punkt > trenner + 1 ? pfad.slice(0, punkt) : pfad
const endung = punkt > trenner + 1 ? pfad.slice(punkt) : ''
for (let n = 2; n < 1000; n++) {
const kandidat = `${stamm} (${n})${endung}`
if (!existiert(kandidat)) return kandidat
}
return `${stamm} (${Date.now()})${endung}`
}
@@ -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 ''
}
+54
View File
@@ -0,0 +1,54 @@
// 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
/** 5.7.0 (F23): die Kennung bei TMDb (Zahl) oder IMDb (tt) Jellyfin,
* Emby und Kodi lesen sie als <uniqueid> und müssen nicht mehr über
* Titel und Jahr raten (kodi.wiki NFO files: uniqueid type=tmdb|imdb). */
kennung?: { quelle: string; id: string }
}
function xml(wert: string): string {
return wert
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** 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>`)
}
if (meta.kennung !== undefined && meta.kennung.id.length > 0) {
const typ = meta.kennung.id.startsWith('tt') ? 'imdb' : meta.kennung.quelle === 'omdb' ? 'imdb' : 'tmdb'
zeilen.push(` <uniqueid type="${typ}" default="true">${xml(meta.kennung.id)}</uniqueid>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
}
+30
View File
@@ -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,198 @@
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
//
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
//
// Drei Sicherungen, alle bewusst:
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
// Weg, wie Rippy Daten verlieren könnte.
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
import { join } from 'node:path'
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
export const STANDARD_AUFBEWAHRUNG = '7'
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
const text = (wert ?? '').trim().toLowerCase()
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
if (text === 'sofort' || text === '0') return { art: 'sofort' }
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
}
export function regelText(regel: AufbewahrungsRegel): string {
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
}
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
const beendet = new Date(beendetIso)
if (Number.isNaN(beendet.getTime())) return ''
if (regel.art === 'sofort') return beendet.toISOString()
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
}
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
export interface AufraeumKandidat {
id: string
phase: string
komprimiert: boolean
rohOrdner: string
rohGeloeschtAm: string | null
beendet: string | null
fertigeDateien: readonly string[]
}
export interface AufraeumZugriff {
existiert: (pfad: string) => boolean
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
groesse: (pfad: string) => number
ordnerLoeschen: (pfad: string) => void
}
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
existiert: existsSync,
groesse: (pfad) => {
try {
return statSync(pfad).size
} catch {
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
return 0
}
},
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
}
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
export function loeschHindernis(
v: AufraeumKandidat,
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): string {
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
for (const datei of v.fertigeDateien) {
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
}
if (regel.art === 'behalten') return 'Regel: behalten'
const frist = faelligAm(v.beendet, regel)
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
return ''
}
export interface AufraeumErgebnis {
geloescht: string[]
uebersprungen: Array<{ id: string; grund: string }>
fehler: Array<{ id: string; grund: string }>
}
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
export function aufraeumen(
vorgaenge: readonly AufraeumKandidat[],
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): AufraeumErgebnis {
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
for (const v of vorgaenge) {
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
if (hindernis.length > 0) {
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
continue
}
try {
zugriff.ordnerLoeschen(v.rohOrdner)
ergebnis.geloescht.push(v.id)
} catch (fehler) {
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
// gesagt — beim nächsten Takt kommt er wieder dran.
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
}
}
return ergebnis
}
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
export function ordnerGroesse(pfad: string): number {
let summe = 0
const gehen = (ordner: string): void => {
let eintraege: import('node:fs').Dirent[]
try {
eintraege = readdirSync(ordner, { withFileTypes: true })
} catch {
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
// erfunden.
return
}
for (const e of eintraege) {
const voll = join(ordner, e.name)
if (e.isDirectory()) gehen(voll)
else {
try {
summe += statSync(voll).size
} catch {
// siehe oben
}
}
}
}
if (existsSync(pfad)) gehen(pfad)
return summe
}
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>, groesse: (pfad: string) => number = ordnerGroesse): FremderOrdner[] {
if (!existsSync(rohWurzel)) return []
const liste: FremderOrdner[] = []
let namen: string[]
try {
namen = readdirSync(rohWurzel, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
} catch (fehler) {
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
// bleibt dann leer, weil wirklich nichts gesehen wurde.
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
return liste
}
for (const name of namen) {
const voll = join(rohWurzel, name)
if (bekannte.has(voll) || bekannte.has(name)) continue
liste.push({ ordner: voll, bytes: groesse(voll) })
}
return liste.sort((a, b) => b.bytes - a.bytes)
}
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
* darf fremden Ordner löschen" anfassen nie etwas anderes. */
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
const kandidat = pfad.replace(/[\\/]+$/, '')
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
const rest = kandidat.slice(wurzel.length)
return /^[\\/][^\\/]+$/.test(rest)
}
+142
View File
@@ -0,0 +1,142 @@
// 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'
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
// Auswahl-Dialog rechnet mit derselben Funktion).
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird (2)", „(3)" angehängt statt zu überschreiben 5.7.0
* (F24): vorher die ersten acht Zeichen der Vorgangskennung, also
* Akira (1988) [bluray-2]", was niemandem etwas sagte. */
export function zielordner(
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 ziel
for (let n = 2; n < 100; n++) {
const kandidat = join(basis, `${name} (${n})`)
if (!existiert(kandidat)) return kandidat
}
return join(basis, `${name} [${jobId.slice(0, 8)}]`)
}
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, staffelOrdnerName(staffel))
}
/** 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).
*
* 5.7.0 (Durchsicht F3): `nurDiese` nennt die Dateien DIESER Disc (nur
* Namen, in Ablage-Reihenfolge). Vorher zählte die Funktion ALLE MKVs im
* Season-Ordner und sobald Disc 1 dort schon S01E01/S01E02 abgelegt
* hatte, hieß es bei Disc 2 übersprungen: 4 Dateien, 2 Zuordnungen".
* Der Serien-Merker lief damit ab der zweiten Disc ins Leere. Ohne
* `nurDiese` gilt das alte Verhalten (alle MKVs des Ordners). */
export function episodenUmbenennen(
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,
},
nurDiese?: readonly string[],
): string[] {
const meldungen: string[] = []
const dateien =
nurDiese !== undefined
? [...nurDiese]
: 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
}
+534
View File
@@ -0,0 +1,534 @@
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
// noch komprimiert"),
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
// Neulesen mehr.
//
// Was hier NEU ist gegenüber 5.3.x:
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
// * 5.5.0 Arbeitsordner: Liegt die Ablage auf dem Server, rechnet HandBrake
// LOKAL (Arbeitsordner\fertig\<Vorgang>) und erst das Ergebnis wandert
// über das Netz — kein Encode gegen eine SMB-Freigabe.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
statSync,
unlinkSync,
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, KompressionStand } from '../../gemeinsam/nachrichten'
import { freierName, rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachlistenAusSpuren,
sprachwahl,
spurwahl,
type KompressionsAuftrag,
type RohDatei,
type SpurWahl,
} from './auftrag'
export interface AblageUmgebung {
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
handbrake: () => string
/** Wurzel der Ablage. */
ablage: () => string
/** 5.5.0: eigener lokaler Arbeitsordner '' wenn keiner (dann wird
* direkt in die Ablage gerechnet, wie bisher). */
arbeitsordner?: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date
/** Datei verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
}
export interface AblageErgebnis {
status: 'fertig' | 'fehler' | 'abgebrochen'
zielOrdner: string
dateien: string[]
text: string
komprimiert: boolean
preset: string
fertigBytes: number
hinweise: string[]
/** 5.5.0: Welche Folgen-Nummern vergeben wurden (leer bei Filmen oder ohne
* Zuordnung) der Serien-Merker zählt daran weiter. */
folgenVergeben: number[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
export function dateiVerschieben(von: string, nach: string): void {
try {
renameSync(von, nach)
} catch (fehler) {
const code = (fehler as NodeJS.ErrnoException).code
if (code !== 'EXDEV') throw fehler
copyFileSync(von, nach)
unlinkSync(von)
}
}
/** Die Sprachlisten aus den gewählten Spuren, als Felder für sprachwahl. */
function sprachlistenAusSpurenFuerWahl(datei: RohDatei): { audioSprachen: string[]; untertitelSprachen: string[] } {
const listen = sprachlistenAusSpuren(datei)
return { audioSprachen: listen.audio, untertitelSprachen: listen.untertitel }
}
/** Der Zusatz in der Fortschrittszeile: „ · Ton 1,3 · Untertitel 2 (vorgewählt)". */
function spurText(spurNr: SpurWahl | null, wahl: { audio: string[]; untertitel: string[]; untertitelStandard: boolean }): string {
if (spurNr !== null) {
return `${spurNr.audioNr.length > 0 ? ` · Ton Spur ${spurNr.audioNr.join(',')}` : ' · Ton: erste Spur'}${
spurNr.untertitelNr.length > 0 ? ` · Untertitel Spur ${spurNr.untertitelNr.join(',')}${spurNr.untertitelStandard > 0 ? ' (vorgewählt)' : ''}` : ''
}`
}
return `${wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''}${
wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''
}`
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
// Ergebnis selbst.
return 0
}
}
export async function komprimierenUndAblegen(
auftrag: KompressionsAuftrag,
umgebung: AblageUmgebung,
melden: AblageMeldung,
abbruch: AbortSignal,
): Promise<AblageErgebnis> {
const einstellungen = umgebung.einstellungen()
const handbrake = umgebung.handbrake()
const soll = komprimierenFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const komprimiert = soll && handbrake.length > 0
const preset =
auftrag.preset.length > 0
? auftrag.preset
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
const fehlschlag = (text: string, statusPreset: string, ist: boolean): AblageErgebnis => ({
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: [],
text,
komprimiert: ist,
preset: statusPreset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
})
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
const alsSerie = dateien.some((d) => d.rolle === 'folge')
const staffel = auftrag.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
const fertigOrdner =
auftrag.zielOrdner.length > 0
? auftrag.zielOrdner
: alsSerie
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
try {
mkdirSync(fertigOrdner, { recursive: true })
} catch (fehler) {
return fehlschlag(`Zielordner nicht anlegbar (${fertigOrdner}): ${String(fehler)} — ist die Ablage erreichbar?`, '', komprimiert)
}
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
// 5.5.0: Eigener Arbeitsordner → HandBrake schreibt LOKAL, danach wird
// verschoben. Ohne ihn wie bisher direkt ins Ziel.
const arbeit = (umgebung.arbeitsordner?.() ?? '').trim()
const zwischen = komprimiert && arbeit.length > 0 ? join(arbeit, 'fertig', auftrag.vorgangId) : ''
if (zwischen.length > 0) mkdirSync(zwischen, { recursive: true })
const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
}
const gewichte = dateien.map((d) => d.groesseBytes)
const scannen = umgebung.spurenScannen ?? spurenScannen
const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = []
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
const nachzuziehen: Array<[string, string]> = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
/** Die Folgen DIESER Disc (Zielnamen, Ablage-Reihenfolge) nur die werden
* umbenannt, nicht der ganze Season-Ordner (5.7.0, F3). */
const folgenDateien: string[] = []
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
// ihren Namen bis zur Episoden-Zuordnung.
const nummern = rollenNummern(dateien.map((d) => d.rolle))
// Beim Wiederholen darf die neue Fassung den alten Namen tragen — die alte
// Datei fällt erst, wenn die neue fertig ist (unten). Alles andere, was
// schon dasteht, wird NIE überschrieben (F3): dann „x (2).mkv".
const ersetzt = new Set(auftrag.ersetzt ?? [])
const freiOderErsetzt = (pfad: string): string => (ersetzt.has(pfad) ? pfad : freierName(pfad, existsSync))
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const [nummer, anzahl] = nummern[i]
const zielDatei = freiOderErsetzt(
join(
zielFuer(datei.rolle),
zielDateiname({
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
alsSerie,
rolle: datei.rolle,
nummer,
anzahl,
original: basename(datei.pfad),
}),
),
)
if (datei.rolle === 'folge') folgenDateien.push(basename(zielDatei))
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
phase: 'legt-ab',
prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
})
try {
verschieben(datei.pfad, zielDatei)
} catch (fehler) {
return fehlschlag(
`Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
'',
false,
)
}
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue
}
let wahl = sprachwahl(datei, global)
// 5.7.0: Spuren EINZELN gewählt — die Roh-Datei wird gescannt und jede
// gewählte MakeMKV-Spur als HandBrake-Spurnummer wiedergefunden
// (auftrag.ts spurwahl). Scheitert der Scan, gelten die Sprachen der
// gewählten Spuren als Liste (wie 5.4.05.6.x).
const einzeln = datei.audioSpuren !== undefined || datei.untertitelSpuren !== undefined
let spurNr: SpurWahl | null = null
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null && einzeln) {
spurNr = spurwahl(datei, spuren, global.audio[0] ?? '')
for (const f of spurNr?.fehlend ?? []) {
hinweise.push(`${basename(datei.pfad)}: ${f} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`)
}
if (spurNr !== null && spurNr.audioNr.length === 0 && (datei.audioSpuren?.length ?? 0) > 0) {
// Keine gewählte Tonspur gefunden → lieber Sprachlisten als eine
// stumme Datei.
spurNr = null
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
hinweise.push(`${basename(datei.pfad)}: keine der gewählten Tonspuren gefunden — Sprachen ${wahl.audio.join(',') || 'nach Einstellung'} statt Spuren.`)
}
} else if (spuren === null && einzeln) {
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — Sprachen ${[...wahl.audio, ...wahl.untertitel].join(',') || 'nach Einstellung'} statt einzelner Spuren.`)
} else if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
)
}
if (fehlt.untertitel.length > 0) {
hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
}
} else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
}
}
const ausgabe = zwischen.length > 0 ? join(zwischen, basename(datei.pfad)) : zielDatei
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)}`,
})
const encode = await komprimieren({
programm: handbrake,
eingabe: datei.pfad,
ausgabe,
preset,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
...(spurNr !== null
? { audioNr: spurNr.audioNr, untertitelNr: spurNr.untertitelNr, untertitelStandardNr: spurNr.untertitelStandard }
: {}),
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
abbruch,
aufFortschritt: (prozent, restS) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
restS,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
})
if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (encode.status === 'error') {
return fehlschlag(
`Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
preset,
true,
)
}
if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
}
// 5.5.0: Lokal Gerechnetes auf die Ablage bringen — erst jetzt, da alle
// Encodes gelungen sind. Ein Netzfehler hier kostet keinen Encode: Die
// Dateien liegen im Arbeitsordner und der Vorgang sagt, wo.
for (let i = 0; i < nachzuziehen.length; i++) {
const [von, nach] = nachzuziehen[i]
if (abbruch.aborted) return abgebrochen(fertigOrdner, [], preset, komprimiert, hinweise)
melden({ phase: 'legt-ab', prozent: 100, text: `Verschiebe auf die Ablage ${i + 1}/${nachzuziehen.length}: ${basename(nach)}` })
try {
verschieben(von, nach)
} catch (fehler) {
return fehlschlag(
`Verschieben auf die Ablage fehlgeschlagen: ${String(fehler)} — die fertigen Dateien liegen unter ${zwischen}, die Roh-Dateien unter ${auftrag.rohOrdner}.`,
preset,
true,
)
}
}
if (zwischen.length > 0) {
try {
rmSync(zwischen, { recursive: true, force: true })
} catch {
// Ein leerer Zwischenordner, der stehen bleibt, stört nicht.
}
}
// ── Wiederholen: die alte Fassung weicht der neuen — JETZT erst, da alle
// neuen Dateien da sind. Vorher hätte ein gescheiterter Lauf die
// erste Fassung gekostet; danach stünde sie bei Serien der
// Umbenennung im Weg („existiert schon"). ────────────────────────
for (const alt of auftrag.ersetzt ?? []) {
if (fertigeDateien.includes(alt) || !existsSync(alt)) continue
try {
unlinkSync(alt)
hinweise.push(`Alte Fassung ersetzt: ${basename(alt)}`)
} catch (fehler) {
hinweise.push(`Alte Fassung nicht gelöscht (${basename(alt)}): ${String(fehler)}`)
}
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
let folgenVergeben: number[] = []
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
} else {
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden, undefined, folgenDateien))
folgenVergeben = episoden
} else {
hinweise.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
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',
kennung: zuordnung === null || zuordnung.meta.id === null ? undefined : { quelle: zuordnung.meta.quelle, id: String(zuordnung.meta.id) },
},
anzeigeTitel,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
// 5.7.0 (F21): Was mit dem Poster passiert ist, steht in den Hinweisen
// — vorher wurde die Antwort verschluckt (R4).
const posterErgebnis = await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
if (posterErgebnis.length > 0 && !posterErgebnis.startsWith('poster.jpg')) hinweise.push(posterErgebnis)
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
// unter dem alten Namen nicht mehr gibt.
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
let fertigBytes = 0
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const jetzt = umgebung.jetzt?.() ?? new Date()
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
umgebung.bibliothekEintragen({
fingerprint: auftrag.fingerprint,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
typ: auftrag.discTyp,
groesseBytes: fertigBytes,
ablageort: fertigOrdner,
datum: begonnen.toISOString(),
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
}
const text =
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
(refresh.length > 0 ? ` ${refresh}` : '') +
(auftrag.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '')
return {
status: 'fertig',
zielOrdner: fertigOrdner,
dateien: endgueltig,
text,
komprimiert,
preset: komprimiert ? preset : '',
fertigBytes,
hinweise,
folgenVergeben,
}
}
function abgebrochen(
zielOrdner: string,
dateien: string[],
preset: string,
komprimiert: boolean,
hinweise: string[],
): AblageErgebnis {
return {
status: 'abgebrochen',
zielOrdner,
dateien,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
komprimiert,
preset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
}
}
/** Alle MKVs im Zielordner (und in extras\) nach dem Umbenennen der Folgen
* ist das die einzige verlässliche Liste. */
function vorhandeneMkv(ordner: string, extras: string): string[] {
const liste: string[] = []
for (const o of [ordner, extras]) {
if (!existsSync(o)) continue
try {
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
} catch {
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
}
}
return liste.sort()
}
+283
View File
@@ -0,0 +1,283 @@
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
//
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { MetaVorschlag, SpurAngabe, TitelRolle } from '../../gemeinsam/nachrichten'
import { istSpurAngabe } from '../../gemeinsam/nachrichten'
import { spurenZuordnen, sprachenDerSpuren, type HbSpurAngabe } from '../../gemeinsam/spuren'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
pfad: string
titelNr: number
rolle: TitelRolle
dauerS: number
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
groesseBytes: number
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
/** 5.7.0: die GEWÄHLTEN Spuren (einzeln); fehlend = Sprachlisten gelten. */
audioSpuren?: SpurAngabe[]
untertitelSpuren?: SpurAngabe[]
/** 5.7.0: vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
untertitelVorwahlSpur?: number | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
export interface KompressionsAuftrag {
vorgangId: string
laufwerk: string
discTyp: string
rohOrdner: string
dateien: RohDatei[]
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
zuordnung: Zuordnung | null
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
staffel: number | null
ersteFolge: number | null
discNr: number | null
fingerprint: string | null
lesefehler: boolean
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
hinweise: string[]
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
begonnen: string
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
preset: string
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs die neue
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
* '' = Zielordner wird frisch bestimmt. */
zielOrdner: string
/** Beim Wiederholen: die fertigen Dateien des ERSTEN Durchlaufs. Sie
* werden gelöscht, sobald die neuen fertig sind vorher nicht, damit
* ein gescheiterter zweiter Lauf die erste Fassung nicht kostet. Bei
* Serien stünden sie sonst der Umbenennung im Weg (existiert schon"). */
ersetzt?: string[]
}
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
return JSON.stringify(auftrag)
}
/** Liest einen gemerkten Auftrag zurück null, wenn er nicht die Form hat
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
export function auftragAusJson(text: string): KompressionsAuftrag | null {
let roh: unknown
try {
roh = JSON.parse(text)
} catch {
return null
}
if (typeof roh !== 'object' || roh === null) return null
const a = roh as Record<string, unknown>
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
const dateien: RohDatei[] = []
for (const d of a.dateien) {
if (typeof d !== 'object' || d === null) return null
const e = d as Record<string, unknown>
if (typeof e.pfad !== 'string') return null
dateien.push({
pfad: e.pfad,
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
...(Array.isArray(e.audioSpuren) && e.audioSpuren.every(istSpurAngabe) ? { audioSpuren: e.audioSpuren } : {}),
...(Array.isArray(e.untertitelSpuren) && e.untertitelSpuren.every(istSpurAngabe) ? { untertitelSpuren: e.untertitelSpuren } : {}),
...(e.untertitelVorwahlSpur === null || typeof e.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: e.untertitelVorwahlSpur } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
return {
vorgangId: a.vorgangId,
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
rohOrdner: a.rohOrdner,
dateien,
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
staffel: typeof a.staffel === 'number' ? a.staffel : null,
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
discNr: typeof a.discNr === 'number' ? a.discNr : null,
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
lesefehler: a.lesefehler === true,
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
preset: typeof a.preset === 'string' ? a.preset : '',
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
ersetzt: Array.isArray(a.ersetzt) ? a.ersetzt.map(String) : [],
}
}
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
export function filmAus(wert: unknown): MetaVorschlag | null {
if (typeof wert !== 'object' || wert === null) return null
const f = wert as Record<string, unknown>
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
return {
typ: f.typ === 'tv' ? 'tv' : 'movie',
tmdbId: f.tmdbId,
titel: f.titel,
jahr: typeof f.jahr === 'number' ? f.jahr : null,
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
untertitel: string[]
/** Die erste Untertitelspur vorwählen weil der Ton nicht in der
* Wunschsprache ist. */
untertitelStandard: boolean
}
/**
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
* japanischer Ton und braucht dringend die deutschen untertitel"
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
global: { audio: readonly string[]; untertitel: readonly string[] },
): SprachWahl {
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
// Regel — null heißt ausdrücklich „keine Vorwahl".
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
const v = datei.untertitelVorwahl
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
}
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
return { audio, untertitel, untertitelStandard }
}
/** Was HandBrake bekommt, wenn Spuren EINZELN gewählt wurden (5.7.0). */
export interface SpurWahl {
/** HandBrake-Spurnummern (1-basiert, Scan-Reihenfolge). */
audioNr: number[]
untertitelNr: number[]
/** Index (1-basiert) der vorgewählten Untertitelspur in `untertitelNr`, 0 = keine. */
untertitelStandard: number
/** Klartext für die Hinweise: gewählte Spuren, die nicht in der Datei sind. */
fehlend: string[]
}
/**
* Spurwahl je Datei (pur, 5.7.0): Die gewählten MakeMKV-Spuren werden in
* HandBrakes Scan der Roh-Datei wiedergefunden (spurenZuordnen). Die Regel
* von 5.4.0 gilt weiter: Spielt die erste gewählte Tonspur nicht in der
* Wunschsprache, wird eine gewählte Untertitelspur der Wunschsprache
* vorgewählt außer der Nutzer hat eine Vorwahl gesetzt (Spur) oder
* ausdrücklich keine (null). Liefert null, wenn keine Spuren gewählt sind
* (dann gilt die Sprachwahl). */
export function spurwahl(
datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren' | 'untertitelVorwahlSpur'>,
scan: { audio: readonly HbSpurAngabe[]; untertitel: readonly HbSpurAngabe[] },
wunschSprache: string,
): SpurWahl | null {
if (datei.audioSpuren === undefined && datei.untertitelSpuren === undefined) return null
const audio = spurenZuordnen(datei.audioSpuren ?? [], scan.audio)
const untertitel = spurenZuordnen(datei.untertitelSpuren ?? [], scan.untertitel)
const gefundeneUntertitel = (datei.untertitelSpuren ?? []).filter((s) => !untertitel.fehlend.includes(s))
// Wer wird vorgewählt?
let standardSpur: SpurAngabe | undefined
if (datei.untertitelVorwahlSpur === null) {
standardSpur = undefined
} else if (typeof datei.untertitelVorwahlSpur === 'number') {
standardSpur = gefundeneUntertitel.find((s) => s.nr === datei.untertitelVorwahlSpur)
} else {
const ersteTon = (datei.audioSpuren ?? []).find((s) => !audio.fehlend.includes(s))
const tonFremd = wunschSprache.length > 0 && ersteTon !== undefined && ersteTon.lang.toLowerCase() !== wunschSprache
if (tonFremd) standardSpur = gefundeneUntertitel.find((s) => s.lang.toLowerCase() === wunschSprache)
}
// Die vorgewählte Spur nach vorn — HandBrakes --subtitle-default zählt
// die AUSGABE-Reihenfolge, und die folgt der Liste.
let untertitelNr = untertitel.nummern
let untertitelStandard = 0
if (standardSpur !== undefined) {
const i = gefundeneUntertitel.indexOf(standardSpur)
if (i >= 0) {
untertitelNr = [untertitel.nummern[i], ...untertitel.nummern.filter((_, k) => k !== i)]
untertitelStandard = 1
}
}
const fehlend = [
...audio.fehlend.map((s) => `Tonspur ${s.sprache || s.lang}${s.codec.length > 0 ? ` ${s.codec}` : ''}`),
...untertitel.fehlend.map((s) => `Untertitel ${s.sprache || s.lang}${s.beschreibung.includes('erzwungen') ? ' (nur erzwungene)' : ''}`),
]
return { audioNr: audio.nummern, untertitelNr, untertitelStandard, fehlend }
}
/** Die Sprachlisten aus gewählten Spuren der Rückfall, wenn der Scan der
* Roh-Datei scheitert (pur). */
export function sprachlistenAusSpuren(datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren'>): { audio: string[]; untertitel: string[] } {
return { audio: sprachenDerSpuren(datei.audioSpuren ?? []), untertitel: sprachenDerSpuren(datei.untertitelSpuren ?? []) }
}
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
}
/**
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
* Vorher sprang der Balken je Titel auf null zurück bei 2 × 16 GB stand
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
* Teile gleich. `prozent` < 0 heißt vom laufenden Teil noch keine Angabe".
*/
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
if (gewichte.length === 0) return -1
const gleich = gewichte.some((g) => !(g > 0))
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
const gesamt = g.reduce((s, w) => s + w, 0)
if (gesamt <= 0) return -1
const i = Math.min(Math.max(0, index), g.length - 1)
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
}
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
* Warnung der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
export function folgenPlausibel(
dateiSekunden: readonly number[],
nummern: readonly number[],
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
toleranz = 0.15,
): string[] {
const warnungen: string[] = []
for (let i = 0; i < nummern.length; i++) {
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
const ist = dateiSekunden[i] ?? 0
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
warnungen.push(
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
)
}
}
return warnungen
}
+89
View File
@@ -0,0 +1,89 @@
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
// Ampel die Regeln prüft:
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
// bleibt der Zeitstempel-Name des Vorgangs.
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { sichererName } from '../../gemeinsam/ablagepfad'
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
import type { RohDatei } from './auftrag'
export interface DiscAngaben {
staffel: number | null
discNr: number | null
}
export function rohOrdnerName(
zuordnung: Zuordnung | null,
angaben: DiscAngaben | null,
vorgangsName: string,
existiert: (name: string) => boolean,
): string {
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
let basis: string
if (zuordnung.meta.typ === 'tv') {
basis = sichererName(zuordnung.titel)
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
} else {
basis = sichererName(zuordnung.titel, zuordnung.jahr)
}
if (basis.length === 0) return vorgangsName
if (!existiert(basis)) return basis
for (let n = 2; n < 100; n++) {
const kandidat = `${basis} (${n})`
if (!existiert(kandidat)) return kandidat
}
return vorgangsName
}
export interface FilmGruppe {
film: MetaVorschlag
dateien: RohDatei[]
}
export interface FilmGruppen {
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
primaer: RohDatei[]
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
* übernimmt diesen Film. */
primaerFilm: MetaVorschlag | null
weitere: FilmGruppe[]
}
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
const primaer: RohDatei[] = []
const weitere: FilmGruppe[] = []
for (const d of dateien) {
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
primaer.push(d)
continue
}
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
if (gruppe !== undefined) gruppe.dateien.push(d)
else weitere.push({ film: d.film, dateien: [d] })
}
if (primaer.length === 0 && weitere.length > 0) {
const erste = weitere.shift()!
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
}
return { primaer, primaerFilm: null, weitere }
}
/** Eine Zuordnung aus dem Vorschlag allein wenn TMDb-Details nicht zu
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
return {
titel: v.titel,
jahr: v.jahr,
confidence: 1,
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
}
}
+901
View File
@@ -0,0 +1,901 @@
// 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).
//
// Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den
// RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions-
// Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts →
// ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien
// liegen — die nächste Disc kann rein, während die vorige noch rechnet
// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
// nach einem Abbruch nachsehen.
//
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
// aus dem Abbild (iso:), das nicht mehr hängen kann.
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, 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,
DiscTyp,
GeraeteInfo,
RipAuftragOptionen,
RipAuftragTitel,
RipStatus,
TitelRolle,
} from '../../gemeinsam/nachrichten'
import 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 { rettungsAbbildDatei } from '../rip/rettung'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
import { BLURAY_MIN_BYTES } from '../laufwerk/disc'
interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */
abbruch: AbortController
/** Beendet nur den gerade laufenden Titel (überspringen). */
titelAbbruch?: AbortController
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
uebersprungen?: boolean
status: RipStatus
}
/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */
export interface VorgangBeginn {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
rohOrdner: string
begonnen: string
}
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) — seit 5.7.0 asynchron mit Abbruch (F30). */
audioSpurLesen?: (
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
abbruch?: AbortSignal,
) => Promise<number>
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
ablage: () => string
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
rohWurzel?: () => string
einstellungen: () => Einstellungen
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
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) für
* Audio-CD und ISO, die keine Kompression durchlaufen. */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
/** 5.4.0: Der Rip ist durch der Kern merkt sich den Vorgang und reiht
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
* Disc) null, wenn nichts zu holen ist. */
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste
* Disc soll rein, während die Kompression läuft. */
nachRipAuswerfen?: (id: string) => void
/** 5.5.0: Das Rettungs-Abbild lesen — injizierbar für Tests. */
rettungsAbbild?: typeof rettungsAbbildDatei
}
/** Das Rettungs-Abbild und sein Bericht die zwei Dateien im Roh-Ordner,
* die KEIN Aufräumen anfasst (5.7.0, Durchsicht F8). */
export const RETTUNG_ISO = 'rettung.iso'
export const RETTUNG_DATEIEN: readonly string[] = [RETTUNG_ISO, `${RETTUNG_ISO}.rettung.txt`]
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
interface LetzterAuftrag {
auftrag: RipAuftragTitel[]
optionen: RipAuftragOptionen
}
export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>()
/** 5.5.0: Rippy hat an diesem Laufwerk Lesefehler GESEHEN nur dann
* wird das Rettungs-Abbild angeboten (Commander: nicht bei heilen Discs). */
private fehlerGesehen = new Map<string, string>()
private letzterAuftrag = new Map<string, LetzterAuftrag>()
constructor(private readonly umgebung: PipelineUmgebung) {}
laeuft(id: string): boolean {
return this.laufend.has(id)
}
/** Warum eine Rettung angeboten wird — '' wenn keine Fehler gesehen wurden. */
rettungGrund(id: string): string {
return this.fehlerGesehen.get(id) ?? ''
}
/** Beim Entnehmen der Disc: Was gesehen wurde, gilt nicht mehr. */
discEntnommen(id: string): void {
this.fehlerGesehen.delete(id)
this.letzterAuftrag.delete(id)
}
private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id)
const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
if (eintrag !== undefined) eintrag.status = mitRettung
this.umgebung.melden(mitRettung)
}
private fehler(id: string, text: string): RipStatus {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
}
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
* weiter der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
* während Folge 2 heil daneben lag). */
ueberspringen(id: string): void {
const vorgang = this.laufend.get(id)
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
vorgang.uebersprungen = true
vorgang.titelAbbruch.abort()
}
/** 5.5.0: Rettungs-Abbild + Rip daraus mit dem letzten Auftrag dieses
* Laufwerks. Nur, wenn Rippy vorher Lesefehler gesehen hat. */
async rettungStarten(id: string): Promise<void> {
const grund = this.fehlerGesehen.get(id)
if (grund === undefined) {
this.umgebung.melden(
this.fehler(id, 'Rettungs-Abbild nur bei GESEHENEN Lesefehlern — an dieser Disc hat Rippy keine bemerkt. Erst normal rippen.'),
)
return
}
const letzter = this.letzterAuftrag.get(id)
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
}
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher).
*
* 5.7.0 (Durchsicht 12.09.2026, F2): Löst NIE mit einer Ausnahme auf.
* Vorher warf `mkdirSync` bei nicht erreichbarer Ablage aus dieser
* Funktion heraus, der Aufrufer (`void … .then(() => starten())`)
* verschluckte das kein Fehler im Fenster, keine Zeile im Protokoll,
* der Dialog ging zu, nichts passierte (Sonde 10). Jetzt wird jede
* Ausnahme zur Fehler-Meldung des Laufwerks (R4). */
async starten(
id: string,
auftrag: readonly RipAuftragTitel[] = [],
optionen: RipAuftragOptionen = {},
rettung = false,
): Promise<void> {
try {
await this.lauf(id, auftrag, optionen, rettung)
} catch (fehler) {
const vorgangId = this.laufend.get(id)?.status.vorgangId
this.laufend.delete(id)
const text = `Rip auf Laufwerk ${id} abgebrochen — unerwarteter Fehler: ${String(fehler).slice(0, 300)}`
if (vorgangId !== undefined) this.umgebung.vorgangEndet?.(vorgangId, 'fehler', text)
this.umgebung.melden({ ...this.fehler(id, text), ...(vorgangId !== undefined ? { vorgangId } : {}) })
}
}
/** Ist dieser Ordner da und beschreibbar? Eine Ablage auf einer NAS oder
* einem Netzlaufwerk ist beim Start nicht selbstverständlich erreichbar
* ausprobieren statt annehmen (Regel aus AGENTS: Wo eine Eigenschaft
* ausprobierbar ist, probiere sie aus). '' wenn alles gut, sonst der Grund. */
static schreibprobe(ordner: string): string {
try {
mkdirSync(ordner, { recursive: true })
const probe = join(ordner, `.rippy-schreibprobe-${process.pid}`)
writeFileSync(probe, 'rippy')
unlinkSync(probe)
return ''
} catch (fehler) {
return String(fehler instanceof Error ? fehler.message : fehler)
}
}
private async lauf(
id: string,
auftrag: readonly RipAuftragTitel[],
optionen: RipAuftragOptionen,
rettung: boolean,
): Promise<void> {
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 vorgangsName = vorgangsNameFuer(geraet, startZeit)
// 5.7.0 (F2): Erst nachsehen, ob das Ziel überhaupt beschreibbar ist —
// eine NAS, die gerade weg ist, soll einen Satz in der Kachel
// hinterlassen, nicht ein stilles Nichts.
const zielWurzel = geraet.typ === 'cd' || geraet.typ === 'unknown' ? this.umgebung.ablage() : (this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'))
const probe = Pipeline.schreibprobe(zielWurzel)
if (probe.length > 0) {
this.umgebung.melden(
this.fehler(
id,
`Rip nicht gestartet: Der Ordner ${zielWurzel} ist nicht erreichbar oder nicht beschreibbar (${probe}). Ist das Netzlaufwerk bzw. die NAS verbunden? Einstellungen → Ablage zeigt die Pfade.`,
),
)
return
}
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
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
}
// 5.7.0 (F10): „unbekannt" heißt nur dann ISO, wenn NIEMAND Titel gewählt
// hat. Liegt ein Titel-Auftrag vor, hat makemkvcon die Disc gerade
// gelesen — dann ist es eine Video-Disc, egal was der Windows-Steuercode
// zum Typ sagte (bei manchen USB-Brücken antwortet er gar nicht).
if (geraet.typ === 'unknown' && auftrag.length === 0) {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
this.melden(this.laufend.get(id)!.status)
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
return
}
// Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe
// ist es eine Blu-ray oder DVD — der Typ steuert nur noch Preset und
// Ordnername.
const discTyp: DiscTyp = geraet.typ === 'unknown' ? (geraet.groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd') : geraet.typ
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
// behält seinen Zeitstempel-Namen als Kennung.
const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const startAngaben = this.umgebung.discAngaben?.(id) ?? null
const rohOrdner = join(
rohWurzel,
rohOrdnerName(
zuordnung,
{ staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
vorgangsName,
(name) => existsSync(join(rohWurzel, name)),
),
)
mkdirSync(rohOrdner, { recursive: true })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
this.umgebung.vorgangBeginnt?.({
id: vorgangsName,
laufwerk: id,
typ: discTyp,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
rohOrdner,
begonnen: startZeit.toISOString(),
})
const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const hinweise: string[] = []
this.laufend.set(id, {
abbruch,
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName },
})
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
this.laufend.delete(id)
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
// Punkt „Aufräumen nach Abbruch"). 5.7.0 (Durchsicht F8): AUSSER dem
// Rettungs-Abbild — dafür wurde die Disc stundenlang Sektor für Sektor
// gelesen; es bleibt liegen, auch wenn der Rip daraus leer ausging.
if (alleDateien.size === 0) {
try {
const rettungDa = existsSync(join(rohOrdner, RETTUNG_ISO))
if (rettungDa) {
for (const name of dateienIn(rohOrdner)) {
if (!RETTUNG_DATEIEN.includes(name)) rmSync(join(rohOrdner, name), { recursive: true, force: true })
}
text += ` Das Rettungs-Abbild bleibt unter ${rohOrdner}.`
} else {
rmSync(rohOrdner, { recursive: true, force: true })
}
} catch (fehler) {
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
}
}
this.umgebung.vorgangEndet?.(vorgangsName, phase, text)
this.melden({
id,
phase,
prozent: -1,
text,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
vorgangId: vorgangsName,
})
}
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
let quellePfad = geraet.pfad
if (rettung) {
const isoPfad = join(rohOrdner, RETTUNG_ISO)
this.melden({
id,
phase: 'rettet',
prozent: 0,
text: `Rettungs-Abbild: lese die Disc Sektor für Sektor nach ${isoPfad} — Unlesbares wird mit Nullen gefüllt …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: 0, stellen: 0 },
})
const lesen = this.umgebung.rettungsAbbild ?? rettungsAbbildDatei
const ergebnis = await lesen(
geraet.pfad,
isoPfad,
geraet.groesseBytes,
(prozent, unlesbar, stellen) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'rettet',
prozent,
text: `Rettungs-Abbild: ${prozent} % — ${unlesbar} unlesbare Sektoren an ${stellen} Stellen (mit Nullen gefüllt)`,
rettung: { unlesbareSektoren: unlesbar, stellen },
})
}
},
abbruch.signal,
)
if (ergebnis.status === 'cancelled') {
beenden('abgebrochen', 'Rettungs-Abbild abgebrochen.')
return
}
if (ergebnis.status === 'error') {
beenden('fehler', `Rettungs-Abbild gescheitert: ${ergebnis.fehler}`)
return
}
hinweise.push(
`Rettungs-Abbild: ${ergebnis.unlesbareSektoren} unlesbare Sektoren an ${ergebnis.stellen.length} Stellen mit Nullen gefüllt (Bericht: rettung.iso.rettung.txt).`,
)
quellePfad = isoPfad
this.melden({
id,
phase: 'rippt',
prozent: -1,
text: `Rettungs-Abbild fertig (${ergebnis.unlesbareSektoren} Sektoren genullt) — rippe jetzt aus dem Abbild …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: ergebnis.unlesbareSektoren, stellen: ergebnis.stellen.length },
})
}
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
// Trailer, Werbung und Alterskennzeichen.
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
// vorher sprang er je Titel zurück auf null.
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
const gescheitert: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
const titelAbbruch = new AbortController()
const weiterreichen = (): void => titelAbbruch.abort()
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
const vorgang = this.laufend.get(id)
if (vorgang !== undefined) {
vorgang.titelAbbruch = titelAbbruch
vorgang.uebersprungen = false
}
const beschriftung =
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
this.melden({
id,
phase: 'rippt',
prozent: gewichteterFortschritt(gewichte, i, -1),
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
titelGesamt: laeufe.length,
haengt: null,
vorgangId: vorgangsName,
})
// Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch
// oder Fehler ist eine halbe Datei und wird entfernt (5.4.0).
const vorher = dateienIn(rohOrdner)
const rip = await rippen({
programm,
geraetepfad: quellePfad,
zielOrdner: rohOrdner,
titel: welcher,
abbruch: titelAbbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, text })
},
aufHaengt: (klemmt) => {
// Der Defekt-Wächter hat angeschlagen: Ab jetzt ist die Rettung
// eine Option (5.5.0).
if (klemmt !== null && !rettung) this.fehlerGesehen.set(id, `Defekt-Wächter: hängt an Byte ${klemmt.offset}`)
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
},
})
abbruch.signal.removeEventListener('abort', weiterreichen)
if (rip.status === 'cancelled') {
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
// Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) {
beenden(
'abgebrochen',
'Abgebrochen.' +
(aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') +
(alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''),
)
return
}
uebersprungen.push(beschriftung)
continue
}
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
continue
}
// rippen() liefert den ganzen Zielordner zurück, also auch die
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
}
}
const nachLauf = this.laufend.get(id)
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
if (alleDateien.size === 0) {
beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.')
return
}
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
// ── Übergabe an die Warteschlange (5.4.0) ──────────────────────────
const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => {
const a = auftragJeDatei.get(pfad)
const rolle: TitelRolle = a?.rolle ?? 'hauptfilm'
return {
pfad,
titelNr: a?.nr ?? -1,
rolle,
dauerS: a?.dauerS ?? 0,
groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
// 5.7.0: die einzeln gewählten Spuren reisen mit in die Ablage.
...(a?.audioSpuren !== undefined ? { audioSpuren: a.audioSpuren } : {}),
...(a?.untertitelSpuren !== undefined ? { untertitelSpuren: a.untertitelSpuren } : {}),
...(a?.untertitelVorwahlSpur === null || typeof a?.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: a.untertitelVorwahlSpur } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
const angaben = this.umgebung.discAngaben?.(id) ?? null
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
let hauptDateien = gruppen.primaer
let hauptZuordnung = zuordnung
if (gruppen.primaerFilm !== null) {
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
}
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
for (let n = 0; n < gruppen.weitere.length; n++) {
const gruppe = gruppen.weitere[n]
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
const gruppenId = `${vorgangsName}-${n + 2}`
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
try {
mkdirSync(gruppenOrdner, { recursive: true })
for (const d of gruppe.dateien) {
const neu = join(gruppenOrdner, basename(d.pfad))
renameSync(d.pfad, neu)
d.pfad = neu
}
} catch (fehler) {
hinweise.push(`${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
hauptDateien = [...hauptDateien, ...gruppe.dateien]
continue
}
this.umgebung.vorgangBeginnt?.({
id: gruppenId,
laufwerk: id,
typ: discTyp,
titel: gruppenZuordnung.titel,
jahr: gruppenZuordnung.jahr,
rohOrdner: gruppenOrdner,
begonnen: startZeit.toISOString(),
})
weitereAuftraege.push({
titel: gruppenZuordnung.titel,
auftrag: {
vorgangId: gruppenId,
laufwerk: id,
discTyp,
rohOrdner: gruppenOrdner,
dateien: gruppe.dateien,
zuordnung: gruppenZuordnung,
staffel: null,
ersteFolge: null,
discNr: angaben?.discNr ?? null,
fingerprint: null,
lesefehler: lesefehlerGesamt,
hinweise: [],
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
},
})
}
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName,
laufwerk: id,
discTyp,
rohOrdner,
dateien: hauptDateien,
zuordnung: hauptZuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null,
fingerprint: this.umgebung.fingerprint?.(id) ?? null,
lesefehler: lesefehlerGesamt,
hinweise,
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
}
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
const weitereTitel: string[] = []
for (const w of weitereAuftraege) {
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
weitereTitel.push(w.titel)
}
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
this.laufend.delete(id)
this.umgebung.nachRipAuswerfen?.(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text:
`Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
(platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
(weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
(lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
(hinweise.length > 0 ? `${hinweise.join(' ')}` : ''),
dateien: dateien.map((d) => d.pfad),
lesefehler: lesefehlerGesamt,
vorgangId: vorgangsName,
})
}
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))
await 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) })
},
abbruch.signal,
)
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) groesse += groesseVon(datei)
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 })
}
}
}
export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an
// der Liste selbst.
return 0
}
}
function dateienIn(ordner: string): Set<string> {
try {
return new Set(readdirSync(ordner))
} catch {
// Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und
// im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist.
return new Set()
}
}
/** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines
* abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
* (das Rettungs-Abbild). Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
const entfernt: string[] = []
for (const name of dateienIn(ordner)) {
if (vorher.has(name) || schonen.includes(name)) continue
try {
unlinkSync(join(ordner, name))
entfernt.push(name)
} catch (fehler) {
// Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da.
entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`)
}
}
return entfernt
}
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
return new Promise((fertig) => {
const kind = werkzeugSpawn(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') }))
})
}

Some files were not shown because too many files have changed in this diff Show More