Compare commits

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
124 changed files with 12783 additions and 4163 deletions
+728 -7
View File
@@ -1,6 +1,721 @@
# SAVEPOINT — Rippy # SAVEPOINT — Rippy
## ÜBERGABE an die nächste Session (01.09.2026, spät — 5.4.0 gebaut und lokal installiert, NICHT veröffentlicht) ## ÜBERGABE an die nächste Session (13.09.2026 nachts — 5.7.1 VERÖFFENTLICHT im Tag `aktuell`: Titel lesen wartet, solange MakeMKV arbeitet — Der Patriot)
### Stand
**5.7.1 ist gebaut, am ausgepackten Paket bewiesen, auf DIESEM PC
installiert (13.09.2026 01:06, Installer-Exit 0, SHA-256 vorher geprüft,
Rippy lief dabei nicht; `Rippy.exe` 5.7.1 auch über `\\localhost\C$`)
und seit 13.09.2026 ~01:15 IM KANAL `aktuell`.**
**Veröffentlicht (13.09.2026 nachts, Commander: „Update freigegeben"):**
Release 1, Assets 46 (`RippySetup-5.7.1.exe`, 131 576 689 Bytes, HTTP 201),
47 (Blockmap, 137 575 Bytes, 201), 48 (`latest.yml`, 14 146 Bytes, 201 —
das alte Asset 45 vorher gelöscht, 204); Release umbenannt in „Rippy 5.7.1
(Update-Kanal aktuell)" mit den 5.7.1-Notizen (PATCH 200). **Anonym
extern gegengemessen:** `latest.yml` HTTP 200, 14 146 Bytes, Byte für Byte
gleich dem Bau (`version: 5.7.1`), Setup HTTP 200 mit Content-Length
131 576 689, Blockmap 200 mit 137 575. 5.7.0 (43/44) und alle älteren
Setups liegen weiter im Release, `w0.yml` (36) unangetastet. Der andere
PC holt sich 5.7.1 über die Update-Funktion. Weg wie bei 5.7.0: die
API-Schritte einzeln als PowerShell-Befehle (Token nie ausgegeben).
* Setup: `RippySetup-5.7.1.exe`, **131 576 689 Bytes**, SHA-256
`7463711833721C2068270DE39B43F0ABE433891EA10030AD2ECB21BF8480F669`
(Bau in `dist-setup-571/`, Kopie in `dist-setup/`; Blockmap 137 575 B,
`latest.yml` 14 146 B).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:6408,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.7.1`.
* **518 Tests** in 52 Dateien (5.7.0: 508), drei Typprüfungen, Bau, Klick-
Beweis (34 Schritte) und Ampel (`eb54c59`) grün.
* Commits: `eb54c59` (Fix, Tests, Notizen, Version 5.7.1, Klick-Bilder)
und dieser SAVEPOINT.
### Der Fund des Commanders (12.09.2026 spät): „Der Patriot" scheitert in 5.7.0 erneut
Bild: Dialog mit „✗ MakeMKV hat die Titel nach 15 Minuten nicht gelesen".
Daneben das MakeMKV-Programm (v1.18.4, LibreDrive), das dieselbe DVD
„The Patriot Extended Cut" fertig liest — sein Protokoll:
```
IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden.
Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.
CELLs 1-53 wurden vom Titelstart entfernt … Titel #1 wurde hinzugefügt (31 cell(s), 2:47:40)
… Titel #11 (0:09:46), #16 (0:09:58), #17 (0:02:01) · Die Aufgabe wurde erfolgreich abgeschlossen
```
**Ursache:** MakeMKV liest bei beschädigter IFO die ganze VOB-Datei
(5,8 GB) durch — so lange, wie das Laufwerk für die Disc braucht, im
Programm genauso. Rippys Grenze war eine feste GESAMTgrenze (5.6.1: 5 min,
5.7.0: 15 min) — für diese Disc immer zu kurz. Kandidat 2 der Durchsicht,
jetzt mit Beleg.
**Fix (5.7.1):**
* `buildInfoArgs` gibt `--progress=-same` mit; `titelInfoLesen` wertet die
Ausgabe zeilenweise aus (PRGV Prozent, PRGC Schritt, PRGT Aufgabe, MSG
Meldung) und beendet makemkvcon nur, wenn er so lange STILL ist, wie
die Einstellung erlaubt (`titelLesenMinuten`, Vorgabe 15) — jede Zeile
setzt die Uhr zurück. Kündigt MakeMKV den VOB-Scan an
(`istVobScanMeldung`: IFO + VOB im Text, deutsch wie englisch), gilt nur
noch die Obergrenze `VOB_SCAN_HOECHSTENS_MS` (4 h).
* Der Dialog zeigt währenddessen `titelLaufZwischenstand`: „MakeMKV liest
seit 17 min · Öffnen der DVD-Disc 43 % · letzte Meldung: „…" — beim
VOB-Scan mit der Erklärung (2060 min, im Programm genauso). Neue
Meldung/Schritt sofort (ab 30 s), Prozente höchstens alle 5 s, sonst
jede Minute.
* Bricht Rippy ab, stehen die letzten acht Zeilen von makemkvcon im
Fehler und im Protokoll („Titel-Lauf G gescheitert: … [makemkvcon info:
kein Lebenszeichen seit 900 s — letzte Zeilen: …]").
* Einstellungen → Allgemein heißt jetzt „Titel lesen — ohne Lebenszeichen
höchstens" (Klick-Beweis angepasst).
* Tests `test/lebenszeichen-571.test.ts` mit nachgebautem makemkvcon
(Node-Skript über aufruf.ts): redselig über die Stille-Grenze hinaus →
Titel kommt; stumm → „kein Lebenszeichen seit 1 s" mit letzten Zeilen;
VOB-Scan mit Obergrenze.
**Nicht gemessen (kein Patriot hier):** ob makemkvcon während des
VOB-Scans PRGV-Zeilen schickt. Deshalb die doppelte Absicherung: schickt
er Fortschritt, hält die Stille-Uhr ihn am Leben; schickt er nichts, hält
ihn die VOB-Ankündigung am Leben (Obergrenze 4 h). Der Commander sieht im
Dialog, was passiert — und das Protokoll trägt die letzten Zeilen.
### Was am Gerät zu prüfen ist
* **Der Patriot noch einmal:** Der Dialog muss nach ~1 min „MakeMKV liest
seit … · … letzte Meldung: „IFO-Datei … beschädigt …"" zeigen und
irgendwann die vier Titel (2:47:40, 9:46, 9:58, 2:01). **Wie lange das
MakeMKV-Programm braucht, bitte stoppen** — steht die Zeit über 15 min,
ist die Ursache bestätigt. Danach der Rip: MakeMKV scannt die VOB beim
Rippen noch einmal (gleiche Dauer), die Rip-Phase hat keine Zeitgrenze.
* Die Punkte aus der 5.7.0-Übergabe darunter (Spuren einzeln an der
Kizuna-Disc, Ton-Codec statt AAC, Schlüssel-Karte, anderer PC).
## Übergabe vom 12.09.2026 spät (5.7.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.7.1)
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch `worktree-windows-electron`
(Spitze `9d16e82` plus dieser SAVEPOINT). v5-Code in `rippy-windows/`.
* **Die drei Läufe:** `npm test` (508 Tests in 51 Dateien, 13 übersprungen),
`npm run smoke`, `npm run klick` (34 Klick-Schritte, Bilder
`beweise/klick/*.png``02-dialog-vorwahl.png` zeigt die Spur-Haken).
Alle drei in dieser Session grün; dazu die Messung
`RIPPY_MESSUNG=1 npx vitest run test/messung.spuren.test.ts` am
mitgelieferten HandBrake (braucht ffmpeg im PATH).
* **Release veröffentlichen — für 5.7.0 ERLEDIGT (12.09. spät, Commander:
„Update freigegeben"). Der Weg fürs nächste Mal, wieder NUR nach seinem
Wort:** Dateien im Gitea-Tag `aktuell` ERSETZEN (Setup, `.blockmap`,
`latest.yml`; `w0.yml` bleibt liegen), ERST Setup und Blockmap
hochladen, DANN `latest.yml` löschen (204) und neu hochladen (201),
Release PATCHen, anonym über
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
gegenmessen — Token `F:\Coding Stuff\Gittea Token\rippy.txt` über das
PowerShell-Werkzeug lesen (nie ausgeben), LAN-Adresse
`http://192.168.178.153:3000/api/v1/repos/Hitonabi/rippy/releases/1`,
Upload per `Invoke-WebRequest -Form @{ attachment = Get-Item … }`. Ein
Skript mit allen Schritten blockt der Auto-Modus-Wächter — die Schritte
einzeln als Befehle gehen. Dateien in `rippy-windows/dist-setup-570/`
(Original) und `rippy-windows/dist-setup/` (Kopie). **Im Kanal liegt
5.7.0** (5.6.1 wurde nie hochgeladen).
### Stand
**5.7.0 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
installiert (12.09.2026 22:41, Installer-Exit 0, SHA-256 vorher geprüft,
Rippy lief dabei nicht; `Rippy.exe` Version 5.7.0 auch über
`\\localhost\C$` gesehen) und seit 12.09.2026 ~22:55 IM KANAL `aktuell`.**
**Veröffentlicht (12.09.2026 spät, Commander: „Update freigegeben"):**
Release 1, Assets 43 (`RippySetup-5.7.0.exe`, 131 575 882 Bytes, HTTP 201),
44 (Blockmap, 137 533 Bytes, 201), 45 (`latest.yml`, 13 433 Bytes, 201 —
das alte Asset 42 vorher gelöscht, 204); Release umbenannt in „Rippy 5.7.0
(Update-Kanal aktuell)" mit den 5.7.0-Notizen (PATCH 200, 4 403 Zeichen).
**Anonym extern gegengemessen:** `latest.yml` HTTP 200, 13 433 Bytes, Byte
für Byte gleich dem Bau (`version: 5.7.0`), Setup HTTP 200 mit
Content-Length 131 575 882, Blockmap 200 mit 137 533. Die alten Setups
5.1.05.6.0 liegen weiter im Release, `w0.yml` (36) unangetastet. Der
zweite PC holt sich 5.7.0 über die Update-Funktion (5.6.0 → 5.7.0). Der
Auto-Modus-Wächter blockte den Start des Upload-Skripts; die API-Schritte
liefen einzeln als PowerShell-Befehle (Token nie ausgegeben).
* Setup: `RippySetup-5.7.0.exe`, **131 575 882 Bytes**, SHA-256
`006B761EC2552637A4D1294C066B62B594A3B8FF08BB658115FFCD429D4AC185`
(Blockmap 137 533 Bytes, `latest.yml` 13 433 Bytes mit den 5.7.0-Notizen).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:37052,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.7.0`.
* **508 Tests** in 51 Dateien (5.6.1: 450), drei Typprüfungen, Bau und
Klick-Beweis (34 Schritte) grün.
* **Ampel GRÜN** für `9d16e82` (Lauf 442) und `5353b38` (SAVEPOINT) —
nachdem der Commander den hängenden Lauf 279 abgebrochen hatte (22:38).
* **Update vorbereitet:** idempotentes Skript
`<Scratchpad>\veroeffentlichen-570.ps1` (Probe ohne `-Los`, Upload mit
`-Los`: ERST Setup + Blockmap, DANN latest.yml löschen/hochladen, Release
PATCHen, anonym gegenmessen; kein Remove-Item; Token nie ausgegeben —
der Auto-Modus-Wächter blockt den Start des Skripts, die Schritte
einzeln als PowerShell-Befehle sind der Weg). Kanal-Stand gemessen 22:45:
Release 1 „Rippy 5.6.0 (Update-Kanal aktuell)", 24 Assets, zuletzt 40
(`RippySetup-5.6.0.exe`), 41 (Blockmap), 42 (`latest.yml`, 8 273 B);
extern `latest.yml` HTTP 200 mit `version: 5.6.0`. Hochzuladen:
`dist-setup-570\RippySetup-5.7.0.exe` (131 575 882 B), `.blockmap`
(137 533 B), `latest.yml` (13 433 B, sha512 `VL9GcKxP…XdeXDg==`).
* Der frühere Hänger: Lauf 279 (`d457600`) blieb in `test/pipeline.test.ts` HÄNGEN —
der neue F2-Test nahm unter Linux `/proc/rippy-nicht-da` als tote
Ablage; auf procfs antwortet mkdir mit ENOENT, obwohl `/proc` da ist,
und Nodes `mkdirSync({recursive:true})` pendelt endlos (synchron, kein
Test-Timeout greift). Fix `9d16e82` (Ordner unter einer Datei → ENOTDIR,
sofort) ist gepusht; sein Lauf wartet, bis der hängende Lauf endet
(Gitea-Zeitlimit 3 h, ab 22:14) oder jemand ihn in Gitea abbricht
(Actions → #279 → Abbrechen; per API gibt es kein Cancel in 1.27).
Seit 22:41 erledigt (siehe oben).
* Commits dieser Session (alle auf `worktree-windows-electron`):
`6f1add4` Bericht der Durchsicht · `12b9720` F1/F5/F12/F13/A2 Schlüssel ·
`8c4456d` F2/F4/F6/F8/F10 kein stilles Scheitern · `904e4b2` F3/F9
Serien und Zwischenname · `551f1d7` F7 Fortschritt · `783b8e4` F11
Laufwerks-Gesundheit · `c1ee339` Kleinkram F15F30/A5 · `3dacd7c` F14
Erkennung · `ae82d8c` F16 Installer-Rückfrage · `6b6e5c3` Titel lesen
15 min · `0fc87aa` 4K nach Leistung · `fc2d16c` Disc-Typ aus dem
Dateisystem · `e7e33e9` Spuren einzeln + F31 · `d457600` Version 5.7.0 ·
`9d16e82` F2-Test ohne /proc.
### Was 5.7.0 enthält — die Durchsicht vom 12.09.2026, abgearbeitet
Der Bericht (`beweise/berichte/2026-09-12-durchsicht.md`, 31 Funde, 8
Auffälligkeiten, 5 Kandidaten für „Der Patriot / Déjà Vu / Training Day"
auf dem anderen PC) ist komplett umgesetzt — nach den Entscheiden des
Commanders vom selben Abend:
1. **F1 Beta-Key mit `@`** (wahrscheinlichste Ursache der drei Filme):
`KEY_MUSTER = /T-[^\s<>"'&;]{50,}/`, „end of September 2026" wird
gelesen, ein angenommener Schlüssel bleibt stehen, Ablehnung ROT,
„nicht prüfbar" (grau) ohne Laufwerk. **Vom anderen PC noch zu holen:**
Protokoll und Stand der Schlüssel-Karte NACH dem Update — dann ist der
Kandidat bestätigt oder verworfen.
2. **Kandidat 2 → Titel lesen 15 Minuten + Regler** (Einstellungen →
Allgemein, 560), Zwischenstand je Minute im Dialog, „Abbrechen"
beendet den Lauf im Kern, Meldung nennt Kopierschutz/Playlisten.
3. **Jede Spur einzeln anhaken** („Jede Spur einzeln anhaken
(Recommended)"): Dialog mit Ton- und Untertitel-Zeilen je Titel
(`gemeinsam/spuren.ts`, `SpurZeilen` im Dialog), Vorwahl je Extra als
Spur, Parser liest SINFO 2/22, abgeleitete Spuren (0x800) — DTS-Kerne
versteckt, „nur erzwungene" sichtbar; Ablage findet die Spuren im
HandBrake-Scan der Roh-Datei wieder (`spurenZuordnen`, gestuft) und
gibt `--audio n,n --subtitle n,n --subtitle-default=1`; MakeMKV-Regel
jetzt `-sel:all,+sel:all,-sel:havecore,=100:all` (Stereo-Doppel und
Kommentare bleiben), alte Rippy-Regel wird ersetzt, fremde bleibt.
4. **4K nach der Leistung** („Ja, genau so"): GPU mit 4K-Preset →
komprimieren (8 Bit), nur CPU → verlustfrei; AV1 10 Bit als eigene Wahl.
5. **F10 Disc-Typ aus dem Dateisystem**: `INDX0300` = UHD (am Akira-Abbild
gemessen), `0100/0200` = Blu-ray, `VIDEO_TS` = DVD.
6. **F16 Installer aus dem Archiv nur nach Rückfrage** („Archiv nur nach
Rückfrage"): Frage in der MakeMKV-Karte, Größe + SHA-256 im Bericht.
7. **F31 (NEU, beim Bau gemessen): Ton war seit 5.0 nie verlustfrei.**
`--aencoder copy` ohne `--audio-copy-mask` → HandBrakeCLI 1.11.2 kopiert
nur AAC, rechnet AC3/DTS/TrueHD um. Jetzt Maske
`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac` auf beiden Wegen. **Dem
Commander sagen:** Filme, deren Roh-Dateien nach der Kompression
gelöscht wurden, tragen AAC — bei wichtigem HD-Ton neu rippen.
8. Alle übrigen Funde F2F30 und A5 (siehe Commit-Liste oben; jeder mit
Test).
### Messungen dieser Session (Regel D)
* HandBrakeCLI 1.11.2 (vendor), Quelle per ffmpeg (jpn 5.1 / deu 2.0 /
deu 2.0 „Kommentar" AC3, deu/eng SRT), Preset „H.265 MKV 1080p30":
`--audio 1,3` → jpn + deu-Kommentar; `--subtitle 2,1 --subtitle-default=1`
→ eng (default) vor deu; `--audio none --subtitle none` → nur Video;
ohne `--audio-copy-mask` Passthru-Zeile „allowed codecs are AAC" und
Ausgabe AAC; mit Maske „AAC, AC3, E-AC3, TrueHD, DTS, DTS-HD, MP3, FLAC"
und Ausgabe AC3. Presets-Export: alle drei Produktions-Presets tragen die
volle CopyMask — die CLI verengt sie trotzdem.
* HandBrake-Scan-JSON: `AudioList[].CodecName` („dtshd", „dca", „ac3"),
`ChannelCount`, `ChannelLayout`; `SubtitleList[].Source` (Zahl),
`SourceName` („UTF-8", „PGS"), `Format`.
* HandBrake 4K (RX 9070 XT): `vce_h265` 8 Bit läuft, `vce_h265_10bit`
Segfault (Profil auto und main10), `vce_av1_10bit` läuft.
* `BDMV/index.bdmv` am Akira-UHD-Abbild: `INDX0300`.
* `QueryDosDeviceW`: C: → `\Device\HarddiskVolume7`.
* Electron 44 utilityProcess überlebt unbehandelte Promise-Ablehnung.
* Forum t=1053: Key 68 Zeichen mit `@`, „valid until end of September 2026".
* Echte `rippy.db` über `\\localhost\C$`: Schema 4 — der Datenbank-Befund
vom 01.09. war die MSIX-Schattensicht dieses Terminals (A1).
* Nodes `mkdirSync({recursive:true})` auf procfs: Endlosschleife (Ampel-Lauf 279).
### ⚠ Am Gerät noch zu messen
* **Spuren einzeln an einer echten Disc** (Kizuna: DTS-HD MA deu/jpn,
drei PGS deu): Dialog-Zeilen, DTS-Kern unsichtbar, Roh-Datei enthält
die Stereo-Fassung (neue MakeMKV-Regel — beim ersten Start von 5.7.0
meldet die Werkzeug-Karte „Auswahlregel erneuert"), HandBrake-Ausgabe
mit genau den gewählten Spuren, Ton-Codec in der fertigen Datei DTS-HD
MA statt AAC (F31).
* Gewöhnliche Blu-ray: `index.bdmv` muss `INDX0200` (oder 0100) tragen.
* 4K-Disc am Laufwerk: Typ `uhd`, Preset „H.265 VCN 2160p 4K", Encode
läuft durch (Messung nur am Abbild).
* Titel lesen > 5 min an einer zähen DVD; Abbrechen gibt das Laufwerk frei.
* Rettungs-Abbild an einer AACS-Disc (nie gemessen, siehe 5.5.0).
* Der andere PC: Protokoll + Schlüssel-Karte nach dem Update (Kandidat 1).
### Offene Fragen an den Commander
* **4K-Vorgabe:** 8-Bit-HEVC-Hardware (läuft) oder AV1 10 Bit (läuft,
braucht AV1-fähige Abspielgeräte)? Heute: HEVC 8 Bit vorgegeben, AV1
wählbar.
* **Alte Filme mit AAC-Ton (F31):** neu rippen oder so lassen?
### Bekannte Schwächen, bewusst in Kauf genommen
* Die Spur-Zuordnung Roh-Datei ↔ Dialog läuft über Sprache + Codec-Familie
+ Kanäle (MakeMKV nummeriert anders als HandBrake); bei zwei identischen
Spuren gleicher Sprache, gleichen Codecs und gleicher Kanalzahl
entscheidet die Reihenfolge. Was nicht gefunden wird, steht als Hinweis.
* Keine gewählte Tonspur → HandBrakes erste Spur (kein stummer Film); der
Dialog sagt es.
* Die Automatik (ohne Dialog) rippt weiter nach Sprachlisten, nicht nach
Spuren.
* 10-Bit-HEVC per VCE stürzt in HandBrake 1.11.2 ab — 4K bleibt 8 Bit,
bis ein neueres HandBrake mitgeliefert wird.
* Der Kanal trägt 5.7.0; der andere PC (5.6.0) holt sie über das Update,
dieser PC hat sie per Installer.
## Übergabe vom 03.09.2026 spät (5.6.1 — gebaut und lokal installiert, nie veröffentlicht; überholt durch 5.7.0)
### Stand
**5.6.1 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
installiert (03.09.2026 19:40, Installer-Exit 0 — Rippy 5.6.0 lief dabei mit fünf Prozessen ohne Rip oder Encode und wurde vom Installer beendet), NICHT im Kanal — dort liegt 5.6.0.** Der
Commander testet erst; auf sein Wort geht 5.6.1 in den Kanal (Weg wie bei
5.6.0, siehe die Übergabe darunter: ERST Setup und Blockmap, DANN
`latest.yml`, PATCH, anonym gegenmessen).
* Setup: `RippySetup-5.6.1.exe`, **131 566 691 Bytes**, SHA-256 `04806C8DF53A799FB38EE185E3FE49A8CA139D61CF6BAD041C39A4F6CAEF6ED4`
(Bau in `dist-setup-561/`, Kopie in `dist-setup/`).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:27472,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.6.1`.
* **450 Tests** in 45 Dateien, drei Typprüfungen, Bau
und Klick-Beweis (33 Schritte) grün.
* Commits: `6871734` (Fix, Test, Messung, Aenderungsnotizen, Version 5.6.1 — 17 Dateien) und dieser SAVEPOINT.
### Der Fund des Commanders (03.09.2026, 19:35): „die auswahl der untertitel ist weiterhin nicht drin"
Bild aus 5.6.0: Blu-ray „Digimon Adventure: Last Evolution Kizuna" (2020),
Titel 1 mit TON deu×2 und jpn×2, aber ohne UNTERTITEL-Chips. Gemessen
(Regel D) mit `makemkvcon64 -r --minlength=120 info disc:0` an der Disc in
G: — die volle Ausgabe liegt unter
`beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt` (375 Zeilen):
```
SINFO:1,1,1,6202,"Audio" SINFO:1,1,3,0,"deu" DTS-HD MA Surround 5.1 German
SINFO:1,3,1,6202,"Audio" SINFO:1,3,3,0,"jpn" DTS-HD MA Stereo Japanese
SINFO:1,5,1,6203,"Untertitel" SINFO:1,5,3,0,"deu" PGS German
SINFO:1,6,1,6203,"Untertitel" SINFO:1,6,3,0,"deu" PGS German (nur erzwungene) Attribut 22 = 6144
SINFO:1,7,1,6203,"Untertitel" SINFO:1,7,3,0,"deu"
```
**Ursache:** MakeMKV schreibt den Stream-Typ (Attribut 1) in seiner
Anzeigesprache. Deutsch eingestellt heißt es „Untertitel"; der Parser
(`kern/rip/parser.ts parseStreamInfo`) prüfte seit 26.07. auf das
englische „Subtitles" (damals an einem englisch eingestellten MakeMKV
gemessen). „Audio" heißt zufällig in beiden Sprachen gleich — deshalb
kamen die Tonspuren an, die Untertitel nicht. Die MakeMKV-Auswahlregel
(`spurauswahl.ts`) war nicht betroffen: MakeMKV nimmt alle Spuren mit, nur
der Dialog konnte sie nicht zeigen.
**Fix (5.6.1):** Der Typ-CODE im vierten Feld zählt — 6201 Video, 6202
Audio, 6203 Untertitel (`STREAM_CODE_*`, `StreamInfo.typCode`); das Wort
bleibt nur Notnagel für Ausgaben ohne Code. Test
`test/rip-parser-561.test.ts` mit den gemessenen Zeilen (deu×2/jpn×2 Ton,
deu×3 Untertitel; französisch „Sous-titres" per Code erkannt).
**Nebenbefund für später:** Attribut 22 trägt Flags — 6144 bei der Spur
„nur erzwungene", 0 bei der vollen; Attribut 30 die Beschreibung („PGS
German (nur erzwungene)"). Damit ließe sich die Option „einzelne Spuren
(voll / nur erzwungen)" bauen, die der Commander am 03.09. NICHT gewählt
hat („nur Extras, immer optional"). Heute: Chip „deu×3" = alle drei
kommen mit, die Vorwahl gilt je Sprache.
### Was am Gerät zu prüfen ist
* Dieselbe Disc noch einmal in den Dialog: UNTERTITEL-Chip „deu×3" muss
erscheinen; bei Rolle Extra dazu „BEIM ABSPIELEN AN".
* Alles aus der 5.6.0-Übergabe darunter (zwei Filme je Disc, Vorwahl im
Spieler, Restzeit, Hell-Modus mit Postern, Rettungs-Abbild, Automatik,
Benachrichtigung, Datenbank-Befund).
## Übergabe vom 03.09.2026 abends (5.6.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.6.1)
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch `worktree-windows-electron`
(Spitze siehe `git log`). v5-Code in `rippy-windows/`; `main` ist reines
Docker-Rippy.
* **Bau:** wie in BAUEN.md — `npm install` + `node node_modules/electron/install.js`,
Playwright mit `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, `vendor/` aus
`%LOCALAPPDATA%\Rippy\tools`.
* **Die drei Läufe:** `npm test` (446 Tests in 44 Dateien),
`npm run smoke` (Gerüst), `npm run klick` (33 Klick-Schritte, Bilder
`beweise/klick/*.png`, darunter `07-hell-modus.png` und `02-dialog-vorwahl.png`).
Alle drei in dieser Session grün.
* **Release veröffentlichen — für 5.6.0 ERLEDIGT (03.09. abends, Commander:
„Dann Deploy und aktualisierung bei mir aufm lokalen pc“). Der Weg fürs
nächste Mal, wieder NUR nach seinem Wort:** die
Dateien im Gitea-Tag `aktuell` ERSETZEN (`RippySetup-5.6.0.exe`,
`.blockmap`, `latest.yml`; `w0.yml` bleibt liegen). Dateien liegen in
`rippy-windows/dist-setup/` (Kopie) und `rippy-windows/dist-setup-560/`
(Original). Weg wie bei 5.5.0 (siehe unten „Veröffentlicht"): Token
`F:\Coding Stuff\Gittea Token\rippy.txt` über das PowerShell-Werkzeug
lesen (nie ausgeben), LAN-Adresse
`http://192.168.178.153:3000/api/v1/repos/Hitonabi/rippy/releases/1`,
ERST Setup und Blockmap hochladen, DANN `latest.yml` löschen (204) und
neu hochladen (201), Release-Name/Notizen PATCHen, anonym über
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
gegenmessen. Das PowerShell-Werkzeug blockt Befehle mit `Remove-Item`
Skript ohne schreiben. **Im Kanal liegt 5.6.0.**
### Stand
**5.6.0 ist gebaut, am ausgepackten Paket bewiesen, auf DIESEM PC
installiert (03.09.2026 19:17, Installer-Exit 0, Rippy lief dabei nicht) und
seit dem Abend des 03.09.2026 IM KANAL `aktuell`.**
**Veröffentlicht (03.09.2026 abends, Commander: „perfekt, danke. Dann
Deploy und aktualisierung bei mir aufm lokalen pc“):** Release 1, Assets 40
(`RippySetup-5.6.0.exe`, 131 566 582 Bytes, HTTP 201), 41 (Blockmap,
137 537 Bytes, 201), 42 (`latest.yml`, 8 273 Bytes, 201 — das alte Asset 39
vorher gelöscht, 204); Release umbenannt in „Rippy 5.6.0 (Update-Kanal
aktuell)“ mit den 5.6.0-Notizen (PATCH 200). **Anonym extern
gegengemessen:** `latest.yml` HTTP 200 und Byte für Byte gleich dem Bau
(`cmp`), Setup HTTP 200 mit Content-Length 131 566 582, Blockmap 200 mit
137 537. Lokal noch einmal geprüft: `%LOCALAPPDATA%\Programs\Rippy\Rippy.exe`
Version 5.6.0.0 (Stand 19:16), kein Rippy-Prozess lief. Der zweite PC holt
sich 5.6.0 über die Update-Funktion (5.5.0 → 5.6.0).
* Setup: `RippySetup-5.6.0.exe`, **131 566 582 Bytes**, SHA-256
`4392CA5D36E165491085810D4181B22594BB29D62334ED1CDD0D2171264DA1FD` (Bau in `dist-setup-560/`).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:15172,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.6.0`.
* **446 Tests** in 44 Dateien (5.5.0: 413), drei
Typprüfungen und Bau grün.
* Klick-Beweis: 33 Schritte grün, Ausgang 0 — neu gegenüber 5.5.0:
beide Titel auf Hauptfilm → keine Vorwahl; Titel 1 auf Extra → „BEIM
ABSPIELEN AN" erscheint, `deu` gewählt; zwei „anderer Film …"-Knöpfe,
Suchfeld öffnet; Kompressions-Karte zeigt „noch … s" (ETA aus der
nachgebauten HandBrake-Zeile), die Kachel zeigt KEINEN vollen Block mehr,
sondern „Kompression fertig — das Ergebnis steht im Verlauf"; Hell-Modus
über Einstellungen → Allgemein (`html[data-design=hell]`, Bild), zurück
auf Dunkel; Vorgangs-Leiste in der Kachel (TITEL LESEN ✓, RIPPEN aktiv,
KOMPRIMIEREN/ABLEGEN offen, keine „PHASE x/4"-Texte) und in der
Kompressions-Karte (KOMPRIMIEREN aktiv).
* Commits dieser Session: `f5dab5d` (Kern, Fenster, Tests, Klick-Beweis, Aenderungsnotizen, Version 5.6.0 — 59 Dateien) und dieser SAVEPOINT.
### Was 5.6.0 enthält — die sechs Punkte des Commanders vom 03.09.2026
Seine Worte: „Bei Kompression ETA anzeigen / Wenn ein Job abgeschlossen
wird, wird Kompression beim Hero-Header und im Kompression angezeigt — nur
nötig bei Kompression, oder? / Beim Scrollen in den Einstellungen muss die
Bar oben sinnvoll mitscrollen / Untertitel müssen separat auswählbar sein
(das hatten wir besprochen) / White Mode / Verhalten, wenn eine Disc mehr
als einen Film hat, besprechen." Seine Entscheide in den Rückfragen: **nur
in der Kompressions-Karte; Untertitel-Vorwahl je Inhalt — „NUR für Extras
und IMMER OPTIONAL", weiche Spur, nie eingebrannt; je Titel eigener Film
im Dialog; Roh-Ordner UND Dateien tragen den Filmnamen.**
1. **Restzeit (ETA):** `kern/komprimieren/handbrake.ts etaAusZeile` liest
HandBrakes eigene Klammer — Formatstring aus dem Binary (1.11.2):
`Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)`.
`KompressionStand.restS` → Kompressions-Karte „noch ~12 min"
(`bausteine.restText`). In den ersten Sekunden ohne Klammer: „Restzeit
wird berechnet …".
2. **Kachel-Kurzzeile:** `LaufwerkKachel` zeigt nach dem Rip nur noch eine
Zeile (läuft / Platz n / fertig → Verlauf / Fehler bleibt sichtbar, R4).
3. **Kopfzeile bleibt stehen:** `App.tsx` header `sticky top-0 z-40` mit
`bg-buehne/85 backdrop-blur-md`; die Sektionswahl der Einstellungen
sitzt darunter (`lg:top-[76px]`).
4. **Untertitel-Vorwahl je Extra:** `RipAuftragTitel.untertitelVorwahl`
(`string` = diese Sprache vorgewählt, `null` = keine, fehlend = Rippys
Regel) → `RohDatei.untertitelVorwahl``auftrag.ts sprachwahl` stellt
sie nach vorn und setzt `untertitelStandard` (HandBrake
`--subtitle-default=1`, weiche Spur). Dialog: Select „BEIM ABSPIELEN AN"
nur bei Rolle Extra, Vorgabe „Rippys Regel (deu)".
5. **Mehrere Filme je Disc:** `RipAuftragTitel.film` (MetaVorschlag) aus dem
Dialog („anderer Film …" mit TMDb-Suche über `metadaten-suchen`);
`kern/ablauf/filme.ts filmGruppen` teilt am Rip-Ende: Titel eines
anderen Films ziehen per `renameSync` in einen eigenen Roh-Ordner
(`rohOrdnerName`), bekommen eigenen Vorgang (`<vorgang>-2`, `-3` …) und
eigenen Kompressions-Auftrag mit voller Zuordnung
(`erkennung.zuordnungAus` → TMDb-Details; Notnagel
`zuordnungAusVorschlag`). Behält KEIN Titel die Disc-Zuordnung, wird die
erste Gruppe zum Hauptvorgang. Die Vorschau im Dialog zeigt je Film
seine Zeile.
6. **Ordner und Dateien mit Filmnamen:** Roh-Ordner
`roh\Akira (1988)` bzw. `roh\Spartacus - Staffel 1 Disc 2`, bei
Wiederholung „(2)"; ohne Zuordnung weiter der Zeitstempel. Dateien
(`kern/ablage/dateinamen.ts`): `Akira (1988).mkv`, zwei Fassungen
`… - Fassung 1.mkv`, Extras `… - Extra 01.mkv` in `extras\`; Folgen
behalten den Namen bis zur Episoden-Zuordnung. Der Vorgangs-Name
(Zeitstempel) bleibt die Kennung — `Vorgang.rohOrdner` trägt den Pfad.
7. **Hell-Modus:** Einstellung `design` (dunkel / hell / system);
`App.tsx` setzt `data-design` auf `<html>`, `stil.css` überschreibt die
Farb-Tokens (`:root[data-design="hell"]`: warmes Papier, dunkleres Gold,
Tinte statt Weiß). Neuer Token `--color-tinte` ersetzt alle
`white/`-Alpha-Klassen (`bg-tinte/…`, `border-tinte/…`).
8. **Vorgangs-Leiste** (Commander mitten in der Session: „Phase 14 plus
Komprimierung — zu einem machen, der Phasen-Balken gefällt mir"; sein
Ja zu Variante A): `gemeinsam/vorgangsleiste.ts` (pur: Stationen
Titel lesen → [Rettungs-Abbild] → Rippen → Komprimieren → Ablegen,
`stationsZustand`, `leisteAusRip/-Vorgang/-Kompression`) und
`fenster/uebersicht/VorgangsLeiste.tsx` (Segment-Balken in der aktiven
Station, kompakt als Punkte). Kachel: Details der Laufwerks-Stationen,
nach dem Rip nur der Stand; Kompressions-Karte: Stationen 34 mit
Restzeit; Verlauf und Warteschlange: Punkte. Die Texte „PHASE x/4"
sind weg (`PHASEN_MONO`).
### ⚠ Am Gerät noch zu messen (nichts davon war in dieser Session möglich)
* **Zwei Filme auf einer echten Disc** (Double Feature): Roh-Umzug per
`renameSync` im Arbeitsordner, zwei Vorgänge, zwei Zielordner mit Poster
und NFO — Unit-Tests decken die Gruppen-Logik (`filme.test.ts`), nicht
den Lauf mit MakeMKV.
* **Untertitel-Vorwahl im Spieler:** Ob Jellyfin/VLC die als Default
markierte Spur bei einem Extra wirklich einblendet — HandBrake-Flag ist
am 01.09. gemessen (setzt default UND forced), das Abspielen nicht.
* **Restzeit an einem echten Encode** (die Klammer erscheint nach den
ersten Sekunden; im Klick-Beweis kommt sie vom Nachbau).
* **Hell-Modus mit Poster-Kulisse und echten Postern.**
* Weiter offen aus 5.5.0: Rettungs-Abbild an Spartacus Disc 2, Automatik an
echter Disc, Benachrichtigung live, Datenbank-Befund vom 01.09. (siehe
5.5.0-Übergabe unten).
### Bekannte Schwächen, bewusst in Kauf genommen
* Ein Roh-Ordner mit Filmnamen ist auch bei „Neu komprimieren" derselbe —
die Regel „ein Ordner je Vorgang" gilt weiter, nur der Name ist lesbar.
* Der zweite Film einer Disc bekommt Staffel/Folge nie (nur Filme, keine
Serien) und keinen Fingerprint (die Disc als Ganzes ist beim Hauptvorgang
gemerkt).
* Ohne Arbeitsordner schreibt HandBrake weiterhin direkt ins Ziel — bei
„Neu komprimieren" in denselben Ordner überschreibt der neue Encode die
alte Datei gleichen Namens erst am Ende des Laufs (Umzug), aber ohne
Arbeitsordner während des Laufs. Wer wiederholt, sollte den Arbeitsordner
gesetzt haben (5.5.0-Empfehlung).
* Das Fenster startet mit dunklem Hintergrund (`backgroundColor` in
`haupt/fenster.ts`) und schaltet erst mit den Einstellungen auf Hell —
ein kurzes Aufblitzen beim Start.
## Übergabe vom 02.09.2026 abends (5.5.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.6.0)
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch `worktree-windows-electron`
(Spitze siehe `git log`). v5-Code in `rippy-windows/`; `main` ist reines
Docker-Rippy.
* **Bau:** wie in BAUEN.md — `npm install` + `node node_modules/electron/install.js`,
Playwright mit `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, `vendor/` aus
`%LOCALAPPDATA%\Rippy\tools`.
* **Die drei Läufe:** `npm test` (413 Tests in 42 Dateien),
`npm run smoke` (Gerüst), `npm run klick` (29 Klick-Schritte durch
das echte Programm, jetzt auch durch die Erst-Einrichtung; Bilder
`beweise/klick/01…09*.png`). Alle drei in dieser Session grün.
* **Release veröffentlichen — für 5.5.0 ERLEDIGT (02.09. abends, Commander:
„veröffentlichen und installation lokal bei mir"). Der Weg fürs nächste
Mal, wieder NUR nach seinem Wort:** die Dateien im Gitea-Tag `aktuell`
ERSETZEN (`RippySetup-5.5.0.exe`, `.blockmap`, `latest.yml`; `w0.yml`
bleibt liegen). Dateien liegen in `rippy-windows/dist-setup/` (Kopie)
und `rippy-windows/dist-setup-550/` (Original). Token:
`F:\Coding Stuff\Gittea Token\rippy.txt` (40 Hex; über das
PowerShell-Werkzeug lesen, nie ausgeben). Weg wie am 01.09.: LAN-Adresse
`http://192.168.178.153:3000/api/v1/…/releases/1`, `latest.yml` erst
löschen (204), dann hochladen (201), danach ANONYM über die externe
HTTPS-Adresse gegenmessen. **5.4.0 war nie im Kanal — 5.5.0 ersetzt sie
direkt; im Kanal liegt bis dahin 5.3.2.**
### Stand
**5.5.0 ist gebaut, am ausgepackten Paket bewiesen, auf DIESEM PC
installiert (17:50, Installer-Exit 0, Rippy lief dabei nicht) und seit dem
Abend des 02.09.2026 IM KANAL `aktuell`.**
**Veröffentlicht (02.09.2026 abends):** Release 1, Assets 37
(`RippySetup-5.5.0.exe`, 131 560 997 Bytes, HTTP 201), 38 (Blockmap,
137 360 Bytes, 201), 39 (`latest.yml`, 6 524 Bytes, 201 — das alte Asset 35
vorher gelöscht, 204); Release umbenannt in „Rippy 5.5.0 (Update-Kanal
aktuell)" mit den 5.5.0-Notizen (PATCH 200). **Anonym extern
gegengemessen** (`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/
releases/download/aktuell/…`): `latest.yml` HTTP 200 und Byte für Byte
gleich dem Bau (`cmp`), Setup HTTP 200 mit Content-Length 131 560 997,
Blockmap 200 mit 137 360. Die alten Setups 5.1.05.3.2 liegen wie bisher
weiter im Release; `w0.yml` (36) unangetastet. Der zweite PC bekommt 5.5.0
über die Update-Funktion. Der PowerShell-Wächter des Werkzeugs blockt
Befehle mit `Remove-Item` — das Upload-Skript kommt ohne aus. Der Commander wollte 5.4.0 vor
der Freigabe noch umgebaut haben (Clean Code, UI/UX, Übersicht, FirstRun)
— das ist 5.5.0; 5.4.0 wurde dadurch nie veröffentlicht.
* Setup: `RippySetup-5.5.0.exe`, **131 560 997 Bytes**, SHA-256
`8E55C7797BC41C6A153B584CF58556104A4D67F8A2A2590D570079CA719A6181` (Bau in `dist-setup-550/`).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:23276,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.5.0`.
* **413 Tests** in 42 Dateien (5.4.0: 353), drei
Typprüfungen und Bau grün.
* Klick-Beweis: 29 Schritte grün, Ausgang 0 — Speicher-Karte
(Arbeitsordner und Ablage getrennt, Fußleiste FREI) → keine Bibliothek →
Rippen → Dialog → drei Titel → Sprach-Chips → jpn statt deu →
„Hauptinhalt + Extras" → Rollen auf Folge → Staffel 1, ab Folge 3 →
Vorschau → **Platz-Prüfung** → RIPPT → „Kompression eingereiht" →
Kompressions-Karte → „Komprimiert und abgelegt" → Dateien `S01E03/S01E04`
unter `Season 01` → **Roh lokal im Arbeitsordner, Zwischenordner
geräumt** → **Verlauf****Bilanz** → Roh-Dateien-Karte → Kompression
ohne „Allgemeines Preset", Chips Deutsch/Englisch → Werkzeuge mit
Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update → Roh
löschen → Ereignisse → **zweiter Start `RIPPY_START_BEREICH=einrichtung`:
Willkommen vor 7 Poster-Spalten → Prüfung → Ordner → Qualität → Titel →
Meldungen → Automatik → „Los geht's"**.
* Commits dieser Session: `3dc6798` (Kern, Oberfläche, Tests, Klick-Beweis, Aenderungsnotizen, Version 5.5.0 — 67 Dateien) und dieser SAVEPOINT.
### Was 5.5.0 enthält — die Wünsche des Commanders vom 02.09.2026
Seine Worte (gekürzt): „Bevor wir das Update freigeben … CleanCode:
Separation of Concerns / UI/UX: MakeMKV-Header weg aus der Übersicht,
„Bereit"-Parameter sinnvoll, Allgemeines Preset weg, Dropdowns für Ton
und Untertitel, Programm → Allgemein oder eigener Update-Block,
Durchsuchen bei Werkzeugen / Bibliothek: brauchen wir die wirklich? /
Übersicht ist zu leer: freier Speicher auf Server und Arbeitsordner /
alles auch in den FirstRun / angenehme Fenstergröße je Monitor / weitere
Ideen her." Seine Entscheide in den Rückfragen: **Bibliothek weg, dafür
Verlauf in der Übersicht; Arbeitsordner lokal, Ablage auf dem Server;
Fußleiste ersetzen; Oberfläche UND Kern aufteilen; Poster-Grid im
FirstRun; aus ARM übernommen: Automatik mit Countdown, Protokoll-Datei +
Ordner öffnen, Benachrichtigung aufs Handy, Statistik; Serien-Merker,
Platz-Prüfung, Warteschlange pausieren; Rettungs-Abbild mit Nullen — aber
NUR, wenn Rippy wirklich Fehler sieht.** Nicht gewählt: Zeitfenster,
Oberflächen-Test, Backup-Weg als eigener Modus.
1. **Kern aufgeteilt** (`kern/index.ts` 1244 → Bootstrap + Router):
`kern/kontext.ts` (KernKontext: db, anFenster, hinweis/fehler,
Protokoll), `kern/dienste/einstellungen.ts`, `werkzeuge.ts`,
`laufwerke.ts`, `erkennung.ts`, `vorgaenge.ts`, `speicher.ts`,
`benachrichtigung.ts`, `automatik.ts`.
2. **Oberfläche aufgeteilt** (`fenster/App.tsx` 2686 → Rahmen):
`bausteine.tsx` (Gemeinsames), `dialoge/` (AendernDialog,
TitelWahlDialog), `uebersicht/` (LaufwerkKachel, KompressionsKarte,
SpeicherKarte, VerlaufKarte, Uebersicht), `einstellungen/` (Karten.tsx
je Karte eine Funktion, SprachAuswahl, Einstellungen),
`einrichtung/` (ErstEinrichtung, PosterHintergrund).
3. **Übersicht:** MakeMKV-Kopf weg (→ Einstellungen → MakeMKV, plus LED
SCHLÜSSEL); Speicher-und-Bilanz-Karte (Arbeitsordner/Ablage mit
Füllbalken, Discs/gesichert/abgelegt/je Disc); Verlauf (letzte sechs
Vorgänge, Ordner öffnen, Sprung zu Roh-Dateien); Kompressions-Karte mit
**Pausieren/Weiter**; Fußleiste = sechs LEDs (KERN, DATENBANK,
WERKZEUGE, SCHLÜSSEL, FREI n GB, Version/UPDATE) statt PID und Ping.
4. **Einstellungen:** Ablage (+ Arbeitsordner, Auswurf), Kompression
(Presets je Typ, **SprachAuswahl** als Chips + Dropdown in
Wunschreihenfolge, kein Allgemeines Preset), Roh-Dateien, **Automatik**
(an/aus, Countdown), Metadaten, MakeMKV (Schlüssel-Stand, Neu prüfen,
Aktualisieren, eigener Key), Medienserver, **Benachrichtigung**
(Discord-Webhook, Telegram, Testnachricht), **Allgemein** (Autostart,
Protokoll öffnen, Ablage/Arbeitsordner im Explorer, Kern/DB/Protokoll-
Pfade), **Update** (eigene Karte), Werkzeuge (**Durchsuchen …** mit
exe-Dialog). Die Einstellungs-Schlüssel neu: `arbeitsordner`,
`automatik`, `automatikSekunden`, `benachrichtigungen`,
`discordWebhook`, `telegramToken`, `telegramChatId`; `transcodePreset`
ist aus der erlaubten Liste raus.
5. **Arbeitsordner** (`einstellungen.arbeitsordner()`, Roh unter
`<arbeit>\roh`): `ablegen.ts` encodiert nach `<arbeit>\fertig\<id>` und
zieht danach in die Ablage (`nachzuziehen`), räumt den Zwischenordner.
`speicher.ts` misst mit `fs.statfsSync` (geht hoch bis zum vorhandenen
Elternpfad), Takt jede Minute, LED-Warnung unter 60/20 GB.
6. **Platz-Prüfung** (`gemeinsam/platz.ts`, EINE Rechnung): Dialog zeigt
das Urteil vor dem Klick und sperrt den Knopf bei „hart"; der Kern
urteilt beim Start (`index.ts ripMitPlatz`, auch für die Automatik).
7. **Automatik** (`automatik.ts`): ab 90 % Erkennung, nicht schon
gerippt, Video-Disc → Titel lesen → Countdown (Vorgabe 30 s) → Rip mit
Vorschlag; Kachel mit Sekunden, „Jetzt", „Stopp"; wer selbst klickt
(Rippen, Ändern, Zuordnung), beendet den Countdown. Unsicher = nur der
Grund.
8. **Serien-Merker** (Einstellung `serienMerker` JSON je `tv:<id>`): nach
jedem Serien-Rip merkt sich Rippy Staffel und nächste Folge; die
nächste Disc derselben Serie bekommt „ab Folge n" vorbelegt (nur, wenn
Disc-Nr unbekannt oder > 1).
9. **Rettungs-Abbild** (`kern/rip/rettung.ts`, Pipeline-Phase `rettet`):
nur nach GESEHENEN Lesefehlern (`fehlerGesehen`: Defekt-Wächter,
Lesefehler, Titelfehler) erscheint „Rettungs-Abbild …"; Sektor-Leser
1 MiB → 64 KiB → 2048 B mit je zwei Versuchen, Nullen für den Rest,
Bericht `<roh>\rettung.iso.rettung.txt`; danach derselbe Rip aus
`iso:<pfad>`. Der Kern lehnt `rettung-start` ohne gesehene Fehler ab.
10. **Benachrichtigung** (`benachrichtigung.ts`): Discord-Webhook (POST
JSON `{content, username}`), Telegram (`bot<TOKEN>/sendMessage`,
`{chat_id, text}`); bei Rip fertig, Kompression fertig, Fehler,
Automatik-Start; Fehlschlag = Ereignis-Zeile, nie ein Abbruch.
11. **Protokoll-Datei** (`gemeinsam/protokoll.ts`): Haupt und Kern
schreiben `<userData>\logs\rippy-JJJJ-MM-TT.log` (14 Tage). Der Kern
schreibt beim Start „Datenbank geöffnet: <Pfad>" — die Zeile, die am
01.09. gefehlt hat.
12. **Fensterlage** (`haupt/fensterlage.ts`): 80 % des Arbeitsbereichs,
mittig, mindestens 1100 × 720; gemerkt in `<Profil>\fenster-lage.json`,
nur wiederhergestellt, wenn sie auf einem heutigen Bildschirm liegt.
13. **Erst-Einrichtung:** acht Schritte (Willkommen, Prüfung mit
Durchsuchen für makemkvcon, Ordner mit Ablage + Arbeitsordner + frei,
Qualität mit Encodern, Empfehlungen und Sprach-Chips, Titel, Meldungen,
Automatik/Auswurf/Roh-Regel, Fertig) vor dem **Poster-Grid**
(`PosterHintergrund`: 7 Spalten, CSS `poster-lauf`, Platzhalter bis
zum TMDb-Schlüssel, dann `tmdb.beliebtePoster` über `poster-laden`).
14. **Nachrichten** (`gemeinsam/nachrichten.ts`): neu `speicher`,
`automatik`, `poster-vorschau`; Fenster → Kern `kompression-pause`,
`automatik-stopp`, `automatik-jetzt`, `benachrichtigung-testen`,
`poster-laden`, `rettung-start`, `speicher-laden`; `bibliothek` und
`bibliothek-laden` sind weg; `vorgaenge` trägt `statistik` und
`kompression.pausiert`; `RipStatus` trägt `rettungMoeglich`/`rettung`.
Brücke neu: `dateiWaehlen`, `ordnerOeffnen`, `protokollOeffnen`.
### ⚠ Der Datenbank-Befund — weiter ungeklärt
Unverändert aus der 5.4.0-Übergabe: `%APPDATA%\Rippy\rippy.db` war bis
21:13 am 01.09. Schema 1 vom 30.08. ohne Einstellungen, obwohl 5.3.2 hier
lief. Seit 5.5.0 steht der Datenbank-Pfad unter Einstellungen → Allgemein
UND als erste Zeile in der Protokoll-Datei (`Protokoll öffnen`). **Beim
Commander-Test als Erstes:** Kommt die Erst-Einrichtung wieder? Dann ist
die Roaming-DB die lebende, einmal neu einrichten — die Protokoll-Datei
sagt, welche Datei Rippy wirklich öffnet.
### Was am Gerät noch zu messen ist (nichts davon war in dieser Session möglich)
* **Rettungs-Abbild an Spartacus Disc 2** (echter Defekt): Der Leser
öffnet `\\.\Q:` über `fs/promises.open` — am echten Laufwerk NICHT
gemessen (Unit-Test mit nachgebautem Gerät: 7 Fälle). Zu prüfen: Dauer,
ob Windows das Gerät direkt lesen lässt, ob MakeMKV das `iso:`-Abbild
ohne Hänger rippt. Braucht doppelten Platz im Arbeitsordner.
* **Automatik an einer echten, sicher erkannten Disc** (Klick-Beweis hat
sie aus): Countdown in der Kachel, Stopp/Jetzt, Start mit Vorschlag.
* **Benachrichtigung live**: Discord/Telegram nach Doku gebaut, NICHT
live gemessen — der Testknopf unter Einstellungen → Benachrichtigung ist
dafür da.
* **Poster-Grid mit echtem TMDb-Schlüssel** (`movie/popular`, 2 Seiten):
im Klick-Beweis nur Platzhalter.
* **Ablage auf `\\192.168.178.62\rippy` mit lokalem Arbeitsordner** im
echten Betrieb (statfs auf UNC ist am 01.09. gemessen: 2216 GB frei).
* Weiter offen aus 5.4.0: MakeMKV-Key läuft Ende September ab (Rippy holt
neu); BU40N am USB.
### Messungen dieser Session (Regel D)
* `fs.statfsSync` (Node 24): Laufwerke, Ordner und UNC liefern
bavail/bsize/blocks; nicht vorhandener Pfad wirft ENOENT →
`vorhandenerPfad` geht hoch (siehe Kopf `kern/dienste/speicher.ts`).
* Discord „Execute Webhook": JSON `content` ≤ 2000 Zeichen; Telegram
`sendMessage`: JSON `chat_id`, `text` — beides Doku, nicht live.
* makemkvcon: `iso:<Pfad>` als Quelle (Manpage) — `parser.ts quelle()`
liefert es für `.iso`.
* Playwright: `_electron.launch` ein zweites Mal im selben Skript mit
anderem `env` funktioniert (Erst-Einrichtung im Klick-Beweis).
### Bekannte Schwächen, bewusst in Kauf genommen
* Die Platz-Schätzung rechnet pauschal 25 % für die Kompression — bei
„NICHT komprimieren" nur Roh + 2 GB Luft.
* Der Serien-Merker merkt sich je Serie EINEN Stand (die zuletzt gerippte
Disc) — zwei Staffeln durcheinander gerippt, gewinnt die letzte.
* Die Automatik startet nur mit Rippys Vorschlag (Rollen, Sprachen aus
der Einstellung) — wer je Titel anders will, klickt selbst.
* Das Rettungs-Abbild liest über Node, nicht über MakeMKVs Backup-Modus:
langsamer bei heilen Bereichen, dafür unter Rippys Kontrolle.
* `kern/dienste/vorgaenge.ts` und `erkennung.ts` sind noch groß; ein
weiterer Schnitt lohnt erst, wenn sich dort etwas ändert.
## Übergabe vom 01.09.2026 spät (5.4.0 — gebaut, nie veröffentlicht; überholt durch 5.5.0)
### Wo die nächste Session startet ### Wo die nächste Session startet
@@ -20,7 +735,7 @@
(„Update freigegeben"):** die VIER Dateien im Gitea-Tag `aktuell` („Update freigegeben"):** die VIER Dateien im Gitea-Tag `aktuell`
ERSETZEN (`RippySetup-5.4.0.exe`, `.blockmap`, `latest.yml`; `w0.yml` ERSETZEN (`RippySetup-5.4.0.exe`, `.blockmap`, `latest.yml`; `w0.yml`
bleibt liegen). Dateien liegen in `rippy-windows/dist-setup/` (Kopie) bleibt liegen). Dateien liegen in `rippy-windows/dist-setup/` (Kopie)
und `rippy-windows/dist-setup-540/` (Original). Token: und `rippy-windows/dist-setup-540b/` (Original des zweiten Baus). Token:
`F:\Coding Stuff\Gittea Token\rippy.txt` (40 Hex; über das `F:\Coding Stuff\Gittea Token\rippy.txt` (40 Hex; über das
PowerShell-Werkzeug lesen). Weg wie am 01.09.: LAN-Adresse PowerShell-Werkzeug lesen). Weg wie am 01.09.: LAN-Adresse
`http://192.168.178.153:3000/api/v1/…/releases/1`, `latest.yml` erst `http://192.168.178.153:3000/api/v1/…/releases/1`, `latest.yml` erst
@@ -33,11 +748,13 @@
installiert (22:23), NICHT im Kanal.** Der Commander wollte es ausdrücklich installiert (22:23), NICHT im Kanal.** Der Commander wollte es ausdrücklich
so: erst lokal, dann testen, dann veröffentlichen. so: erst lokal, dann testen, dann veröffentlichen.
* Setup: `RippySetup-5.4.0.exe`, **131 546 293 Bytes**, SHA-256 * Setup: `RippySetup-5.4.0.exe`, **131 546 539 Bytes**, SHA-256
`18A7BE8F6197D0933BF9AC8FA18C463474D99EE5B259DF960EC277CD135346C1`. `FF0DE0B8C0A6A0AEE2926C08E8D6A40F2CC651E6C4B4BBF7B6BF2DE2CC390BEB`
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:10740,node:24.18.1 (zweiter Bau 22:30 in `dist-setup-540b/`, nach dem Fix `2c74dc6`; der
erste Bau 22:23 in `dist-setup-540/` ist überholt).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:25652,node:24.18.1
datenbank=ok leine=gesetzt pong=ok version=5.4.0`. datenbank=ok leine=gesetzt pong=ok version=5.4.0`.
* **351 Tests** in 34 Dateien (vorher 261), Typprüfung und Bau grün. * **353 Tests** in 34 Dateien (vorher 261), Typprüfung und Bau grün.
* Klick-Beweis: 15 Schritte grün, Ausgang 0 — Rippen → Dialog → drei * Klick-Beweis: 15 Schritte grün, Ausgang 0 — Rippen → Dialog → drei
Titel → Sprach-Chips → jpn statt deu → „Hauptinhalt + Extras" → Rollen Titel → Sprach-Chips → jpn statt deu → „Hauptinhalt + Extras" → Rollen
auf Folge → Staffel 1, ab Folge 3 → Vorschau `Serien\…\Season 01` auf Folge → Staffel 1, ab Folge 3 → Vorschau `Serien\…\Season 01`
@@ -46,7 +763,11 @@ so: erst lokal, dann testen, dann veröffentlichen.
`… S01E04.mkv` unter `Season 01` → Roh-Dateien-Karte → Roh löschen → `… S01E04.mkv` unter `Season 01` → Roh-Dateien-Karte → Roh löschen →
Ereignisse. Bilder: `beweise/klick/01…05*.png`. Ereignisse. Bilder: `beweise/klick/01…05*.png`.
* Commits dieser Session: `3f7f962` (Kern), `e3ce822` (Oberfläche), * Commits dieser Session: `3f7f962` (Kern), `e3ce822` (Oberfläche),
`6dd1b0b` (Klick-Beweis), `c46a280` (Version 5.4.0), dazu der SAVEPOINT. `6dd1b0b` (Klick-Beweis), `c46a280` (Version 5.4.0), `2c74dc6` (Neu
komprimieren ersetzt die alte Fassung erst NACH dem Encode), dazu die
SAVEPOINTs. Beim zweiten Installieren (22:30) LIEF Rippy bereits (fünf
Prozesse) — der Commander hatte die 22:23-Fassung offenbar schon
gestartet; der Installer hat sie ersetzt.
### Was 5.4.0 enthält — alle zwölf Punkte der vorigen Übergabe ### Was 5.4.0 enthält — alle zwölf Punkte der vorigen Übergabe
+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.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

@@ -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 )"
+1 -1
View File
@@ -34,7 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` | | `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema | | `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 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 Rippen → Dialog → Sprachen → Folgen → Rip → Warteschlange → Kompression → Ablage → Roh-Dateien. 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). | | `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 Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an. fasst die echten Einstellungen nicht an.
+211 -19
View File
@@ -22,11 +22,16 @@ import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..') const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick') const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage') 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') const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true }) rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true }) mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { 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 // Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte // Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
@@ -44,20 +49,29 @@ mkdirSync(bilder, { recursive: true })
setzen.run('untertitelSprachen', 'deu') setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten') setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false') setzen.run('autoAuswurf', 'false')
setzen.run('arbeitsordner', arbeit)
// Die Automatik bleibt aus: Der Beweis klickt selbst.
setzen.run('automatik', 'aus')
db.close() db.close()
} }
const app = await electron.launch({ const start = (env) =>
electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'), executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'], args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' }, env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
timeout: 60_000, timeout: 60_000,
}) })
const seite = await app.firstWindow() let app = await start({})
seite.setDefaultTimeout(30_000) async function fenster() {
seite.on('console', (m) => { 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)}`) if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
}) })
return s
}
let seite = await fenster()
let nr = 0 let nr = 0
async function bild(name) { async function bild(name) {
@@ -87,6 +101,18 @@ await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
}) })
await bild('uebersicht') 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 schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click() await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor() await seite.getByText('Was soll gerippt werden?').waitFor()
@@ -98,16 +124,26 @@ await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 2', { exact: true }).waitFor() await dialog().getByText('Titel 2', { exact: true }).waitFor()
}) })
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => { await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor() await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
await dialog().getByRole('button', { name: /✓ deu/ }).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 (deu abwählen, jpn anwählen)', async () => { await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ }) await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
await chips.first().click() await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
await dialog().getByRole('button', { name: /^jpn$/ }).first().click() await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor() 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 () => { await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
@@ -117,6 +153,29 @@ await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel
await dialog().getByText(/2 von 3 gewählt/).waitFor() 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 () => { await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select') const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge') await rollen.nth(0).selectOption('folge')
@@ -130,11 +189,24 @@ await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Season 01/).waitFor() await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).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 bild('dialog')
await schritt('Rip starten → Phase RIPPT', async () => { await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click() await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor() 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 schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
@@ -142,9 +214,13 @@ await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor() await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
}) })
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => { 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('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_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 bild('fertig')
@@ -168,6 +244,26 @@ await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', a
console.log(`KLICK dateien: ${gefunden.join(' | ')}`) 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 schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click() await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor() await seite.locator('#roh').waitFor()
@@ -176,17 +272,113 @@ await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', as
}) })
await bild('roh-dateien') await bild('roh-dateien')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => { await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click() 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 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 schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click() await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor() await seite.getByText(/Kompression „/).first().waitFor()
}) })
await bild('ereignisse') await bild('ereignisse')
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.') // ── 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(() => {}) await app.close().catch(() => {})
process.exit(0) process.exit(0)
+20 -7
View File
@@ -4,7 +4,9 @@
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option // --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung // --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited." // --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert // 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 fs = require('node:fs')
const args = process.argv.slice(2) const args = process.argv.slice(2)
@@ -45,16 +47,21 @@ if (args.includes('--scan')) {
out('"MainFeature": 0,') out('"MainFeature": 0,')
out('"TitleList": [') out('"TitleList": [')
out('{') 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('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },') out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }') 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('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },') out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,') out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`) out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [') out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },') out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }') 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(']') out(']')
@@ -74,10 +81,16 @@ out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0 let schritt = 0
const stufen = [5, 30, 55, 80, 100] const stufen = [5, 30, 55, 80, 100]
const tick = () => { const tick = () => {
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`) 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 schritt += 1
if (schritt < stufen.length) { if (schritt < stufen.length) {
setTimeout(tick, 300) setTimeout(tick, 500)
return return
} }
fs.copyFileSync(eingabe, ausgabe) fs.copyFileSync(eingabe, ausgabe)
+21 -12
View File
@@ -31,18 +31,27 @@ if (befehl === 'info') {
out(`TINFO:${t.nr},11,0,"${t.bytes}"`) out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`) out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) { if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`) // 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
out(`SINFO:${t.nr},1,3,0,"jpn"`) // (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
out(`SINFO:${t.nr},1,4,0,"Japanese"`) // dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
out(`SINFO:${t.nr},2,1,6202,"Audio"`) // und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
out(`SINFO:${t.nr},2,3,0,"deu"`) const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
out(`SINFO:${t.nr},2,4,0,"German"`) out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`) if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
out(`SINFO:${t.nr},3,3,0,"deu"`) out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
out(`SINFO:${t.nr},3,4,0,"German"`) out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`) out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
out(`SINFO:${t.nr},4,3,0,"eng"`) if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
out(`SINFO:${t.nr},4,4,0,"English"`) 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) process.exit(0)
+201
View File
@@ -1,3 +1,204 @@
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 Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen. Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rippy", "name": "rippy",
"version": "5.4.0", "version": "5.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rippy", "name": "rippy",
"version": "5.4.0", "version": "5.7.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rippy", "name": "rippy",
"productName": "Rippy", "productName": "Rippy",
"version": "5.4.0", "version": "5.7.1",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js", "main": "out/haupt/index.js",
"author": "KrBrZ", "author": "KrBrZ",
+14
View File
@@ -5,6 +5,8 @@
// Kontextgrenze — contextBridge kann keine Ports übertragen) // Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status') // 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt // 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 // Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert // did-finish-load — ob die React-Seite ihre Horcher da schon registriert
@@ -52,6 +54,18 @@ contextBridge.exposeInMainWorld('rippy', {
ordnerWaehlen(titel: string): Promise<string | null> { ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as 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. */ /** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> { autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean> return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
File diff suppressed because it is too large Load Diff
+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>
)
}
+3
View File
@@ -8,6 +8,9 @@ declare global {
aufStatus(horcher: (status: unknown) => void): void aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null> 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> autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown> updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }> updateInstallieren(): Promise<{ ok: boolean; grund: string }>
+86 -10
View File
@@ -8,8 +8,8 @@
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { import {
istKernNachricht, istKernNachricht,
type AutomatikStand,
type BeschaffungStand, type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand, type DiscInfoStand,
type FensterNachricht, type FensterNachricht,
type GeraeteInfo, type GeraeteInfo,
@@ -19,6 +19,7 @@ import {
type RipAuftragOptionen, type RipAuftragOptionen,
type RipAuftragTitel, type RipAuftragTitel,
type RipStatus, type RipStatus,
type SpeicherStand,
type TitelStand, type TitelStand,
type VorgaengeStand, type VorgaengeStand,
type WerkzeugAuskunft, type WerkzeugAuskunft,
@@ -46,7 +47,6 @@ export interface Verbindung {
ripProtokoll: Record<string, string[]> ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string> einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: { schluessel: {
quelle: string quelle: string
angenommen: boolean | null angenommen: boolean | null
@@ -63,6 +63,12 @@ export interface Verbindung {
vorgaenge: VorgaengeStand | null vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */ /** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht> 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. */ /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[] meldungen: string[]
} }
@@ -71,7 +77,7 @@ type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 30 const MAX_MELDUNGEN = 30
const stand: Verbindung = { export const LEERE_VERBINDUNG: Verbindung = {
verbunden: false, verbunden: false,
kern: null, kern: null,
pongLatenzMs: null, pongLatenzMs: null,
@@ -84,13 +90,17 @@ const stand: Verbindung = {
ripProtokoll: {}, ripProtokoll: {},
einstellungen: {}, einstellungen: {},
werkzeuge: null, werkzeuge: null,
bibliothek: [],
schluessel: null, schluessel: null,
beschaffung: null, beschaffung: null,
vorgaenge: null, vorgaenge: null,
gesundheit: {}, gesundheit: {},
speicher: null,
automatik: {},
poster: [],
meldungen: [], meldungen: [],
} }
const stand: Verbindung = { ...LEERE_VERBINDUNG }
const horcher = new Set<Horcher>() const horcher = new Set<Horcher>()
let gestartet = false let gestartet = false
let port: MessagePort | null = null let port: MessagePort | null = null
@@ -103,8 +113,9 @@ function kopie(): Verbindung {
discInfos: { ...stand.discInfos }, discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege }, vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen }, einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
gesundheit: { ...stand.gesundheit }, gesundheit: { ...stand.gesundheit },
automatik: { ...stand.automatik },
poster: [...stand.poster],
meldungen: [...stand.meldungen], meldungen: [...stand.meldungen],
} }
} }
@@ -120,6 +131,11 @@ function meldung(text: string): void {
function kernNachricht(nachricht: KernNachricht): void { function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) { switch (nachricht.art) {
case 'kern-bereit': 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 stand.kern = nachricht
break break
case 'pong': case 'pong':
@@ -140,6 +156,8 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`) meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') { } else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`) meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
stand.automatik = rest
} else if (ereignis.art === 'laufwerk-weg') { } else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
} }
@@ -184,9 +202,6 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'werkzeuge': case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft stand.werkzeuge = nachricht.auskunft
break break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status': case 'schluessel-status':
stand.schluessel = nachricht.stand stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
@@ -198,6 +213,10 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'vorgaenge': case 'vorgaenge':
stand.vorgaenge = nachricht.stand stand.vorgaenge = nachricht.stand
break 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': case 'kompression-ergebnis':
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`) meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
break break
@@ -207,9 +226,18 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`) meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
} }
break 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': { case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
@@ -247,6 +275,15 @@ function kernNachricht(nachricht: KernNachricht): void {
} }
function portAnnehmen(neuerPort: MessagePort): void { 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 port = neuerPort
stand.verbunden = true stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => { neuerPort.onmessage = (ereignis: MessageEvent) => {
@@ -257,8 +294,8 @@ function portAnnehmen(neuerPort: MessagePort): void {
senden({ art: 'ping', zeit: Date.now() }) senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' }) senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' }) senden({ art: 'werkzeuge-laden' })
senden({ art: 'bibliothek-laden' })
senden({ art: 'vorgaenge-laden' }) senden({ art: 'vorgaenge-laden' })
senden({ art: 'speicher-laden' })
melden() melden()
} }
@@ -310,6 +347,11 @@ export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`) 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). */ /** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void { export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`) anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
@@ -324,6 +366,10 @@ export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) 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 { export function metadatenSuchen(id: string, text: string): void {
senden({ art: 'metadaten-suchen', id, text }) senden({ art: 'metadaten-suchen', id, text })
} }
@@ -340,6 +386,11 @@ export function schluesselPruefen(): void {
senden({ art: 'schluessel-pruefen' }) 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 { export function einstellungSetzen(schluessel: string, wert: string): void {
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`) anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
} }
@@ -372,3 +423,28 @@ export function fremdenOrdnerLoeschen(ordner: string): void {
export function laufwerkPruefen(id: string): void { export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`) 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' })
}
+50 -2
View File
@@ -31,6 +31,9 @@
--color-gruen: oklch(0.78 0.13 150); --color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85); --color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25); --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 { :root {
@@ -73,6 +76,51 @@
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */ /* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } ::-webkit-scrollbar-thumb { background: 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: oklch(1 0 0 / 0.2); } ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
::-webkit-scrollbar-track { background: 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>
)
}
+179 -12
View File
@@ -45,6 +45,8 @@ export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ───────────────────────────────────────────────── // ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase = export type RipPhase =
| 'liest-info' | 'liest-info'
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
| 'rettet'
| 'rippt' | 'rippt'
| 'komprimiert' | 'komprimiert'
| 'legt-ab' | 'legt-ab'
@@ -72,6 +74,12 @@ export interface RipStatus {
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) die Kompression läuft /** Der Vorgang, zu dem dieser Rip gehört (5.4.0) die Kompression läuft
* danach unter dieser Kennung in der Warteschlange weiter. */ * danach unter dieser Kennung in der Warteschlange weiter. */
vorgangId?: string 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: /** Was ein Titel IST davon hängt ab, WOHIN er abgelegt wird:
@@ -90,6 +98,30 @@ export interface SprachEintrag {
spuren: number 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. /** Ein Titel im Rip-Auftrag: welcher, als was und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy * Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR * gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
@@ -105,6 +137,19 @@ export interface RipAuftragTitel {
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */ * Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
audioSprachen?: string[] audioSprachen?: string[]
untertitelSprachen?: 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): /** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
@@ -132,6 +177,8 @@ export interface DiscTitelVorschlag {
grund: string grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */ /** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } 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, /** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
@@ -195,9 +242,13 @@ export interface DiscInfoStand {
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat (Laufzeit /** Was Rippy NACH dem Info-Lauf noch dazugelernt hat (Laufzeit
* bestätigt: 124 min"), Klartext-Zeilen. */ * bestätigt: 124 min"), Klartext-Zeilen. */
hinweise: string[] 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). */ /** Ein Eintrag der Bibliothek (§ 6.6) seit 5.5.0 nur noch die
* Wiedererkennung (schon gerippt am "), keine eigene Ansicht mehr. */
export interface BibliothekEintrag { export interface BibliothekEintrag {
fingerprint: string fingerprint: string
titel: string titel: string
@@ -207,7 +258,7 @@ export interface BibliothekEintrag {
ablageort: string ablageort: string
datum: string datum: string
dauerS: number dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */ /** TMDb-Posterpfad — '' wenn keiner bekannt. */
posterPfad: string posterPfad: string
} }
@@ -238,6 +289,9 @@ export interface BeschaffungStand {
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */ /** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null anteil: number | null
fehler: boolean 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) ───────────────────── // ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
@@ -247,7 +301,7 @@ export interface BeschaffungStand {
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen' export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen Roh-Dateien, /** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen Roh-Dateien,
* Übersicht Kompression). */ * Übersicht Verlauf). */
export interface VorgangEintrag { export interface VorgangEintrag {
id: string id: string
laufwerk: string laufwerk: string
@@ -280,6 +334,9 @@ export interface KompressionStand {
prozent: number prozent: number
text: string text: string
phase: VorgangPhase 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 /** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
@@ -289,6 +346,20 @@ export interface FremderOrdner {
bytes: number 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 { export interface VorgaengeStand {
vorgaenge: VorgangEintrag[] vorgaenge: VorgangEintrag[]
/** Summe aller Roh-Ordner, die noch da sind. */ /** Summe aller Roh-Ordner, die noch da sind. */
@@ -296,7 +367,8 @@ export interface VorgaengeStand {
fremde: FremderOrdner[] fremde: FremderOrdner[]
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */ /** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
regel: string regel: string
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[] } kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[]; pausiert: boolean }
statistik: Statistik
} }
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */ /** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
@@ -313,6 +385,33 @@ export interface LaufwerkBericht {
stunden: number 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. */ /** Nachrichten, die der Kern verschickt. */
export type KernNachricht = export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus } | { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
@@ -329,7 +428,6 @@ export type KernNachricht =
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> } | { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand } | { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt /** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt
* starten kann ihn nur das Haupt (shell/UAC, § 9). */ * starten kann ihn nur das Haupt (shell/UAC, § 9). */
@@ -351,9 +449,18 @@ export type KernNachricht =
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange /** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
* (5.4.0) geht auch ans Haupt (Taskleiste, Updates warten). */ * (5.4.0) geht auch ans Haupt (Taskleiste, Updates warten). */
| { art: 'vorgaenge'; stand: VorgaengeStand } | { 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. */ /** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string } | { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht } | { 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. */ /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = export type FensterNachricht =
@@ -363,6 +470,8 @@ export type FensterNachricht =
* und nicht schon beim Einlegen (der 30.08.-Fund das Erkennen dauert * und nicht schon beim Einlegen (der 30.08.-Fund das Erkennen dauert
* sehr sehr lange"). */ * sehr sehr lange"). */
| { art: 'titel-lesen'; id: string } | { 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. */ /** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen } | { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
| { art: 'rip-abbruch'; id: string } | { art: 'rip-abbruch'; id: string }
@@ -374,11 +483,12 @@ export type FensterNachricht =
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag } | { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' } | { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' } | { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */ /** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' } | { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */ /** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' } | { 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 ── // ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
| { art: 'vorgaenge-laden' } | { art: 'vorgaenge-laden' }
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */ /** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
@@ -394,6 +504,20 @@ export type FensterNachricht =
| { art: 'fremden-ordner-loeschen'; ordner: string } | { art: 'fremden-ordner-loeschen'; ordner: string }
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */ /** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
| { art: 'laufwerk-pruefen'; id: string } | { 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. */ /** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht = export type HauptNachricht =
@@ -413,6 +537,8 @@ export interface HauptStatus {
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster /** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */ * gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean 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 /** Der Stand des Selbst-Updates (§ 6.8) EINE Definition, die sich Haupt
@@ -489,8 +615,6 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
return istObjekt(wert.werte) return istObjekt(wert.werte)
case 'werkzeuge': case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets) return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status': case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string' return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status': case 'beschaffung-status':
@@ -509,8 +633,11 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
istObjekt(wert.stand) && istObjekt(wert.stand) &&
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) && Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' && typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
istObjekt((wert.stand as Record<string, unknown>).kompression) 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': case 'kompression-ergebnis':
return ( return (
istKennung(wert.vorgangId) && istKennung(wert.vorgangId) &&
@@ -526,6 +653,23 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
typeof (wert.bericht as Record<string, unknown>).text === 'string' && typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
Array.isArray((wert.bericht as Record<string, unknown>).letzte) 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: default:
return false return false
} }
@@ -573,9 +717,20 @@ export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (liste === undefined) continue if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false 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 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 { export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number' if (wert.art === 'ping') return typeof wert.zeit === 'number'
@@ -595,12 +750,22 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if ( if (
wert.art === 'auswerfen' || wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' || wert.art === 'titel-lesen' ||
wert.art === 'titel-abbruch' ||
wert.art === 'rip-abbruch' || wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen' || wert.art === 'rip-ueberspringen' ||
wert.art === 'laufwerk-pruefen' wert.art === 'laufwerk-pruefen' ||
wert.art === 'automatik-stopp' ||
wert.art === 'automatik-jetzt' ||
wert.art === 'rettung-start'
) { ) {
return istKennung(wert.id) 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') { if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string' return istKennung(wert.schluessel) && typeof wert.wert === 'string'
} }
@@ -622,11 +787,13 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
return ( return (
wert.art === 'einstellungen-laden' || wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' || wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' || wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' || wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' || wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen' wert.art === 'roh-aufraeumen' ||
wert.art === 'benachrichtigung-testen' ||
wert.art === 'poster-laden' ||
wert.art === 'speicher-laden'
) )
} }
+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,
}
}
@@ -18,6 +18,11 @@
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30' 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]> = [ const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'], ['vce', 'VCN'],
['nvenc', 'NVENC'], ['nvenc', 'NVENC'],
@@ -25,7 +30,19 @@ const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]>
] ]
/** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN /** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */ * 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( export function presetEmpfehlung(
discTyp: string, discTyp: string,
backends: readonly string[], backends: readonly string[],
@@ -36,9 +53,19 @@ export function presetEmpfehlung(
if (!backends.includes(backend)) continue if (!backends.includes(backend)) continue
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht)) const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
if (treffer !== undefined) return treffer if (treffer !== undefined) return treffer
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU. // 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)) const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner if (kleiner !== undefined) return kleiner
} }
return DEFAULT_HB_PRESET 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)
}
}
+43 -11
View File
@@ -1,17 +1,29 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts — // Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke // kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern. // (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5). //
import { BrowserWindow } from 'electron' // 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 { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand' import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(): BrowserWindow { 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({ const fenster = new BrowserWindow({
width: 1280, x: lage.x,
height: 820, y: lage.y,
minWidth: 860, width: lage.width,
minHeight: 560, height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen // Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
@@ -35,17 +47,37 @@ export function fensterErstellen(): BrowserWindow {
sandbox: true, 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)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er // 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. // gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht. // Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle()) const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`) 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 devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? '' const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) { if (devUrl !== undefined && devUrl.length > 0) {
+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.
}
}
+84 -7
View File
@@ -13,6 +13,7 @@ import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path' import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten' import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog' import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { Protokoll } from '../gemeinsam/protokoll'
import { autostartAktiv, autostartSetzen } from './autostart' import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster' import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart' import { KernVerwaltung } from './kernstart'
@@ -47,12 +48,19 @@ if (!app.requestSingleInstanceLock()) {
} }
function start(): void { 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 // Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3). // Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen() const leine: LeineStatus = leineSetzen()
if (!leine.ok) { if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status. // R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`) console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
} }
let fenster: BrowserWindow | null = null let fenster: BrowserWindow | null = null
@@ -80,6 +88,7 @@ function start(): void {
smoke.datenbank = nachricht.datenbank.ok smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) { if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`) console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
} }
statusSenden() statusSenden()
smokePruefen() smokePruefen()
@@ -93,7 +102,10 @@ function start(): void {
updateEinrichten( updateEinrichten(
nachricht.werte['updateUrl'] ?? '', nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0, () => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text), (text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster — // Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten // vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026). // Laden zu sehen (Commander-Fund 01.09.2026).
@@ -109,10 +121,18 @@ function start(): void {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates // 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt. // warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft) 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) { } else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`) 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) { } else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch. // 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( toast(
'Disc erkannt', 'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` + `${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
@@ -122,7 +142,10 @@ function start(): void {
) )
} }
}, },
() => statusSenden(), (zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus, smokeModus,
) )
@@ -130,6 +153,8 @@ function start(): void {
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt. // aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression' const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null 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 { function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent } kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
@@ -142,7 +167,8 @@ function start(): void {
* die Kompression sonst nichts. */ * die Kompression sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void { function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null // 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 (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) { if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100) fenster.setProgressBar(rip.prozent / 100)
@@ -166,7 +192,7 @@ function start(): void {
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen. // Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void { function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') { if (status.phase === 'rippt' || status.phase === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id) aktiveVorgaenge.add(status.id)
} else { } else {
aktiveVorgaenge.delete(status.id) aktiveVorgaenge.delete(status.id)
@@ -222,6 +248,7 @@ function start(): void {
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das // Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist. // Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(), fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
} }
fenster.webContents.send('haupt-status', status) fenster.webContents.send('haupt-status', status)
} }
@@ -250,7 +277,7 @@ function start(): void {
function fensterZeigen(): void { function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) { if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen() fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten() fensterVerdrahten()
return return
} }
@@ -264,6 +291,28 @@ function start(): void {
app.quit() 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 // Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort // Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt. // Fenster ↔ Kern in BEIDE Richtungen trägt.
@@ -327,6 +376,33 @@ function start(): void {
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0] 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', () => { app.on('second-instance', () => {
fensterZeigen() fensterZeigen()
}) })
@@ -339,15 +415,16 @@ function start(): void {
app.on('before-quit', () => { app.on('before-quit', () => {
beendenGewollt = true beendenGewollt = true
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden() kernVerwaltung.beenden()
}) })
void app.whenReady().then(() => { void app.whenReady().then(() => {
kernVerwaltung.starten() kernVerwaltung.starten()
if (!ohneNebenwirkungen) { if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden) trayErstellen(fensterZeigen, beendenMitRueckfrage)
} }
fenster = fensterErstellen() fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten() fensterVerdrahten()
}) })
+11
View File
@@ -21,6 +21,11 @@ export class KernVerwaltung {
private beendenGewollt = false private beendenGewollt = false
private neustarts = 0 private neustarts = 0
private neustartZeiten: number[] = [] 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( constructor(
private readonly datenbankPfad: string, private readonly datenbankPfad: string,
@@ -54,6 +59,11 @@ export class KernVerwaltung {
kern.on('spawn', () => { kern.on('spawn', () => {
this.beiZustand(this.zustand()) 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) => { kern.on('exit', (code) => {
@@ -80,6 +90,7 @@ export class KernVerwaltung {
* ins Fenster (§ 4.1). * ins Fenster (§ 4.1).
*/ */
fensterVerbinden(webContents: WebContents): void { fensterVerbinden(webContents: WebContents): void {
this.letztesFenster = webContents
if (this.kern === null) return if (this.kern === null) return
const { port1, port2 } = new MessageChannelMain() const { port1, port2 } = new MessageChannelMain()
const nachricht: HauptNachricht = { art: 'fenster-port' } const nachricht: HauptNachricht = { art: 'fenster-port' }
@@ -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}`
}
+8
View File
@@ -10,6 +10,10 @@ export interface NfoMeta {
genres?: readonly string[] genres?: readonly string[]
laufzeitMinuten?: number laufzeitMinuten?: number
quelle?: string 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 { function xml(wert: string): string {
@@ -40,6 +44,10 @@ export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | n
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) { if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`) 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(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`) zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n' return zeilen.join('\n') + '\n'
@@ -165,7 +165,7 @@ export function ordnerGroesse(pfad: string): number {
} }
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */ /** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] { export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>, groesse: (pfad: string) => number = ordnerGroesse): FremderOrdner[] {
if (!existsSync(rohWurzel)) return [] if (!existsSync(rohWurzel)) return []
const liste: FremderOrdner[] = [] const liste: FremderOrdner[] = []
let namen: string[] let namen: string[]
@@ -182,7 +182,7 @@ export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>):
for (const name of namen) { for (const name of namen) {
const voll = join(rohWurzel, name) const voll = join(rohWurzel, name)
if (bekannte.has(voll) || bekannte.has(name)) continue if (bekannte.has(voll) || bekannte.has(name)) continue
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) }) liste.push({ ordner: voll, bytes: groesse(voll) })
} }
return liste.sort((a, b) => b.bytes - a.bytes) return liste.sort((a, b) => b.bytes - a.bytes)
} }
+22 -5
View File
@@ -18,7 +18,9 @@ import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
export { sichererName } export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt /** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */ * 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( export function zielordner(
basis: string, basis: string,
titel: string, titel: string,
@@ -29,8 +31,12 @@ export function zielordner(
const name = sichererName(titel, jahr) const name = sichererName(titel, jahr)
if (name.length === 0) return join(basis, jobId) if (name.length === 0) return join(basis, jobId)
const ziel = join(basis, name) const ziel = join(basis, name)
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`) if (!existiert(ziel)) return 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). */ /** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
@@ -84,7 +90,14 @@ export function matcheEpisoden(
} }
/** Benennt MKVs nach Jellyfin-Schema um: Serie S01E02.mkv". Reihenfolge = /** Benennt MKVs nach Jellyfin-Schema um: Serie S01E02.mkv". Reihenfolge =
* sortierte Dateinamen (MakeMKV nummeriert in Disc-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( export function episodenUmbenennen(
ordner: string, ordner: string,
serie: string, serie: string,
@@ -99,9 +112,13 @@ export function episodenUmbenennen(
existiert: existsSync, existiert: existsSync,
umbenennen: renameSync, umbenennen: renameSync,
}, },
nurDiese?: readonly string[],
): string[] { ): string[] {
const meldungen: string[] = [] const meldungen: string[] = []
const dateien = fs const dateien =
nurDiese !== undefined
? [...nurDiese]
: fs
.listen(ordner) .listen(ordner)
.filter((f) => f.endsWith('.mkv')) .filter((f) => f.endsWith('.mkv'))
.sort() .sort()
+187 -46
View File
@@ -17,12 +17,16 @@
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei; // * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung. // TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet. // * 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 { import {
copyFileSync, copyFileSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
renameSync, renameSync,
rmSync,
statSync, statSync,
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
@@ -30,6 +34,7 @@ import {
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path' import { basename, join } from 'node:path'
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten' import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
import { freierName, rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo' import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver' import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster' import { posterSpeichern } from '../ablage/poster'
@@ -41,8 +46,12 @@ import {
folgenNummern, folgenNummern,
folgenPlausibel, folgenPlausibel,
gewichteterFortschritt, gewichteterFortschritt,
sprachlistenAusSpuren,
sprachwahl, sprachwahl,
spurwahl,
type KompressionsAuftrag, type KompressionsAuftrag,
type RohDatei,
type SpurWahl,
} from './auftrag' } from './auftrag'
export interface AblageUmgebung { export interface AblageUmgebung {
@@ -50,6 +59,9 @@ export interface AblageUmgebung {
handbrake: () => string handbrake: () => string
/** Wurzel der Ablage. */ /** Wurzel der Ablage. */
ablage: () => string ablage: () => string
/** 5.5.0: eigener lokaler Arbeitsordner '' wenn keiner (dann wird
* direkt in die Ablage gerechnet, wie bisher). */
arbeitsordner?: () => string
einstellungen: () => Einstellungen einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */ /** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string presetEmpfehlung?: (discTyp: string) => string
@@ -60,7 +72,7 @@ export interface AblageUmgebung {
) => Promise<ReadonlyArray<readonly [number, number]>> ) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date jetzt?: () => Date
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */ /** Datei verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */ /** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null> spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
@@ -75,9 +87,12 @@ export interface AblageErgebnis {
preset: string preset: string
fertigBytes: number fertigBytes: number
hinweise: string[] 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'>) => void export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über /** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */ * Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
@@ -92,6 +107,24 @@ export function dateiVerschieben(von: string, nach: string): void {
} }
} }
/** 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 { function groesseVon(pfad: string): number {
try { try {
return statSync(pfad).size return statSync(pfad).size
@@ -110,7 +143,7 @@ export async function komprimierenUndAblegen(
): Promise<AblageErgebnis> { ): Promise<AblageErgebnis> {
const einstellungen = umgebung.einstellungen() const einstellungen = umgebung.einstellungen()
const handbrake = umgebung.handbrake() const handbrake = umgebung.handbrake()
const soll = komprimierenFuer(auftrag.discTyp, einstellungen) const soll = komprimierenFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const komprimiert = soll && handbrake.length > 0 const komprimiert = soll && handbrake.length > 0
const preset = const preset =
auftrag.preset.length > 0 auftrag.preset.length > 0
@@ -119,6 +152,17 @@ export async function komprimierenUndAblegen(
const zuordnung = auftrag.zuordnung const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise] const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad)) 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) ────────────────────────────────────────────────── // ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter // Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
@@ -135,7 +179,11 @@ export async function komprimierenUndAblegen(
: zuordnung !== null && zuordnung.titel.length > 0 : zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId) ? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId) : zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
try {
mkdirSync(fertigOrdner, { recursive: true }) 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 // Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film. // Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
@@ -143,6 +191,12 @@ export async function komprimierenUndAblegen(
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner) const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true }) 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 = { const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')), audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')), untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
@@ -152,15 +206,41 @@ export async function komprimierenUndAblegen(
const verschieben = umgebung.verschieben ?? dateiVerschieben const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = [] const fertigeDateien: string[] = []
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
const nachzuziehen: Array<[string, string]> = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der // Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten. // Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = [] 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++) { for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i] const datei = dateien[i]
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad)) const [nummer, anzahl] = nummern[i]
if (abbruch.aborted) { const zielDatei = freiOderErsetzt(
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise) 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) { if (!komprimiert) {
melden({ melden({
phase: 'legt-ab', phase: 'legt-ab',
@@ -168,31 +248,47 @@ export async function komprimierenUndAblegen(
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`, text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
}) })
try { try {
verschieben(datei.pfad, ausgabe) verschieben(datei.pfad, zielDatei)
} catch (fehler) { } catch (fehler) {
return { return fehlschlag(
status: 'fehler', `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
zielOrdner: fertigOrdner, '',
dateien: fertigeDateien, false,
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`, )
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise,
} }
} fertigeDateien.push(zielDatei)
fertigeDateien.push(ausgabe)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS) if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue continue
} }
const wahl = sprachwahl(datei, global) 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 // 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 // (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist". // globale Vorgabe war schon immer „was da ist".
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) { if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad) const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null) { 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) const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) { if (fehlt.audio.length > 0) {
hinweise.push( hinweise.push(
@@ -200,21 +296,19 @@ export async function komprimierenUndAblegen(
) )
} }
if (fehlt.untertitel.length > 0) { if (fehlt.untertitel.length > 0) {
hinweise.push( hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
)
} }
} else { } else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`) 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({ melden({
phase: 'komprimiert', phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0), prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${ restS: -1,
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : '' text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)}`,
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} `,
}) })
const encode = await komprimieren({ const encode = await komprimieren({
programm: handbrake, programm: handbrake,
@@ -224,43 +318,83 @@ export async function komprimierenUndAblegen(
audioSprachen: wahl.audio, audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel, untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard, 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, abbruch,
aufFortschritt: (prozent) => { aufFortschritt: (prozent, restS) => {
melden({ melden({
phase: 'komprimiert', phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent), prozent: gewichteterFortschritt(gewichte, i, prozent),
restS,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`, text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
}) })
}, },
}) })
if (encode.status === 'cancelled') { if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (encode.status === 'error') { if (encode.status === 'error') {
return { return fehlschlag(
status: 'fehler', `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
komprimiert: true,
preset, preset,
fertigBytes: 0, true,
hinweise, )
} }
} if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
fertigeDateien.push(encode.ausgabe) fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS) 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) ──────────────────────────────────── // ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit- // Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird // Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch // umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel). // (die offene Wunde #395 von ARM war genau dieses Ratespiel).
let folgenVergeben: number[] = []
if (alsSerie && folgenSekunden.length > 0) { if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten = const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) { if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge) episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
@@ -270,7 +404,8 @@ export async function komprimierenUndAblegen(
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
} }
if (episoden !== null) { if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden)) hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden, undefined, folgenDateien))
folgenVergeben = episoden
} else { } else {
hinweise.push( hinweise.push(
laufzeiten.length === 0 laufzeiten.length === 0
@@ -295,6 +430,7 @@ export async function komprimierenUndAblegen(
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle: quelle:
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', 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, anzeigeTitel,
zuordnung?.jahr ?? null, zuordnung?.jahr ?? null,
@@ -306,7 +442,10 @@ export async function komprimierenUndAblegen(
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`) hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
} }
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) // 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 server = String(einstellungen['mediaServer'] ?? '')
@@ -354,6 +493,7 @@ export async function komprimierenUndAblegen(
preset: komprimiert ? preset : '', preset: komprimiert ? preset : '',
fertigBytes, fertigBytes,
hinweise, hinweise,
folgenVergeben,
} }
} }
@@ -373,6 +513,7 @@ function abgebrochen(
preset, preset,
fertigBytes: 0, fertigBytes: 0,
hinweise, hinweise,
folgenVergeben: [],
} }
} }
+108 -2
View File
@@ -6,7 +6,9 @@
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank // nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder // gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray). // aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { TitelRolle } from '../../gemeinsam/nachrichten' 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' import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei { export interface RohDatei {
@@ -19,6 +21,15 @@ export interface RohDatei {
/** Sprachen je Datei (leer = die globale Einstellung gilt). */ /** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[] audioSprachen: string[]
untertitelSprachen: 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 { export interface KompressionsAuftrag {
@@ -45,6 +56,11 @@ export interface KompressionsAuftrag {
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen. * Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
* '' = Zielordner wird frisch bestimmt. */ * '' = Zielordner wird frisch bestimmt. */
zielOrdner: string 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 { export function auftragZuJson(auftrag: KompressionsAuftrag): string {
@@ -76,6 +92,11 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0, groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [], audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.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 { return {
@@ -94,6 +115,21 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '', begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
preset: typeof a.preset === 'string' ? a.preset : '', preset: typeof a.preset === 'string' ? a.preset : '',
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '', 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 : '',
} }
} }
@@ -117,11 +153,18 @@ export interface SprachWahl {
* komprimieren/handbrake.ts). * komprimieren/handbrake.ts).
*/ */
export function sprachwahl( export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] }, datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
global: { audio: readonly string[]; untertitel: readonly string[] }, global: { audio: readonly string[]; untertitel: readonly string[] },
): SprachWahl { ): SprachWahl {
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio] const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel] 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 wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch) const untertitelStandard = tonFremd && untertitel.includes(wunsch)
@@ -129,6 +172,69 @@ export function sprachwahl(
return { audio, untertitel, untertitelStandard } 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. */ /** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] { export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge)) const start = Math.max(1, Math.trunc(ersteFolge))
+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' },
}
}
+334 -47
View File
@@ -12,11 +12,16 @@
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher // (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und // Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
// nach einem Abbruch nachsehen. // nach einem Abbruch nachsehen.
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs' //
// 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. // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { join } from 'node:path' import { basename, join } from 'node:path'
import type { import type {
BibliothekEintrag, BibliothekEintrag,
DiscTyp,
GeraeteInfo, GeraeteInfo,
RipAuftragOptionen, RipAuftragOptionen,
RipAuftragTitel, RipAuftragTitel,
@@ -29,10 +34,13 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso' import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv' import { rippen } from '../rip/makemkv'
import { rettungsAbbildDatei } from '../rip/rettung'
import { werkzeugSpawn } from '../werkzeuge/aufruf' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs' import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag' import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
import { BLURAY_MIN_BYTES } from '../laufwerk/disc'
interface LaufenderVorgang { interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */ /** Beendet den GANZEN Auftrag. */
@@ -64,18 +72,21 @@ export interface PipelineUmgebung {
flac: () => string flac: () => string
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
audioToc?: (id: string) => Spur[] audioToc?: (id: string) => Spur[]
/** Rohe Audio-Sektoren lesen (W-7). */ /** Rohe Audio-Sektoren lesen (W-7) — seit 5.7.0 asynchron mit Abbruch (F30). */
audioSpurLesen?: ( audioSpurLesen?: (
id: string, id: string,
start: number, start: number,
sektoren: number, sektoren: number,
schreiben: (daten: Buffer) => void, schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void, fortschritt?: (anteil: number) => void,
) => number abbruch?: AbortSignal,
) => Promise<number>
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ /** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
ablage: () => string ablage: () => string
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
rohWurzel?: () => string
einstellungen: () => Einstellungen einstellungen: () => Einstellungen
melden: (status: RipStatus) => void melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
@@ -92,6 +103,9 @@ export interface PipelineUmgebung {
/** 5.4.0: Der Rip ist durch der Kern merkt sich den Vorgang und reiht /** 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). */ * die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number 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. */ /** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */ /** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
@@ -99,10 +113,27 @@ export interface PipelineUmgebung {
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste /** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste
* Disc soll rein, während die Kompression läuft. */ * Disc soll rein, während die Kompression läuft. */
nachRipAuswerfen?: (id: string) => void 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 { export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>() 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) {} constructor(private readonly umgebung: PipelineUmgebung) {}
@@ -110,10 +141,22 @@ export class Pipeline {
return this.laufend.has(id) 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 { private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id) const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
this.umgebung.melden(status) if (eintrag !== undefined) eintrag.status = mitRettung
this.umgebung.melden(mitRettung)
} }
private fehler(id: string, text: string): RipStatus { private fehler(id: string, text: string): RipStatus {
@@ -131,11 +174,66 @@ export class Pipeline {
vorgang.titelAbbruch.abort() vorgang.titelAbbruch.abort()
} }
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ /** 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( async starten(
id: string, id: string,
auftrag: readonly RipAuftragTitel[] = [], auftrag: readonly RipAuftragTitel[] = [],
optionen: RipAuftragOptionen = {}, 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> { ): Promise<void> {
if (this.laufend.has(id)) { if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
@@ -176,6 +274,21 @@ export class Pipeline {
const startZeit = this.umgebung.jetzt?.() ?? new Date() const startZeit = this.umgebung.jetzt?.() ?? new Date()
const vorgangsName = vorgangsNameFuer(geraet, startZeit) 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). // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
if (geraet.typ === 'cd') { if (geraet.typ === 'cd') {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
@@ -183,46 +296,52 @@ export class Pipeline {
await this.audioCdRippen(id, geraet, startZeit, abbruch) await this.audioCdRippen(id, geraet, startZeit, abbruch)
return return
} }
if (geraet.typ === 'unknown') { // 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.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) this.melden(this.laufend.get(id)!.status)
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
return return
} }
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) // Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe
mkdirSync(rohOrdner, { recursive: true }) // 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 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 const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
this.umgebung.vorgangBeginnt?.({ this.umgebung.vorgangBeginnt?.({
id: vorgangsName, id: vorgangsName,
laufwerk: id, laufwerk: id,
typ: geraet.typ, typ: discTyp,
titel: anzeigeTitel, titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null, jahr: zuordnung?.jahr ?? null,
rohOrdner, rohOrdner,
begonnen: startZeit.toISOString(), begonnen: startZeit.toISOString(),
}) })
// 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 alleDateien = new Set<string>() const alleDateien = new Set<string>()
let lesefehlerGesamt = false let lesefehlerGesamt = false
const gescheitert: string[] = [] const hinweise: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
this.laufend.set(id, { this.laufend.set(id, {
abbruch, abbruch,
@@ -232,10 +351,20 @@ export class Pipeline {
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => { const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
this.laufend.delete(id) this.laufend.delete(id)
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0, // Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
// Punkt „Aufräumen nach Abbruch"). // 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) { if (alleDateien.size === 0) {
try { 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 }) rmSync(rohOrdner, { recursive: true, force: true })
}
} catch (fehler) { } catch (fehler) {
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})` text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
} }
@@ -252,6 +381,81 @@ export class Pipeline {
}) })
} }
// ── 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++) { for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i] const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf, // Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
@@ -271,7 +475,7 @@ export class Pipeline {
id, id,
phase: 'rippt', phase: 'rippt',
prozent: gewichteterFortschritt(gewichte, i, -1), prozent: gewichteterFortschritt(gewichte, i, -1),
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}`, text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
dateien: [...alleDateien], dateien: [...alleDateien],
lesefehler: lesefehlerGesamt, lesefehler: lesefehlerGesamt,
titelNr: i + 1, titelNr: i + 1,
@@ -285,7 +489,7 @@ export class Pipeline {
const vorher = dateienIn(rohOrdner) const vorher = dateienIn(rohOrdner)
const rip = await rippen({ const rip = await rippen({
programm, programm,
geraetepfad: geraet.pfad, geraetepfad: quellePfad,
zielOrdner: rohOrdner, zielOrdner: rohOrdner,
titel: welcher, titel: welcher,
abbruch: titelAbbruch.signal, abbruch: titelAbbruch.signal,
@@ -298,6 +502,9 @@ export class Pipeline {
if (alt !== undefined) this.melden({ ...alt, text }) if (alt !== undefined) this.melden({ ...alt, text })
}, },
aufHaengt: (klemmt) => { 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 const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt }) if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
}, },
@@ -305,7 +512,7 @@ export class Pipeline {
abbruch.signal.removeEventListener('abort', weiterreichen) abbruch.signal.removeEventListener('abort', weiterreichen)
if (rip.status === 'cancelled') { if (rip.status === 'cancelled') {
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
// Nur dieser Titel — oder der ganze Auftrag? // Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) { if (this.laufend.get(id)?.uebersprungen !== true) {
beenden( beenden(
@@ -321,18 +528,20 @@ export class Pipeline {
} }
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
if (rip.status === 'error') { if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind // EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt. // vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig. // Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) 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}`) gescheitert.push(`${beschriftung}: ${rip.fehler}`)
continue continue
} }
// rippen() liefert den ganzen Zielordner zurück, also auch die // rippen() liefert den ganzen Zielordner zurück, also auch die
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war. // Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d)) const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
for (const d of rip.dateien) alleDateien.add(d) for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
for (const d of neu) auftragJeDatei.set(d, auftrag[i]) for (const d of neu) auftragJeDatei.set(d, auftrag[i])
if (neu.length === 0) { if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
@@ -349,7 +558,6 @@ export class Pipeline {
return return
} }
const hinweise: string[] = []
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`) if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`) if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`) if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
@@ -366,16 +574,81 @@ export class Pipeline {
groesseBytes: groesseVon(pfad), groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [], audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [], 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 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 = { const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName, vorgangId: vorgangsName,
laufwerk: id, laufwerk: id,
discTyp: geraet.typ, discTyp,
rohOrdner, rohOrdner,
dateien, dateien: hauptDateien,
zuordnung, zuordnung: hauptZuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null, staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null, ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null, discNr: angaben?.discNr ?? null,
@@ -386,7 +659,12 @@ export class Pipeline {
preset: '', preset: '',
zielOrdner: '', zielOrdner: '',
} }
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0 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) const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter. // Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
@@ -397,10 +675,11 @@ export class Pipeline {
phase: 'fertig', phase: 'fertig',
prozent: 100, prozent: 100,
text: text:
`Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` + `Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
(platz > 0 (platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.` ? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') + : '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 (lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') + : '') +
@@ -474,10 +753,17 @@ export class Pipeline {
const wav = join(arbeit, 'spur.wav') const wav = join(arbeit, 'spur.wav')
const strom = createWriteStream(wav) const strom = createWriteStream(wav)
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { await this.umgebung.audioSpurLesen(
id,
spur.start,
spur.sektoren,
(daten) => strom.write(daten),
(anteil) => {
const s = this.laufend.get(id)?.status const s = this.laufend.get(id)?.status
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
}) },
abbruch.signal,
)
await new Promise<void>((fertig, kaputt) => { await new Promise<void>((fertig, kaputt) => {
strom.end(() => fertig()) strom.end(() => fertig())
strom.on('error', kaputt) strom.on('error', kaputt)
@@ -587,11 +873,12 @@ function dateienIn(ordner: string): Set<string> {
} }
/** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines /** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines
* abgebrochenen oder gescheiterten Titels. Liefert die Namen. */ * abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>): string[] { * (das Rettungs-Abbild). Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
const entfernt: string[] = [] const entfernt: string[] = []
for (const name of dateienIn(ordner)) { for (const name of dateienIn(ordner)) {
if (vorher.has(name)) continue if (vorher.has(name) || schonen.includes(name)) continue
try { try {
unlinkSync(join(ordner, name)) unlinkSync(join(ordner, name))
entfernt.push(name) entfernt.push(name)
+19 -2
View File
@@ -6,6 +6,9 @@
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und // EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
// werden zusammen nicht schneller. // werden zusammen nicht schneller.
// //
// 5.5.0: Pause (Commander: „zum Beispiel beim Spielen") — der laufende
// Film wird fertig gerechnet, der nächste wartet, bis es weitergeht.
//
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel // PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
// prüft das Verhalten der Schlange auf Linux ohne HandBrake. // prüft das Verhalten der Schlange auf Linux ohne HandBrake.
import type { KompressionStand } from '../../gemeinsam/nachrichten' import type { KompressionStand } from '../../gemeinsam/nachrichten'
@@ -34,6 +37,7 @@ export class KompressionsWarteschlange {
private laufend: Eintrag | null = null private laufend: Eintrag | null = null
private laufendStand: KompressionStand | null = null private laufendStand: KompressionStand | null = null
private arbeitet = false private arbeitet = false
private pausiert = false
constructor( constructor(
private readonly ausfuehren: Ausfuehren, private readonly ausfuehren: Ausfuehren,
@@ -71,6 +75,7 @@ export class KompressionsWarteschlange {
preset: '', preset: '',
fertigBytes: 0, fertigBytes: 0,
hinweise: [], hinweise: [],
folgenVergeben: [],
}) })
return true return true
} }
@@ -81,6 +86,17 @@ export class KompressionsWarteschlange {
return false return false
} }
/** Pause: Der laufende Auftrag wird fertig, der nächste startet erst nach
* `pausieren(false)`. */
pausieren(an: boolean): void {
this.pausiert = an
if (!an) void this.schleife()
}
istPausiert(): boolean {
return this.pausiert
}
stand(): WarteschlangenStand { stand(): WarteschlangenStand {
return { return {
laeuft: this.laufendStand === null ? null : { ...this.laufendStand }, laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
@@ -88,7 +104,7 @@ export class KompressionsWarteschlange {
vorgangId: e.auftrag.vorgangId, vorgangId: e.auftrag.vorgangId,
titel: e.titel, titel: e.titel,
prozent: -1, prozent: -1,
text: 'wartet', text: this.pausiert ? 'wartet (Pause)' : 'wartet',
phase: 'wartet', phase: 'wartet',
})), })),
} }
@@ -107,7 +123,7 @@ export class KompressionsWarteschlange {
if (this.arbeitet) return if (this.arbeitet) return
this.arbeitet = true this.arbeitet = true
try { try {
while (this.wartend.length > 0) { while (this.wartend.length > 0 && !this.pausiert) {
const eintrag = this.wartend.shift()! const eintrag = this.wartend.shift()!
this.laufend = eintrag this.laufend = eintrag
this.laufendStand = { this.laufendStand = {
@@ -141,6 +157,7 @@ export class KompressionsWarteschlange {
preset: '', preset: '',
fertigBytes: 0, fertigBytes: 0,
hinweise: [], hinweise: [],
folgenVergeben: [],
} }
} }
this.laufend = null this.laufend = null
+194
View File
@@ -0,0 +1,194 @@
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
//
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
// ist, passiert NICHTS von selbst — es sagt nur, warum.
//
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
// prüft den Ablauf mit einer Fake-Uhr.
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { ABGELOEST } from '../rip/makemkv'
import type { Zuordnung } from '../metadaten/zuordnung'
export const AUTOMATIK_SICHER = 0.9
export interface AutomatikAbhaengigkeiten {
an(): boolean
sekunden(): number
ripLaeuft(id: string): boolean
titelLesen(id: string): Promise<TitelStand>
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
benachrichtigen(titel: string, text: string): void
/** Uhr — injizierbar. */
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
taktStoppen?: (zeiger: NodeJS.Timeout) => void
}
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
return stand.titel
.filter((t) => t.gewaehlt)
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
}
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
* Merker; ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
export function optionenAus(
angaben: { staffel: number | null; discNr: number | null } | null,
merker: { staffel: number; naechsteFolge: number } | null,
): RipAuftragOptionen {
const staffel = angaben?.staffel ?? merker?.staffel ?? null
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
return { staffel, ersteFolge }
}
export class AutomatikDienst {
private zeiger = new Map<string, NodeJS.Timeout>()
private staende = new Map<string, AutomatikStand>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: AutomatikAbhaengigkeiten,
) {}
stand(id: string): AutomatikStand | null {
return this.staende.get(id) ?? null
}
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
const stand = { id, aktiv, sekunden, text }
this.staende.set(id, stand)
this.kontext.anFenster({ art: 'automatik', stand })
}
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
const id = geraet.id
if (!this.abh.an()) return
// 5.7.0 (F28): nicht stumm — der Kasten sagt, warum nichts passiert.
if (geraet.typ === 'cd') {
this.melden(id, false, 0, 'Automatik: Audio-CDs startet Rippy nicht von selbst — „Rippen" liest sie als FLAC.')
return
}
if (geraet.typ === 'unknown') {
this.melden(id, false, 0, 'Automatik wartet: Disc-Typ unbekannt — „Rippen" liest die Titel, dann entscheidest du.')
return
}
if (this.abh.ripLaeuft(id)) return
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
return
}
if (schonGerippt !== null) {
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
return
}
this.melden(id, false, 0, 'Automatik: liest die Titel …')
const titel = await this.abh.titelLesen(id)
if (titel.fehler === ABGELOEST) return
if (this.abh.ripLaeuft(id)) return
if (titel.fehler.length > 0) {
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
return
}
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
return
}
this.countdown(id, titel)
}
private countdown(id: string, titel: TitelStand): void {
this.stopp(id, '')
let rest = this.abh.sekunden()
const n = titel.titel.filter((t) => t.gewaehlt).length
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
this.melden(id, true, rest, text(rest))
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
const zeiger = takt(() => {
rest -= 1
if (rest > 0) {
this.melden(id, true, rest, text(rest))
return
}
this.stopp(id, '')
this.starten(id, titel)
}, 1000)
this.zeiger.set(id, zeiger)
}
private starten(id: string, titel: TitelStand): void {
if (this.abh.ripLaeuft(id)) return
const auftrag = auftragAusVorschlag(titel)
if (auftrag.length === 0) return
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
this.abh.ripStarten(id, auftrag, optionen)
}
private merkerZuordnung = new Map<string, Zuordnung>()
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
if (zuordnung === null) this.merkerZuordnung.delete(id)
else this.merkerZuordnung.set(id, zuordnung)
}
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
const z = this.merkerZuordnung.get(id)
return z === undefined ? null : this.abh.serienMerker(z)
}
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
stopp(id: string, grund: string): void {
const zeiger = this.zeiger.get(id)
if (zeiger !== undefined) {
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
this.zeiger.delete(id)
}
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** Der Kasten in der Kachel verschwindet ein Rip hat begonnen (5.6.0):
* Countdown weg, Text weg. Ohne das stand Automatik gestoppt du
* entscheidest." noch unter dem fertigen Rip. */
leeren(id: string): void {
this.stopp(id, '')
if (this.staende.has(id)) this.melden(id, false, 0, '')
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
if (stand === undefined || !stand.aktiv) return
this.stopp(id, '')
const titel = this.letzteTitel.get(id)
if (titel !== undefined) this.starten(id, titel)
}
private letzteTitel = new Map<string, TitelStand>()
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
titelMerken(id: string, stand: TitelStand): void {
this.letzteTitel.set(id, stand)
}
discEntnommen(id: string): void {
this.stopp(id, '')
this.staende.delete(id)
this.letzteTitel.delete(id)
this.merkerZuordnung.delete(id)
}
}
@@ -0,0 +1,94 @@
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
// discord.com/developers/docs/resources/webhook („Execute Webhook":
// content ist eine Zeichenkette bis 2000 Zeichen).
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
// der ihn ausgelöst hat.
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export interface Kanaele {
discordWebhook: string
telegramToken: string
telegramChatId: string
}
/** Was an Discord geht (pur, testbar). */
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
}
/** Was an Telegram geht (pur, testbar). */
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
return {
url: `https://api.telegram.org/bot${token}/sendMessage`,
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
}
}
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
return {
discordWebhook: lesen('discordWebhook').trim(),
telegramToken: lesen('telegramToken').trim(),
telegramChatId: lesen('telegramChatId').trim(),
}
}
export function kanaeleEingerichtet(k: Kanaele): boolean {
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
}
export class BenachrichtigungsDienst {
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly holen: typeof fetch = fetch,
) {}
eingeschaltet(): boolean {
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
}
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
if (!erzwingen && !this.eingeschaltet()) return []
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
const fehler: string[] = []
const anfragen: Array<{ name: string; url: string; body: string }> = []
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
}
for (const a of anfragen) {
try {
const antwort = await this.holen(a.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: a.body,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
} catch (e) {
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
}
}
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
return fehler
}
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
async testen(): Promise<void> {
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
if (!kanaeleEingerichtet(k)) {
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
return
}
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
}
}
@@ -0,0 +1,150 @@
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
// also wie bisher).
import { join } from 'node:path'
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
import { einstellung, type KernKontext } from '../kontext'
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'ablage',
'arbeitsordner',
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'makemkvAppKey',
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
// 5.5.0
'automatik',
'automatikSekunden',
'benachrichtigungen',
'discordWebhook',
'telegramToken',
'telegramChatId',
'design',
// 5.7.0: wie lange makemkvcon die Titel lesen darf (Kandidat 2 der Durchsicht)
'titelLesenMinuten',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
* frühere Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
* verwirrt nur. */
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
/** 5.7.0: Vorgabe 15 min (Commander-Entscheid 12.09.2026) kopiergeschützte
* DVDs und Discs mit vielen Playlisten brauchen im MakeMKV-GUI regelmäßig
* länger als die alten fünf Minuten. */
export const TITEL_LESEN_MINUTEN_VORGABE = 15
export class EinstellungsDienst {
constructor(private readonly kontext: KernKontext) {}
lesen(schluessel: string, vorgabe = ''): string {
return einstellung(this.kontext, schluessel, vorgabe)
}
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
ablageOrdner(): string {
const eingestellt = this.lesen('ablage')
if (eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
arbeitsordnerEigen(): string {
return this.lesen('arbeitsordner').trim()
}
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
arbeitsordner(): string {
const eigen = this.arbeitsordnerEigen()
return eigen.length > 0 ? eigen : this.ablageOrdner()
}
/** Wo die Roh-Ordner liegen. */
rohWurzel(): string {
return join(this.arbeitsordner(), 'roh')
}
/** Was Pipeline und Ablage brauchen. */
fuerPipeline(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
automatikAn(): boolean {
return this.lesen('automatik', 'an') !== 'aus'
}
/** Grenze fürs Titel-Lesen in Minuten (560, Vorgabe 15). */
titelLesenMinuten(): number {
const n = Number(this.lesen('titelLesenMinuten', String(TITEL_LESEN_MINUTEN_VORGABE)))
return Number.isInteger(n) && n >= 5 && n <= 60 ? n : TITEL_LESEN_MINUTEN_VORGABE
}
automatikSekunden(): number {
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
}
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
senden(): void {
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
}
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
setzen(schluessel: string, wert: string): boolean {
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
return false
}
if (this.kontext.db === null) {
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
return false
}
this.kontext.db.einstellungSchreiben(schluessel, wert)
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
this.senden()
return true
}
}
function istGeheim(schluessel: string): boolean {
return /key|token|webhook|passwort/i.test(schluessel)
}
+486
View File
@@ -0,0 +1,486 @@
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
// Hintergrund der Erst-Einrichtung.
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { einstellung, type KernKontext } from '../kontext'
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen, type Lebenszeichen } from '../rip/makemkv'
import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
ripLaeuft(id: string): boolean
geraetInfoVon(id: string): GeraeteInfo | null
makemkvcon(): string
/** 5.7.0: wie lange makemkvcon die Titel lesen darf (Minuten). Fehlt: 15. */
titelLesenMinuten?(): number
}
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
export interface SerienMerker {
titel: string
staffel: number
naechsteFolge: number
wann: string
}
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
if (text === null || text.length === 0) return {}
try {
const roh: unknown = JSON.parse(text)
if (typeof roh !== 'object' || roh === null) return {}
const ergebnis: Record<string, SerienMerker> = {}
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
if (typeof wert !== 'object' || wert === null) continue
const m = wert as Record<string, unknown>
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
ergebnis[id] = {
titel: typeof m.titel === 'string' ? m.titel : '',
staffel: m.staffel,
naechsteFolge: m.naechsteFolge,
wann: typeof m.wann === 'string' ? m.wann : '',
}
}
return ergebnis
} catch {
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
return {}
}
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
export class ErkennungDienst {
private zuordnungen = new Map<string, Zuordnung>()
private fingerprints = new Map<string, string>()
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl)
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
private merkmale = new Map<string, DiscTitel>()
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
readonly letzteTitel = new Map<string, TitelStand>()
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
private titelAbbrueche = new Map<string, AbortController>()
private titelLaufZaehler = 0
private titelLaufNummern = new Map<string, number>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: ErkennungAbhaengigkeiten,
) {}
private tmdb(): TmdbClient {
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
}
private omdb(): OmdbClient {
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
}
zuordnung(id: string): Zuordnung | null {
return this.zuordnungen.get(id) ?? null
}
fingerprint(id: string): string | null {
return this.fingerprints.get(id) ?? null
}
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
const m = this.merkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
}
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
private alleMerker(): Record<string, SerienMerker> {
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
}
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
if (tmdbId === null) return null
return this.alleMerker()[String(tmdbId)] ?? null
}
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
if (this.kontext.db === null) return
const alle = this.alleMerker()
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
}
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) vorher stand dieselbe Struktur dreimal im Code. */
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = this.merkmale.get(id)
const meta = zuordnung?.meta
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt:
zuordnung === null ? null : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
}
}
schonGerippt(id: string): string | null {
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
}
/** § 5 Schritt 3: Disc rein was ist das? Liefert die Zuordnung (für die
* Automatik) null, wenn die Erkennung scheiterte. */
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
try {
const gefunden = discTitel(geraet.pfad)
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
this.merkmale.set(geraet.id, gefunden)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, this.tmdb(), this.omdb(), {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
this.zuordnungen.set(geraet.id, zuordnung)
this.kontext.protokoll.schreiben(
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
return zuordnung
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
return null
}
}
discVergessen(id: string): void {
this.zuordnungen.delete(id)
this.fingerprints.delete(id)
this.merkmale.delete(id)
this.letzteTitel.delete(id)
this.titelAbbrueche.get(id)?.abort()
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
async metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) für
* Ändern " und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
const tmdb = this.tmdb()
let details = null
try {
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
} catch (fehler) {
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
}
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
return {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const zuordnung = await this.zuordnungAus(vorschlag)
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
}
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
async titelLesen(id: string): Promise<TitelStand> {
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
const senden = (stand: TitelStand): TitelStand => {
if (!stand.laeuft) this.letzteTitel.set(id, stand)
this.kontext.anFenster({ art: 'disc-titel', stand })
return stand
}
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
const geraet = this.abh.geraetInfoVon(id)
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
const programm = this.abh.makemkvcon()
if (programm.length === 0) {
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
}
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
this.titelAbbrueche.get(id)?.abort()
const abbruch = new AbortController()
this.titelAbbrueche.set(id, abbruch)
const meineNummer = ++this.titelLaufZaehler
this.titelLaufNummern.set(id, meineNummer)
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
senden({ ...leer, laeuft: true })
const start = Date.now()
// 5.7.0 (Kandidat 2): Die Grenze kommt aus den Einstellungen (Vorgabe
// 15 min statt fest fünf), und ab der zweiten Minute sagt der Dialog,
// dass es länger dauert — mit Abbrechen-Knopf, statt stumm zu drehen.
// 5.7.1 (Der Patriot): Die Grenze gilt für STILLE — solange MakeMKV
// Fortschritt oder Meldungen schickt, wartet Rippy, und der Dialog zeigt,
// was MakeMKV gerade tut.
const minuten = this.abh.titelLesenMinuten?.() ?? 15
let lebens: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
let letztesLebenszeichen = start
let letzteSendung = start
const zwischenstandSenden = (): void => {
if (!nochAktuell()) return
letzteSendung = Date.now()
senden({ ...leer, laeuft: true, einordnung: titelLaufZwischenstand(Date.now() - start, Date.now() - letztesLebenszeichen, minuten, lebens) })
}
const zwischenstand = setInterval(zwischenstandSenden, 60_000)
zwischenstand.unref()
try {
const info = await titelInfoLesen(programm, geraet.pfad, {
stillMs: minuten * 60_000,
abbruch: abbruch.signal,
aufLebenszeichen: (stand) => {
const neueMeldung = stand.meldung !== lebens.meldung || stand.schritt !== lebens.schritt
lebens = stand
letztesLebenszeichen = Date.now()
// Neue Meldung oder neuer Schritt sofort, Prozente höchstens alle 5 s.
if (Date.now() - start >= 30_000 && (neueMeldung || Date.now() - letzteSendung >= 5_000)) zwischenstandSenden()
},
})
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
const merkmale = this.merkmale.get(id)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0,
})
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
this.kontext.protokoll.schreiben(
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
)
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
// 5.7.0: und jede Spur einzeln.
spuren: spurenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
laeuft: false,
titel,
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await this.nachpruefen(id, titel, info.titel)
return stand
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
const grund = String(fehler).includes('kein Lebenszeichen seit')
? `MakeMKV hat ${minuten} Minuten lang kein Lebenszeichen gegeben (kein Fortschritt, keine Meldung) — Rippy hat den Versuch nach ${Math.round((Date.now() - start) / 60_000)} min beendet und das Laufwerk freigegeben.${
lebens.meldung.length > 0 ? ` Letzte Meldung: „${lebens.meldung}".` : ''
} Das kann an einer beschädigten Disc oder am Laufwerk liegen. Die Geduld steht unter Einstellungen Allgemein; zur Probe die Disc im MakeMKV-Programm öffnen.`
: String(fehler).includes('VOB-Scan nach')
? `MakeMKV liest die beschädigte DVD-Struktur seit ${Math.round((Date.now() - start) / 60_000)} min und ist nicht fertig geworden — Rippy hat den Versuch beendet und das Laufwerk freigegeben. Zur Probe die Disc im MakeMKV-Programm öffnen.`
: `Titel lesen: ${String(fehler).slice(0, 200)}`
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund} [${String(fehler).slice(0, 900)}]`)
return senden({ ...leer, fehler: grund })
} finally {
clearInterval(zwischenstand)
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
if (nochAktuell()) {
this.titelAbbrueche.delete(id)
this.titelLaufNummern.delete(id)
}
}
}
/** Der Nutzer bricht das Titel-Lesen ab (5.7.0): Der Lauf endet sofort,
* makemkvcon gibt das Laufwerk frei, der Dialog zeigt abgebrochen". */
titelAbbrechen(id: string): void {
const abbruch = this.titelAbbrueche.get(id)
if (abbruch === undefined) return
abbruch.abort()
this.titelAbbrueche.delete(id)
this.titelLaufNummern.delete(id)
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} vom Nutzer abgebrochen`)
this.kontext.anFenster({ art: 'disc-titel', stand: { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: 'Abgebrochen — „Rippen" liest die Titel neu.' } })
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus wahrscheinlich" wird
* bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
const bisher = this.zuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = vorschlag.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = this.merkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.omdb(), {
istSerie: true,
alternativen: merkmale.alternativen,
})
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
this.zuordnungen.set(id, neu)
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')}${Math.round(neu.confidence * 100)} %`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
}
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
async posterLaden(): Promise<void> {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
return
}
const pfade = await tmdb.beliebtePoster(40)
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
}
}
/** Was der Dialog während des Titel-Lesens zeigt (pur, 5.7.1): wie lange
* MakeMKV schon liest, was es gerade tut, wann Rippy aufgeben würde. */
export function titelLaufZwischenstand(vergangenMs: number, stillMs: number, geduldMinuten: number, lebens: Lebenszeichen): string {
const min = Math.max(1, Math.round(vergangenMs / 60_000))
const teile: string[] = [`MakeMKV liest seit ${min} min`]
if (lebens.schritt.length > 0 || lebens.prozent >= 0) {
teile.push(`${lebens.schritt.length > 0 ? lebens.schritt : 'Fortschritt'}${lebens.prozent >= 0 ? ` ${lebens.prozent} %` : ''}`)
}
if (lebens.meldung.length > 0) teile.push(`letzte Meldung: „${lebens.meldung.trim().slice(0, 160)}"`)
let text = teile.join(' · ') + '.'
if (lebens.vobScan) {
text += ` Die DVD-Struktur ist beschädigt — MakeMKV liest die ganze VOB-Datei durch; das dauert so lange wie das Lesen der Disc (oft 2060 min), im MakeMKV-Programm genauso. Rippy wartet, solange makemkvcon läuft.`
} else {
const stillMin = Math.floor(stillMs / 60_000)
text += ` Solange MakeMKV Fortschritt meldet, wartet Rippy; ohne Lebenszeichen bricht es nach ${geduldMinuten} min ab (Einstellungen → Allgemein)${stillMin >= 1 ? ` — still seit ${stillMin} min` : ''}.`
}
return text + ' „Abbrechen" beendet den Lauf.'
}
+240
View File
@@ -0,0 +1,240 @@
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
// Klick-Beweises.
//
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { LaufwerkApi } from '../laufwerk/codes'
import {
audioSpurLesen,
audioToc,
auswerfenMitGrund,
geraeteInfo,
kennung,
listOptischeLaufwerke,
} from '../laufwerk/disc'
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
import { typAusDateisystem } from '../metadaten/discmerkmale'
import { DiscWache } from '../laufwerk/wache'
import type { Spur } from '../rip/audiocd'
const GESUNDHEIT_STUNDEN = 24
export interface LaufwerkHorcher {
discEingelegt(geraet: GeraeteInfo): void
discEntnommen(geraet: GeraeteInfo): void
}
export class LaufwerkDienst {
private api: LaufwerkApi | null = null
private wache: DiscWache | null = null
fehler: string | null = null
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
* Buchstabe, den es nicht gibt (Q) die Disc-Merkmale finden dann
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
readonly fake: string
readonly bereit: Promise<void>
/** 5.7.0 (F10): Der Typ aus dem Dateisystem, je Laufwerk und Disc-Größe
* gemerkt die Wache fragt alle 3 s, die Disc ändert sich nicht. */
private typGemerkt = new Map<string, { groesse: number; typ: GeraeteInfo['typ'] }>()
constructor(
private readonly kontext: KernKontext,
private readonly horcher: LaufwerkHorcher,
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
private readonly ripLaeuft: (id: string) => boolean,
umgebung: NodeJS.ProcessEnv = process.env,
) {
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
this.bereit = this.starten()
}
private fakeGeraet(): GeraeteInfo {
return {
id: this.fake,
name: `Laufwerk ${this.fake}:`,
typ: 'bluray',
pfad: `\\\\.\\${this.fake}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
this.horcher.discEingelegt(ereignis.geraet)
} else if (ereignis.art === 'disc-entnommen') {
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
this.horcher.discEntnommen(ereignis.geraet)
} else if (ereignis.art === 'laufwerk-weg') {
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
}
}
}
private async starten(): Promise<void> {
const wacheFehler = (fehler: unknown): void => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
}
try {
if (this.fake.length > 0) {
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
this.wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('../laufwerk/win32')
this.api = win32Api()
this.wache = new DiscWache(
() => listOptischeLaufwerke(this.api!).map((g) => this.typVerfeinern(geraeteInfo(this.api!, g))),
(m) => this.wacheMeldung(m),
wacheFehler,
)
this.wache.starten()
} catch (fehler) {
this.fehler = String(fehler)
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
console.error(`[kern] Laufwerke: ${this.fehler}`)
}
}
aktuellerStand(): GeraeteInfo[] {
return this.wache?.aktuellerStand() ?? []
}
/** Was auf der Disc liegt, schlägt Steuercode und Größe (F10): BDMV mit
* INDX0300 ist UHD, sonst Blu-ray; VIDEO_TS ist DVD. Nur bei ready",
* und nur einmal je Disc (gemerkt an der Größe). */
private typVerfeinern(geraet: GeraeteInfo): GeraeteInfo {
if (geraet.status !== 'ready' || geraet.typ === 'cd') {
this.typGemerkt.delete(geraet.id)
return geraet
}
const alt = this.typGemerkt.get(geraet.id)
if (alt !== undefined && alt.groesse === geraet.groesseBytes) return alt.typ === geraet.typ ? geraet : { ...geraet, typ: alt.typ }
let typ = geraet.typ
try {
const gesehen = typAusDateisystem(geraet.pfad)
if (gesehen !== null && gesehen !== geraet.typ) {
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Typ laut Dateisystem ${gesehen} (Steuercode/Größe sagten ${geraet.typ})`)
typ = gesehen
}
} catch (fehler) {
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Dateisystem nicht lesbar (${String(fehler)})`)
}
this.typGemerkt.set(geraet.id, { groesse: geraet.groesseBytes, typ })
return typ === geraet.typ ? geraet : { ...geraet, typ }
}
/** Der Geräte-Name zu einer Kennung — oder null. */
geraetVon(id: string): string | null {
if (this.api === null) return null
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
}
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
geraetInfoVon(id: string): GeraeteInfo | null {
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
const geraet = this.geraetVon(id)
if (geraet === null || this.api === null) return null
return this.typVerfeinern(geraeteInfo(this.api, geraet))
}
audioToc(id: string): Spur[] {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(this.api, geraet)
}
audioSpurLesen(
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
abbruch?: AbortSignal,
): Promise<number> {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) return Promise.reject(new Error(`Laufwerk ${id}: gibt es nicht`))
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt, abbruch)
}
async auswerfen(id: string): Promise<void> {
await this.bereit
if (this.ripLaeuft(id)) {
this.kontext.anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (this.api === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
return
}
const geraet = this.geraetVon(id)
if (geraet === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
this.wache?.runde()
}
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
async pruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
this.kontext.anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
// 5.7.0 (F11): nur die Ereignisse DIESES Geräts — \Device\CdRomN
// hinter dem Buchstaben, nicht die einer Festplatte oder eines zweiten
// Laufwerks. Ohne Gerätename (Nachbau, Fehlschlag) zählt nur cdrom.
let geraet = ''
try {
geraet = this.api?.geraeteName(id) ?? ''
} catch (fehler) {
this.kontext.protokoll.schreiben(`Laufwerk ${id}: Gerätename nicht ermittelbar (${String(fehler)})`)
}
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN, undefined, geraet)
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil}${bericht.text}`)
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
}
}
@@ -0,0 +1,87 @@
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
// Laufwerk gibt es trotzdem.
import { existsSync, statfsSync } from 'node:fs'
import { dirname } from 'node:path'
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
const echtMessen: Messen = (pfad) => {
const s = statfsSync(pfad)
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
}
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
let p = pfad
for (let i = 0; i < 40; i++) {
if (existiert(p)) return p
const eltern = dirname(p)
if (eltern === p) return p
p = eltern
}
return p
}
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
try {
const s = messen(vorhandenerPfad(pfad, existiert))
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
} catch (fehler) {
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
// Fenster zeigt den Grund, nie eine erfundene Null.
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
}
}
export { platzUrteil } from '../../gemeinsam/platz'
export class SpeicherDienst {
private zeiger: NodeJS.Timeout | null = null
private letzter: SpeicherStand | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly messen: Messen = echtMessen,
) {}
stand(): SpeicherStand {
const arbeit = this.einstellungen.arbeitsordner()
const ablage = this.einstellungen.ablageOrdner()
const s: SpeicherStand = {
arbeit: platzMessen(arbeit, this.messen),
ablage: platzMessen(ablage, this.messen),
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
}
this.letzter = s
return s
}
letzterStand(): SpeicherStand | null {
return this.letzter
}
senden(): void {
try {
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
}
}
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
taktStarten(ms = 60_000): void {
if (this.zeiger !== null) return
this.zeiger = setInterval(() => this.senden(), ms)
this.zeiger.unref?.()
}
}
+466
View File
@@ -0,0 +1,466 @@
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
// Benachrichtigung, wenn eine Kompression zu Ende ist.
import { existsSync, rmSync } from 'node:fs'
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
import type { VorgangBeginn } from '../ablauf/pipeline'
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from '../ablage/rohaufraeumen'
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import type { KernKontext } from '../kontext'
import { TmdbClient } from '../metadaten/tmdb'
import type { Vorgang } from '../speicher/db'
import type { BenachrichtigungsDienst } from './benachrichtigung'
import type { EinstellungsDienst } from './einstellungen'
import type { ErkennungDienst } from './erkennung'
import type { WerkzeugDienst } from './werkzeuge'
/** Statistik aus den Vorgängen (pur). */
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
let fertige = 0
let rohBytes = 0
let fertigBytes = 0
let dauerS = 0
for (const v of liste) {
rohBytes += v.rohBytes
if (v.phase !== 'fertig') continue
fertige += 1
fertigBytes += v.fertigBytes
if (v.beendet !== null) {
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
if (Number.isFinite(d) && d > 0) dauerS += d
}
}
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
}
export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
export class VorgangDienst {
readonly warteschlange: KompressionsWarteschlange
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
private letztePhase = new Map<string, string>()
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly werkzeuge: WerkzeugDienst,
private readonly erkennung: ErkennungDienst,
private readonly benachrichtigung: BenachrichtigungsDienst,
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
private readonly speicherSenden: () => void,
) {
this.warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
(stand) => this.standGeaendert(stand.laeuft),
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
)
}
private get db() {
return this.kontext.db
}
ablageUmgebung(): AblageUmgebung {
return {
handbrake: () => this.werkzeuge.handbrake(),
ablage: () => this.einstellungen.ablageOrdner(),
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
einstellungen: () => this.einstellungen.fuerPipeline(),
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey'))
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag),
}
}
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
try {
this.db?.bibliothekEintragen(eintrag)
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
}
}
/** Jede Änderung der Warteschlange Fortschritt eingeschlossen. 5.7.0
* (Durchsicht F7): Ein Prozent-Tick ist eine KLEINE Nachricht
* (kompression-stand); vorher löste jeder Tick (HandBrake: etwa einmal je
* Sekunde) den vollen Vorgangs-Stand aus mit einem Ordnerlauf durch
* jeden Roh-Ordner, zwei statfs und einem kompletten Neuzeichnen des
* Fensters. Der volle Stand geht nur noch bei einem Phasenwechsel. */
private standGeaendert(laeuft: KompressionStand | null): void {
const phasenwechsel = laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase
if (laeuft !== null && phasenwechsel) {
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
if (phasenwechsel || laeuft === null) {
this.senden()
return
}
this.kontext.anHauptUndFenster({
art: 'kompression-stand',
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
})
}
// Roh-Größen sind teuer (rekursiver Ordnerlauf, bei einer NAS über das
// Netz) und ändern sich nur, wenn Rippy selbst etwas anlegt oder löscht —
// deshalb 30 s lang gemerkt (F7). Wer löscht, leert das Gedächtnis.
private groessen = new Map<string, { bytes: number; wann: number }>()
private static readonly GROESSEN_FRISCH_MS = 30_000
private groesseGemerkt(pfad: string): number {
const jetzt = Date.now()
const alt = this.groessen.get(pfad)
if (alt !== undefined && jetzt - alt.wann < VorgangDienst.GROESSEN_FRISCH_MS) return alt.bytes
const bytes = ordnerGroesse(pfad)
this.groessen.set(pfad, { bytes, wann: jetzt })
return bytes
}
/** Nach Löschen oder Aufräumen: frisch messen. */
private groessenVergessen(): void {
this.groessen.clear()
}
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
this.letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = this.db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status}${text}`)
this.kontext.anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
const letzte = Math.max(...ergebnis.folgenVergeben)
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
}
if (ergebnis.status !== 'abgebrochen') {
void this.benachrichtigung.senden(
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
`${titel}: ${ergebnis.text.slice(0, 300)}`,
)
}
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
else this.senden()
}
// ── Pipeline-Haken ──────────────────────────────────────────────────────
vorgangBeginnt(v: VorgangBeginn): void {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
this.db?.vorgangAnlegen(vorgang)
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk}${v.rohOrdner}`)
this.senden()
}
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = this.warteschlange.einreihen(auftrag, titel)
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
this.senden()
return platz
}
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
this.senden()
}
// ── Stand fürs Fenster ──────────────────────────────────────────────────
stand(): VorgaengeStand {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const liste = this.db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? this.groesseGemerkt(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
// 5.7.0 (F27): Ein fertiger, UNkomprimierter Vorgang hat seine Roh-
// Dateien verschoben — da ist nichts mehr zu wiederholen.
neuKomprimierbar:
rohDa && ruhig && !(v.phase === 'fertig' && !v.komprimiert) && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte, (pfad) => this.groesseGemerkt(pfad))
for (const f of fremde) rohGesamtBytes += f.bytes
return {
vorgaenge,
rohGesamtBytes,
fremde,
regel: regelText(regel),
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
statistik: statistikAus(liste),
}
}
/** Der Knopf „Aktualisieren": frisch messen, nicht aus dem Gedächtnis. */
neuMessen(): void {
this.groessenVergessen()
this.senden()
}
senden(): void {
try {
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
}
this.speicherSenden()
}
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
pause(an: boolean): void {
this.warteschlange.pausieren(an)
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
this.senden()
}
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
rohAufraeumenLauf(): void {
if (this.db === null) return
try {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const kandidaten = this.db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
if (ergebnis.geloescht.length > 0) this.groessenVergessen()
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
} catch (fehler) {
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
}
this.senden()
}
rohLoeschen(vorgangId: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
return
}
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
// direkt unter irgendeinem roh\ ist.
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
this.groessenVergessen()
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
this.kontext.fehler(`${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
return
}
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
this.kontext.fehler(`${ordner}" gehört zu einem Vorgang — dort löschen.`)
return
}
try {
rmSync(ordner, { recursive: true, force: true })
this.groessenVergessen()
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`${ordner}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
neuKomprimieren(vorgangId: string, preset: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" ist schon in der Warteschlange.`)
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
this.kontext.fehler(`${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`)
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
this.kontext.fehler(`${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
return
}
if (vorhandene.length < auftrag.dateien.length) {
this.kontext.hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
const neu: KompressionsAuftrag = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
zielOrdner: alteFassung ? v.zielOrdner : '',
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
}
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = this.warteschlange.einreihen(neu, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
this.senden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
wiederaufnehmen(): void {
if (this.db === null) return
const jetzt = new Date().toISOString()
for (const v of this.db.vorgangListe()) {
if (v.phase === 'rippt') {
this.db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
this.db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
}
+271
View File
@@ -0,0 +1,271 @@
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
import type { KernNachricht } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { finden } from '../werkzeuge/katalog'
import type { EinstellungsDienst } from './einstellungen'
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
export class WerkzeugDienst {
/** Die gemessene Auskunft bleibt liegen die Pipeline braucht sie für die
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
schluesselStand: SchluesselStand | null = null
private beschaffungLaeuft = false
private installerAntwort: ((fehler: string | null) => void) | null = null
/** 5.7.0 (F16): Die offene Ja/Nein-Frage der Beschaffung — null, wenn keine. */
private frageAntwort: ((ja: boolean) => void) | null = null
private letzterBeschaffungsText = ''
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
) {}
// 5.7.0 (Durchsicht F22): Ein Fund gilt 60 s. Vorher lief bei fehlendem
// MakeMKV bei JEDEM Aufruf die Suchkette bis in die Registry (dreimal
// `reg query … /s`, synchron, bis 45 s) — und makemkvcon() wird oft
// gerufen: je Pipeline-Start, je Titel-Lauf, im Update-Lauf alle 15 s.
private gefunden = new Map<string, { pfad: string; eingestellt: string; wann: number }>()
private static readonly FUND_FRISCH_MS = 60_000
private suchen(name: string, schluessel: string): string {
const eingestellt = this.einstellungen.lesen(schluessel)
const alt = this.gefunden.get(name)
if (alt !== undefined && alt.eingestellt === eingestellt && Date.now() - alt.wann < WerkzeugDienst.FUND_FRISCH_MS) return alt.pfad
const pfad = finden(name, eingestellt)
this.gefunden.set(name, { pfad, eingestellt, wann: Date.now() })
return pfad
}
/** Nach einer Installation oder einem neuen Pfad: frisch suchen. */
neuSuchen(): void {
this.gefunden.clear()
}
makemkvcon(): string {
return this.suchen('makemkv', 'werkzeug.makemkv')
}
handbrake(): string {
return this.suchen('handbrake', 'werkzeug.handbrake')
}
flac(): string {
return this.suchen('flac', 'werkzeug.flac')
}
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
async senden(): Promise<void> {
this.neuSuchen()
const makemkvcon = this.makemkvcon()
const handbrake = this.handbrake()
const flac = this.flac()
const { encoderMessen } = await import('../komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
this.kontext.anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
this.kontext.protokoll.schreiben(
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
)
}
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
async schluesselLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { schluesselKette } = await import('../werkzeuge/schluessel')
this.schluesselStand = await schluesselKette(
this.makemkvcon(),
this.einstellungen.lesen('makemkvAppKey'),
new Date().toISOString(),
)
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
}
}
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('../werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
} else if (stand.stand === 'gesetzt') {
this.kontext.hinweis(stand.text)
} else {
this.kontext.protokoll.schreiben(stand.text)
}
} catch (fehler) {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
}
}
/** Antwort des Haupts auf 'installer-starten'. */
installerErgebnis(fehler: string | null): void {
this.installerAntwort?.(fehler ?? '')
this.installerAntwort = null
}
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false, frage?: string): void {
this.letzterBeschaffungsText = text
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler, ...(frage !== undefined ? { frage } : {}) } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}${frage !== undefined ? ` — FRAGE: ${frage}` : ''}`)
}
/** Antwort des Fensters auf eine offene Frage (beschaffung-antwort). */
beschaffungAntwort(ja: boolean): void {
const antwort = this.frageAntwort
this.frageAntwort = null
if (antwort === null) {
this.kontext.hinweis('Es ist keine Frage der MakeMKV-Beschaffung offen.')
return
}
antwort(ja)
}
/** Eine Ja/Nein-Frage ans Fenster; ohne Antwort in zehn Minuten gilt Nein. */
private frage(text: string): Promise<boolean> {
return new Promise((fertig) => {
this.frageAntwort = (ja) => {
clearTimeout(zeiger)
this.beschaffungMelden(ja ? 'Freigegeben — die Archiv-Kopie wird geladen.' : 'Nicht freigegeben.', null, true)
fertig(ja)
}
const zeiger = setTimeout(() => {
if (this.frageAntwort === null) return
this.frageAntwort = null
this.beschaffungMelden('Keine Antwort — die Archiv-Kopie wird nicht geladen.', null, true)
fertig(false)
}, 10 * 60_000)
zeiger.unref()
this.beschaffungMelden(this.letzterBeschaffungsText, null, true, false, text)
})
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
// Prozess-Leine und überlebt ein geschlossenes Rippy).
async makemkvUpdateLauf(): Promise<void> {
if (this.beschaffungLaeuft) {
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return
}
this.beschaffungLaeuft = true
try {
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
'../werkzeuge/beschaffen'
)
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
this.beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
this.beschaffungLaeuft = false
return
}
const installiert = this.schluesselStand?.version ?? ''
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
this.beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
this.beschaffungLaeuft = false
return
}
// Merken, woran eine gelungene Installation zu erkennen ist: Die
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
// erstmals eine).
const alteExe = this.makemkvcon()
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(
neueste,
(text, anteil) => this.beschaffungMelden(text, anteil, true),
'',
undefined,
undefined,
undefined,
// F16: Das Archiv liefert eine unsignierte EXE, die gleich mit
// Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy.
(quelle) =>
this.frage(
`makemkv.com antwortet nicht. Soll Rippy den MakeMKV-Installer ${neueste} aus dem ${quelle} laden? Das ist eine Kopie von fremder Hand, geprüft wird nur, dass sie ein MakeMKV-Programm ist — er läuft danach mit Adminrechten. Wer sicher gehen will, lädt später selbst von makemkv.com.`,
),
)
this.beschaffungMelden(
`Geprüft (Quelle: ${ergebnis.quelle}, ${(ergebnis.bytes / 1024 ** 2).toFixed(1)} MB, SHA-256 ${ergebnis.sha256}) — der Installer wird gestartet …`,
null,
true,
)
const startFehler = await new Promise<string | null>((fertig) => {
this.installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
this.installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
this.beschaffungLaeuft = false
return
}
this.beschaffungMelden(
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
null,
true,
)
// Warten, bis die Installation durch ist: Die Versions-Ressource der
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
let runden = 0
const zeiger = setInterval(() => {
runden += 1
const exe = this.makemkvcon()
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
void this.schluesselLauf().then(() => this.senden())
} else if (runden >= 80) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
null,
false,
)
}
}, 15_000)
zeiger.unref()
} catch (fehler) {
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
this.beschaffungLaeuft = false
}
}
}
File diff suppressed because it is too large Load Diff
+135 -11
View File
@@ -15,8 +15,15 @@
// im Fehlerfall die einzige Auskunft. // im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung — // - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus. // sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
// - `--aencoder copy` OHNE `--audio-copy-mask` kopiert NUR AAC: HandBrake-
// CLI 1.11.2 setzt die Kopiermaske dann auf „copy:aac" — obwohl das
// Preset alle Codecs erlaubt — und rechnet AC3/DTS/TrueHD in AAC um
// („Auto Passthru: allowed codecs are AAC … passthru not possible for
// track 1, using fallback"). Gemessen 12.09.2026 (Durchsicht, F31):
// mit ausdrücklicher Maske bleibt AC3 AC3. Seit 5.0 bis 5.6.1 wurde
// also jede Tonspur verlustbehaftet umgerechnet.
import { werkzeugSpawn } from '../werkzeuge/aufruf' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs' import { existsSync, renameSync, unlinkSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux. // win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path' import { win32 as pfad } from 'node:path'
import { textVon } from '../rip/parser' import { textVon } from '../rip/parser'
@@ -33,6 +40,13 @@ export interface HandbrakeArgsOptionen {
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0): /** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */ * japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean untertitelStandard?: boolean
/** 5.7.0: Spuren EINZELN — HandBrake-Spurnummern (`-a`/`-s`, 1-basiert,
* Scan-Reihenfolge). Gesetzt, gelten die Sprachlisten nicht. Eine leere
* Liste heißt: keine Spur dieser Art. `untertitelStandardNr` ist der
* 1-basierte Index in `untertitelNr` (0 = keine Vorwahl). */
audioNr?: readonly number[]
untertitelNr?: readonly number[]
untertitelStandardNr?: number
} }
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026, // Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
@@ -47,16 +61,47 @@ export interface HandbrakeArgsOptionen {
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt // zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst) // sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik) // -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
// Spur-Schalter GEMESSEN (12.09.2026, Testdatei jpn 5.1/deu 2.0/deu 2.0
// „Kommentar" AC3 + deu/eng SRT, Preset „H.265 MKV 1080p30"):
// --audio 1,3 → jpn und deu-Kommentar, in dieser Reihenfolge
// --subtitle 2,1 --subtitle-default=1 → eng (default=1) vor deu
// --subtitle none → keine Untertitelspur (ohne --audio: die erste Tonspur)
// --audio-copy-mask aac,ac3,eac3,dtshd,dts,mp3,truehd,flac → AC3 bleibt AC3
/** Was HandBrake verlustfrei durchreichen darf genau die Maske der
* mitgelieferten Presets (copy:aac" … „copy:flac"), ausdrücklich, weil
* die CLI sie sonst auf AAC verengt (siehe Kopf). */
export const KOPIERMASKE = 'aac,ac3,eac3,dtshd,dts,mp3,truehd,flac'
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] { export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset] const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()] const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
if (format !== undefined) args.push('--format', format) if (format !== undefined) args.push('--format', format)
// 5.7.0: Spuren einzeln — `--audio 1,3` und `--subtitle 2` (HandBrakeCLI
// --help: „-a, --audio <string> Select audio track(s), separated by
// commas", „-s, --subtitle <string> Select subtitle track(s)"; am
// mitgelieferten 1.11.2 gemessen, siehe test/messung.spuren.test.ts).
if (optionen.audioNr !== undefined || optionen.untertitelNr !== undefined) {
const audioNr = optionen.audioNr ?? []
// Ohne gewählte Tonspur bleibt HandBrakes Vorgabe: die erste Spur —
// ein stummer Film wäre nie gewollt (der Dialog sagt das dazu).
if (audioNr.length > 0) args.push('--audio', audioNr.join(','))
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
const untertitelNr = optionen.untertitelNr ?? []
if (untertitelNr.length > 0) {
args.push('--subtitle', untertitelNr.join(','))
if ((optionen.untertitelStandardNr ?? 0) > 0) args.push(`--subtitle-default=${optionen.untertitelStandardNr}`)
} else {
args.push('--subtitle', 'none')
}
return args
}
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0) const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
if (audio.length > 0) args.push('--audio-lang-list', audio.join(',')) if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
args.push('--first-audio') args.push('--first-audio')
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist // `copy` reicht die gewählten Tonspuren verlustfrei durch — aber nur mit
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist. // ausdrücklicher Kopiermaske (F31, siehe Kopf); `av_aac` ist der
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac') // dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0) const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(',')) if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles') args.push('--all-subtitles')
@@ -72,6 +117,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode // Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/ const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
// grep -a, 02.09.2026):
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
export function etaAusZeile(zeile: string): number {
if (!HB_ENCODE.test(zeile.trim())) return -1
const t = HB_ETA.exec(zeile)
if (t === null) return -1
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
}
/** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss /** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss
* von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M) * von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */ * werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
@@ -122,11 +181,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */ /** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung { export class HbAuswertung {
prozent = -1 prozent = -1
restS = -1
falschesPreset = '' falschesPreset = ''
falscherSchalter = '' falscherSchalter = ''
letzteZeilen: string[] = [] letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } { verarbeite(zeile: string): { prozent?: number; restS?: number } {
if (zeile.trim().length > 0) { if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile) this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift() if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
@@ -134,9 +194,13 @@ export class HbAuswertung {
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile) if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile) if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile) const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) { if (p >= 0) {
const rest = etaAusZeile(zeile)
if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
this.prozent = p this.prozent = p
return { prozent: p } this.restS = rest
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
}
} }
return {} return {}
} }
@@ -245,13 +309,55 @@ export interface HbLaufOptionen {
untertitelStandard?: boolean untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */ /** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[] extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void /** Die fertige Datei darf eine vorhandene gleichen Namens ersetzen
* nur beim Wiederholen (auftrag.ersetzt); sonst ist das ein Fehler. */
ueberschreiben?: boolean
/** Fortschritt 0100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
aufFortschritt?: (prozent: number, restS: number) => void
abbruch?: AbortSignal abbruch?: AbortSignal
} }
/** Der Zwischenname, unter dem HandBrake schreibt (5.7.0, Durchsicht F9):
* `Akira (1988).mkv` `Akira (1988).teil.mkv`. Die Endung bleibt, damit
* --format und die Datei-daneben-Prüfung weiter an der Endung hängen;
* erst eine FERTIGE Datei bekommt den echten Namen. Vorher blieb nach
* Abbruch oder Fehler eine halbe Datei unter dem endgültigen Namen liegen
* ohne Arbeitsordner direkt in der Ablage, wo Jellyfin sie als kaputten
* Film zeigte. */
export function zwischenname(ausgabe: string): string {
const endung = extname(ausgabe)
return endung.length > 0 ? ausgabe.slice(0, -endung.length) + '.teil' + endung : ausgabe + '.teil'
}
/** `x.teil.mkv` → `x.mkv` (auch für die Datei daneben: `x.teil.mp4` → `x.mp4`). */
export function endname(zwischen: string): string {
return zwischen.replace(/\.teil(\.[^.\\/]+)$/, '$1')
}
/** Reste eines abgebrochenen oder gescheiterten Laufs entfernen die
* Zwischen-Datei und, falls HandBrake einen anderen Container wählte,
* ihre Datei daneben. Liefert, was weg ist. */
export function zwischenReste(zwischen: string, entfernen: (p: string) => void = unlinkSync, existiert: (p: string) => boolean = existsSync): string[] {
const weg: string[] = []
for (const kandidat of [zwischen, dateiDaneben(zwischen, existiert)]) {
if (kandidat.length === 0 || !existiert(kandidat)) continue
try {
entfernen(kandidat)
weg.push(kandidat)
} catch {
// Eine Datei, die sich nicht löschen lässt (noch offen?), bleibt —
// sie heißt „.teil" und ist damit als halb erkennbar.
}
}
return weg
}
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> { export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => { return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen) // HandBrake schreibt auf den Zwischennamen; der echte Name kommt erst
// mit dem Erfolg (F9).
const zwischen = zwischenname(optionen.ausgabe)
const args = buildHandbrakeArgs({ ...optionen, ausgabe: zwischen })
args.push(...(optionen.extraArgs ?? [])) args.push(...(optionen.extraArgs ?? []))
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
@@ -267,13 +373,14 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
const zeile = (z: string): void => { const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z) const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
} }
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => { kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen) optionen.abbruch?.removeEventListener('abort', abbrechen)
zwischenReste(zwischen)
fertig({ status: 'error', fehler: String(fehler) }) fertig({ status: 'error', fehler: String(fehler) })
}) })
@@ -281,10 +388,27 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
optionen.abbruch?.removeEventListener('abort', abbrechen) optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile) leser.schluss().forEach(zeile)
if (abgebrochen) { if (abgebrochen) {
zwischenReste(zwischen)
fertig({ status: 'cancelled' }) fertig({ status: 'cancelled' })
return return
} }
fertig(auswertung.urteil(code, optionen.ausgabe)) const urteil = auswertung.urteil(code, zwischen)
if (urteil.status !== 'success') {
zwischenReste(zwischen)
fertig(urteil)
return
}
// Fertig: Zwischenname → echter Name (eine vorhandene Datei gleichen
// Namens wäre ein Fehler in der Ablage-Logik — nie still überschreiben).
const ziel = endname(urteil.ausgabe)
try {
if (optionen.ueberschreiben !== true && existsSync(ziel)) throw new Error(`${basename(ziel)} existiert schon`)
renameSync(urteil.ausgabe, ziel)
} catch (fehler) {
fertig({ status: 'error', fehler: `Die fertige Datei ließ sich nicht umbenennen (${String(fehler)}) — sie liegt unter ${urteil.ausgabe}` })
return
}
fertig({ ...urteil, ausgabe: ziel })
}) })
}) })
} }
+14 -16
View File
@@ -2,9 +2,9 @@
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete // mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
// 4K auf 1080p herunter und DVDs hinauf. // 4K auf 1080p herunter und DVDs hinauf.
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung' import { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
export { DEFAULT_HB_PRESET, presetEmpfehlung } export { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung }
/** Einstellungs-Schlüssel je Disc-Typ. */ /** Einstellungs-Schlüssel je Disc-Typ. */
export const PRESET_SCHLUESSEL: Record<string, string> = { export const PRESET_SCHLUESSEL: Record<string, string> = {
@@ -13,15 +13,12 @@ export const PRESET_SCHLUESSEL: Record<string, string> = {
uhd: 'transcodePresetUhd', uhd: 'transcodePresetUhd',
} }
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
export const PRESET_KEINE = 'keine'
export type Einstellungen = Record<string, string | boolean | undefined> export type Einstellungen = Record<string, string | boolean | undefined>
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */ /** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur)
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean { * 5.7.0: Steht der Typ auf automatisch" und die gemessene Empfehlung
* sagt keine" (4K ohne Grafikkarten-Encoder), bleibt es verlustfrei. */
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): boolean {
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') { if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
return false return false
} }
@@ -29,24 +26,25 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
if (schluessel.length > 0) { if (schluessel.length > 0) {
const wert = String(einstellungen[schluessel] ?? '').trim() const wert = String(einstellungen[schluessel] ?? '').trim()
if (wert === PRESET_KEINE) return false if (wert === PRESET_KEINE) return false
if (wert.length === 0 && empfehlung === PRESET_KEINE) return false
} }
return true return true
} }
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur) /** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
* Kette: Typ-Preset allgemeines transcodePreset EMPFEHLUNG (gemessen, * Kette: Typ-Preset EMPFEHLUNG (gemessen, Hardware zuerst) CPU-DEFAULT.
* Hardware zuerst) CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN diese * PRESET_KEINE wird ÜBERSPRUNGEN diese Funktion darf nie keine" als
* Funktion darf nie keine" als Preset-Namen liefern, sonst bekäme * Preset-Namen liefern, sonst bekäme HandBrake `--preset keine` und
* HandBrake `--preset keine` und scheiterte. */ * scheiterte. (5.7.0, A5: das allgemeine Preset" transcodePreset ist seit
* 5.5.0 aus den Pipeline-Einstellungen raus hier stand es noch als
* toter Zweig.) */
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string { export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? '' const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) { if (schluessel.length > 0) {
const eigen = String(einstellungen[schluessel] ?? '').trim() const eigen = String(einstellungen[schluessel] ?? '').trim()
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
} }
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim() return empfehlung.length > 0 && empfehlung !== PRESET_KEINE ? empfehlung : DEFAULT_HB_PRESET
if (allgemein.length > 0) return allgemein
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
} }
/** deu, eng , ,DEU" ["deu","eng"] HandBrake will ISO-639-2 klein; /** deu, eng , ,DEU" ["deu","eng"] HandBrake will ISO-639-2 klein;
+18 -3
View File
@@ -18,9 +18,21 @@
import { werkzeugSpawn } from '../werkzeuge/aufruf' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser' import { textVon } from '../rip/parser'
/** Eine Spur laut HandBrake-Scan. 5.7.0: dazu CodecName (dtshd", dca",
* truehd", „ac3" ), ChannelCount und bei Untertiteln Source/SourceName
* (PGS") am 12.09.2026 am mitgelieferten HandBrake 1.11.2 gemessen
* (AudioList[].CodecName/ChannelCount/ChannelLayout/TrackNumber). */
export interface HbSpur {
nr: number
lang: string
beschreibung: string
codec: string
kanaele: number
}
export interface HbSpuren { export interface HbSpuren {
audio: Array<{ nr: number; lang: string; beschreibung: string }> audio: HbSpur[]
untertitel: Array<{ nr: number; lang: string; beschreibung: string }> untertitel: HbSpur[]
dauerS: number dauerS: number
} }
@@ -60,14 +72,17 @@ export function spurenAusScan(ausgabe: string): HbSpuren | null {
const liste = (daten as { TitleList?: unknown }).TitleList const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record<string, unknown> const titel = liste[0] as Record<string, unknown>
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => { const spuren = (feld: unknown): HbSpur[] => {
if (!Array.isArray(feld)) return [] if (!Array.isArray(feld)) return []
return feld.map((s, i) => { return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown> const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
const kanaele = eintrag.ChannelCount
return { return {
nr: i + 1, nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(), lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''), beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
codec: String(eintrag.CodecName ?? eintrag.SourceName ?? eintrag.Source ?? eintrag.Format ?? ''),
kanaele: typeof kanaele === 'number' && Number.isFinite(kanaele) ? kanaele : 0,
} }
}) })
} }
+42
View File
@@ -0,0 +1,42 @@
// Der gemeinsame Kontext der Kern-Dienste (5.5.0, Separation of Concerns):
// Jeder Dienst bekommt genau das hier — die Datenbank, die Wege nach
// draußen und das Protokoll — statt auf Modul-Globale zu greifen. So ist
// jeder Dienst für sich lesbar und mit einem Fake-Kontext testbar.
import type { DbStatus, KernNachricht } from '../gemeinsam/nachrichten'
import type { Protokoll } from '../gemeinsam/protokoll'
import type { Datenbank } from './speicher/db'
export interface KernKontext {
/** null, wenn die Datenbank nicht zu öffnen war — der Kern läuft trotzdem (R4). */
readonly db: Datenbank | null
readonly dbStatus: DbStatus
/** Nur ans Fenster (wenn eins verbunden ist). */
anFenster(nachricht: KernNachricht): void
/** Ans Haupt UND ans Fenster — für alles, was Tray/Taskleiste/Updater brauchen. */
anHauptUndFenster(nachricht: KernNachricht): void
/** Eine Zeile ohne Warnzeichen fürs Ereignis-Protokoll — und in die Datei. */
hinweis(text: string): void
/** Eine Zeile MIT Warnzeichen (kern-fehler) — und in die Datei. */
fehler(text: string): void
readonly protokoll: Protokoll
/** Der Gerüst-Beweis (--smoke-kern): ohne Netz, ohne MakeMKV, ohne Takte. */
readonly smoke: boolean
}
/** --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
* nicht er ist ein reiner Node-Prozess). */
export function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
/** Einstellung lesen mit Vorgabe — die eine Quelle (§ 6.7). */
export function einstellung(kontext: Pick<KernKontext, 'db'>, schluessel: string, vorgabe = ''): string {
const wert = kontext.db?.einstellungLesen(schluessel)
return wert === null || wert === undefined ? vorgabe : wert
}
+3
View File
@@ -84,6 +84,9 @@ export class Win32Fehler extends Error {
export interface LaufwerkApi { export interface LaufwerkApi {
laufwerksbuchstaben(): string[] laufwerksbuchstaben(): string[]
laufwerkstyp(buchstabe: string): number laufwerkstyp(buchstabe: string): number
/** Kernel-Gerätename hinter dem Buchstaben (\Device\CdRom0) '' wenn
* nicht zu ermitteln (5.7.0, F11). */
geraeteName(buchstabe: string): string
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM /** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM
* bei gestörter Disc scheitert das zweite und das erste geht weiter * bei gestörter Disc scheitert das zweite und das erste geht weiter
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */ * (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
+18 -6
View File
@@ -196,27 +196,39 @@ export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block /** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block
* eine 47-MB-Spur wandert so nie komplett in den Speicher. * eine 47-MB-Spur wandert so nie komplett in den Speicher.
* DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor * DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
* 2352 Bytes hat dokumentiert, sieht falsch aus, ist richtig. */ * 2352 Bytes hat dokumentiert, sieht falsch aus, ist richtig.
export function audioSpurLesen( * 5.7.0 (Durchsicht F30): Zwischen den Blöcken bekommt der Kern die
* Schleife zurück (setImmediate) vorher blockierte eine ganze Spur den
* Kern, und Abbrechen" wirkte erst zwischen zwei Spuren. `abbruch` endet
* den Lauf am nächsten Block. */
export async function audioSpurLesen(
api: LaufwerkApi, api: LaufwerkApi,
geraet: string, geraet: string,
start: number, start: number,
sektoren: number, sektoren: number,
schreiben: (daten: Buffer) => void, schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void, fortschritt?: (anteil: number) => void,
): number { abbruch?: AbortSignal,
return mitHandle(api, geraet, GENERIC_READ, (h) => { ): Promise<number> {
const handle = api.oeffnen(geraet, GENERIC_READ)
try {
const teile = bloecke(start, sektoren) const teile = bloecke(start, sektoren)
let gelesen = 0 let gelesen = 0
for (let i = 0; i < teile.length; i++) { for (let i = 0; i < teile.length; i++) {
if (abbruch?.aborted === true) throw new Error('abgebrochen')
const [von, anzahl] = teile[i] const [von, anzahl] = teile[i]
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES) const daten = api.steuern(handle, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
schreiben(daten) schreiben(daten)
gelesen += daten.length gelesen += daten.length
fortschritt?.((i + 1) / teile.length) fortschritt?.((i + 1) / teile.length)
// Alle acht Blöcke (~500 KB) einmal Luft holen — Nachrichten, Wache,
// Abbruch kommen dann durch.
if (i % 8 === 7) await new Promise<void>((weiter) => setImmediate(weiter))
} }
return gelesen return gelesen
}) } finally {
api.schliessen(handle)
}
} }
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die // ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
+18 -4
View File
@@ -62,10 +62,24 @@ export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
return liste return liste
} }
/** Gehört dieses Ereignis zu DIESEM Laufwerk? (pur, 5.7.0 Durchsicht F11)
* Mit bekanntem Kernel-Gerät (\Device\CdRom0) zählt nur, was dieses
* Gerät nennt; ohne Gerätename zählt nur der cdrom-Treiber nie mehr die
* Festplatten (Provider disk), die vorher ein VERDACHT: DIE HARDWARE"
* für das Blu-ray-Laufwerk auslösten. */
export function gehoertZuLaufwerk(e: LaufwerkEreignis, geraet: string): boolean {
if (geraet.length > 0) return e.geraet.toLowerCase().includes(geraet.toLowerCase())
return e.provider === 'cdrom'
}
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein /** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */ * (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist.
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht { * `geraet` ist der Kernel-Gerätename des Laufwerks (\Device\CdRom0),
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime()) * '' wenn unbekannt dann zählen alle cdrom-Ereignisse. */
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date, geraet = ''): LaufwerkBericht {
const relevant = ereignisse.filter(
(e) => gehoertZuLaufwerk(e, geraet) && (seit === undefined || new Date(e.zeit).getTime() >= seit.getTime()),
)
let fehlerhafteBloecke = 0 let fehlerhafteBloecke = 0
let controllerfehler = 0 let controllerfehler = 0
let sonstige = 0 let sonstige = 0
@@ -87,7 +101,7 @@ export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: num
if (relevant.length === 0) { if (relevant.length === 0) {
return { return {
urteil: 'ruhig', urteil: 'ruhig',
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`, text: `Das Windows-Protokoll meldet ${zeitraum} nichts zu${geraet.length > 0 ? ` ${geraet}` : 'm Laufwerk'} — weder Lesefehler noch Hardware-Fehler.`,
fehlerhafteBloecke, fehlerhafteBloecke,
controllerfehler, controllerfehler,
sonstige, sonstige,
+8
View File
@@ -82,6 +82,14 @@ export class DiscWache {
* Öffentlich, damit Tests sie ohne Uhr treiben können. */ * Öffentlich, damit Tests sie ohne Uhr treiben können. */
runde(): void { runde(): void {
if (!this.laeuft) return if (!this.laeuft) return
// 5.7.0 (Durchsicht F6): Eine von außen angestoßene Runde (nach dem
// Auswurf) ersetzt die geplante — vorher legte JEDER Aufruf eine zweite
// Takt-Kette an, und nach zwanzig Discs fragte Rippy die Laufwerke alle
// 150 ms statt alle 3 s (Sonde 3: neun statt fünf Runden).
if (this.zeiger !== null) {
clearTimeout(this.zeiger)
this.zeiger = null
}
try { try {
const liste = this.lesen() const liste = this.lesen()
const jetzt = new Map(liste.map((g) => [g.id, g])) const jetzt = new Map(liste.map((g) => [g.id, g]))
+19
View File
@@ -24,6 +24,15 @@ export function win32Api(): LaufwerkApi {
) )
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)') const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()') const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
// 5.7.0 (Durchsicht F11): Welches Kernel-Gerät steckt hinter G:? —
// QueryDosDeviceW liefert \Device\CdRom0 (learn.microsoft.com,
// fileapi/nf-fileapi-querydosdevicew: Ziel ist eine Mehrfach-Zeichenkette,
// der erste Eintrag zählt; am 12.09.2026 an C: dieses PCs gemessen).
// Damit zählt die Laufwerks-Gesundheit nur noch die Ereignisse DIESES
// Laufwerks, nicht die einer hustenden Festplatte.
const QueryDosDeviceW = kernel32.func(
'uint32_t __stdcall QueryDosDeviceW(str16 lpDeviceName, _Out_ char16_t *lpTargetPath, uint32_t ucchMax)',
)
const DeviceIoControl = kernel32.func( 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)', '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)',
) )
@@ -65,6 +74,16 @@ export function win32Api(): LaufwerkApi {
CloseHandle(handle) CloseHandle(handle)
}, },
geraeteName(buchstabe: string): string {
const ziel = Buffer.alloc(2 * 1024)
const laenge = QueryDosDeviceW(`${buchstabe.replace(/:$/, '')}:`, ziel, 1024) as number
if (laenge === 0) return ''
// UTF-16LE, die erste Zeichenkette bis zum ersten NUL.
const text = ziel.toString('utf16le', 0, laenge * 2)
const ende = text.indexOf('\0')
return (ende >= 0 ? text.slice(0, ende) : text).trim()
},
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer { steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
const zurueck = [0] const zurueck = [0]
@@ -250,6 +250,46 @@ export function discZusatzAbtrennen(titel: string): DiscZusatz {
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel } return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
} }
/** Der Disc-Typ aus dem, was auf der Disc liegt (5.7.0, Durchsicht F10)
* unabhängig vom Windows-Steuercode, der bei manchen USB-Brücken nicht
* antwortet, und genauer als die Größe (eine 4K-Disc mit 48 GB Daten
* wurde vorher bluray"):
* * BDMV/index.bdmv beginnt mit INDX" + Fassung: „0300" ist das
* UHD-Format (BDMV 3.0), 0100"/„0200" eine gewöhnliche Blu-ray
* am Akira-UHD-Abbild (F:\Video\backup\UHD_AKIRA) am 12.09.2026 gemessen:
* INDX0300.
* * VIDEO_TS DVD.
* null, wenn nichts davon zu sehen ist (Audio-CD, Datendisc, Lesefehler). */
export function typAusIndexKopf(kopf: Buffer): 'uhd' | 'bluray' | null {
if (kopf.length < 8 || kopf.subarray(0, 4).toString('latin1') !== 'INDX') return null
const fassung = kopf.subarray(4, 8).toString('latin1')
if (fassung === '0300') return 'uhd'
if (fassung === '0100' || fassung === '0200') return 'bluray'
return null
}
export function typAusDateisystem(geraetepfad: string): 'uhd' | 'bluray' | 'dvd' | null {
const wurzel = discWurzel(geraetepfad)
if (wurzel.length === 0) return null
const index = join(wurzel, 'BDMV', 'index.bdmv')
if (existsSync(index)) {
let fd: number | null = null
try {
fd = openSync(index, 'r')
const kopf = lesenBei(fd, 0, 8)
const typ = typAusIndexKopf(kopf)
if (typ !== null) return typ
} catch {
// nicht lesbar — dann entscheidet die Größe (unten)
} finally {
if (fd !== null) closeSync(fd)
}
return 'bluray'
}
if (existsSync(join(wurzel, 'VIDEO_TS'))) return 'dvd'
return null
}
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */ /** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
export function discWurzel(geraetepfad: string): string { export function discWurzel(geraetepfad: string): string {
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '') const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
+21
View File
@@ -25,6 +25,11 @@ export interface FilmTreffer {
id: number id: number
title?: string title?: string
name?: string name?: string
/** 5.7.0 (Durchsicht F14): TMDb antwortet mit language=de-DE auf Deutsch
* (Der Patriot"), die Disc trägt meist den Originaltitel (The
* Patriot") der wörtliche Vergleich braucht beide. */
original_title?: string
original_name?: string
poster_path?: string | null poster_path?: string | null
backdrop_path?: string | null backdrop_path?: string | null
release_date?: string release_date?: string
@@ -185,6 +190,22 @@ export class TmdbClient {
})) as FilmDetails | null })) as FilmDetails | null
} }
/** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung):
* /movie/popular liefert results[].poster_path dieselbe Form wie die
* Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */
async beliebtePoster(hoechstens = 40): Promise<string[]> {
const pfade: string[] = []
for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) {
const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as {
results?: FilmTreffer[]
} | null
for (const f of daten?.results ?? []) {
if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path)
}
}
return pfade.slice(0, hoechstens)
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */ /** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> { async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as { return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
+15 -6
View File
@@ -153,11 +153,19 @@ export function istStub(details: FilmDetails): boolean {
return !beschreibung && !bild && !bewertet return !beschreibung && !bild && !bewertet
} }
/** Akzente und Umlaut-Striche fallen (5.7.0, Durchsicht F14): Déjà Vu"
* auf der Disc heißt DEJA_VU", bei TMDb „Déjà Vu" dasselbe Wort. NFD
* zerlegt é" in „e" + Akzent, \p{M} entfernt den Akzent. Gemessen an
* Sonde 1 der Durchsicht: vorher kein wörtlicher Treffer. */
export function ohneAkzente(t: string): string {
return t.normalize('NFD').replace(/\p{M}+/gu, '').replace(/ß/g, 'ss')
}
/** Titel Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung, /** Titel Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
* Abdeckung und Titelvergleich alle drei sollen dasselbe Wort" meinen. */ * Abdeckung und Titelvergleich alle drei sollen dasselbe Wort" meinen. */
function worte(t: string): Set<string> { function worte(t: string): Set<string> {
return new Set( return new Set(
t ohneAkzente(t)
.toLowerCase() .toLowerCase()
.split(/[^\p{L}\p{N}]+/u) .split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0), .filter((w) => w.length > 0),
@@ -185,7 +193,7 @@ export function gleichBedeutend(a: string, b: string): boolean {
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich // Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen. // 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
const nackt = (t: string): string => const nackt = (t: string): string =>
t ohneAkzente(t)
.toLowerCase() .toLowerCase()
.split(/[^\p{L}\p{N}]+/u) .split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0) .filter((w) => w.length > 0)
@@ -298,9 +306,10 @@ export async function zuordnen(
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? []) const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
// Wörtlicher Filmtreffer (0,95) // Wörtlicher Filmtreffer (0,95) — auf den deutschen ODER den Originaltitel
// (F14: „The Patriot" auf der Disc, „Der Patriot" bei TMDb).
for (const film of filme) { for (const film of filme) {
if (gleichBedeutend(film.title ?? '', kandidat.text)) { if (gleichBedeutend(film.title ?? '', kandidat.text) || gleichBedeutend(film.original_title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id) const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) { if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details) const meta = ausDetails('movie', film.id, details)
@@ -312,9 +321,9 @@ export async function zuordnen(
const serien = (await tmdb.searchTv(kandidat.text)) ?? [] const serien = (await tmdb.searchTv(kandidat.text)) ?? []
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
// Wörtlicher Serientreffer (0,90) // Wörtlicher Serientreffer (0,90) — deutscher oder Originalname.
for (const serie of serien) { for (const serie of serien) {
if (gleichBedeutend(serie.name ?? '', kandidat.text)) { if (gleichBedeutend(serie.name ?? '', kandidat.text) || gleichBedeutend(serie.original_name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id) const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) { if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details) const meta = ausDetails('tv', serie.id, details)
+98 -13
View File
@@ -15,6 +15,8 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs' import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import {
aufgabeAusPrg,
istVobScanMeldung,
KRITISCHE_CODES, KRITISCHE_CODES,
KRITISCHE_TEXTE, KRITISCHE_TEXTE,
LESEFEHLER_MARKE, LESEFEHLER_MARKE,
@@ -31,8 +33,11 @@ import {
type TitelStreams, type TitelStreams,
} from './parser' } from './parser'
/** 5.7.1: auch der Info-Lauf meldet Fortschritt (--progress=-same) daran
* erkennt Rippy, dass MakeMKV noch arbeitet, statt nach fester Zeit
* abzubrechen. */
export function buildInfoArgs(geraetepfad: string): string[] { export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)] return ['-r', '--noscan', '--progress=-same', 'info', quelle(geraetepfad)]
} }
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] { export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
@@ -151,26 +156,106 @@ export interface DiscInfo {
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
} }
/** Was der Info-Lauf gerade tut — für den Dialog (5.7.1). */
export interface Lebenszeichen {
/** Gesamtfortschritt 0100, -1 ohne Angabe. */
prozent: number
/** Der aktuelle Schritt laut PRGC, '' wenn unbekannt. */
schritt: string
/** Die letzte Meldung (MSG-Klartext), '' wenn keine. */
meldung: string
/** MakeMKV hat den VOB-Scan einer beschädigten DVD angekündigt. */
vobScan: boolean
}
export interface TitelInfoOptionen {
/** So lange darf makemkvcon STILL sein (kein Byte Ausgabe), bevor Rippy
* ihn beendet. Solange Fortschritt oder Meldungen kommen, wartet Rippy
* eine feste Gesamtgrenze warf den Patriot-Lauf weg, während das
* MakeMKV-Programm dieselbe Disc fertig las (Commander, 12.09.2026). */
stillMs?: number
/** Hat MakeMKV den VOB-Scan angekündigt, gilt statt der Stille-Grenze
* nur diese Obergrenze der Scan liest die ganze Disc, und ob er dabei
* Fortschritt meldet, ist nicht gemessen. */
vobScanHoechstensMs?: number
abbruch?: AbortSignal
aufLebenszeichen?: (stand: Lebenszeichen) => void
}
export const VOB_SCAN_HOECHSTENS_MS = 4 * 60 * 60_000
/** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s, /** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s,
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */ * zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py).
export function titelInfoLesen( * 5.7.1: Die Grenze ist eine STILLE-Grenze makemkvcon wird nur beendet,
programm: string, * wenn er so lange nichts mehr sagt; wer Fortschritt meldet, darf weiter. */
geraetepfad: string, export function titelInfoLesen(programm: string, geraetepfad: string, optionen: TitelInfoOptionen = {}): Promise<DiscInfo> {
timeoutMs = 300_000, const stillMs = optionen.stillMs ?? 300_000
abbruch?: AbortSignal, const vobHoechstensMs = optionen.vobScanHoechstensMs ?? VOB_SCAN_HOECHSTENS_MS
): Promise<DiscInfo> { const abbruch = optionen.abbruch
return new Promise((fertig, scheitert) => { return new Promise((fertig, scheitert) => {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), { const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true, windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
const stuecke: Buffer[] = [] const stuecke: Buffer[] = []
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck)) const stand: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck)) const start = Date.now()
const zeiger = setTimeout(() => { let zeiger: NodeJS.Timeout
// Die letzten Zeilen reisen im Fehler mit — fürs Protokoll, damit ein
// fremder PC sagen kann, WO makemkvcon stehen blieb.
const letzteZeilen: string[] = []
const stumm = (): void => {
kind.kill() kind.kill()
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) const grund = stand.vobScan
}, timeoutMs) ? `makemkvcon info: VOB-Scan nach ${Math.round(vobHoechstensMs / 60_000)} min nicht fertig`
: `makemkvcon info: kein Lebenszeichen seit ${Math.round(stillMs / 1000)} s`
scheitert(new Error(letzteZeilen.length > 0 ? `${grund} — letzte Zeilen: ${letzteZeilen.join(' | ')}` : grund))
}
const neuWarten = (): void => {
clearTimeout(zeiger)
// Beim VOB-Scan zählt nur die Obergrenze ab Start des Laufs.
const frist = stand.vobScan ? Math.max(0, vobHoechstensMs - (Date.now() - start)) : stillMs
zeiger = setTimeout(stumm, frist)
}
neuWarten()
const leserAus = new ZeilenLeser()
const leserFehler = new ZeilenLeser()
const aufStueck = (leser: ZeilenLeser, stueck: Buffer): void => {
stuecke.push(stueck)
let neu = false
for (const zeile of leser.hinzu(stueck)) {
if (zeile.trim().length > 0) {
letzteZeilen.push(zeile.trim().slice(0, 200))
if (letzteZeilen.length > 8) letzteZeilen.shift()
}
const p = progressAusPrgv(zeile)
if (p >= 0) {
if (p !== stand.prozent) {
stand.prozent = p
neu = true
}
continue
}
const aufgabe = aufgabeAusPrg(zeile)
if (aufgabe !== null) {
if (aufgabe.art === 'schritt' && aufgabe.name !== stand.schritt) {
stand.schritt = aufgabe.name
neu = true
}
continue
}
const meldung = parseMsg(zeile)
if (meldung !== null && meldung.text !== stand.meldung) {
stand.meldung = meldung.text
if (istVobScanMeldung(meldung.text)) stand.vobScan = true
neu = true
}
}
neuWarten()
if (neu) optionen.aufLebenszeichen?.({ ...stand })
}
kind.stdout.on('data', (stueck: Buffer) => aufStueck(leserAus, stueck))
kind.stderr.on('data', (stueck: Buffer) => aufStueck(leserFehler, stueck))
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon // Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026: // auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über // Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
+80 -9
View File
@@ -10,6 +10,9 @@
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des // MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner. // Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string { export function quelle(geraetepfad: string): string {
// 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie
// als `iso:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}`
let pfad = (geraetepfad ?? '').trim() let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return '' if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) { for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
@@ -65,6 +68,27 @@ export function progressAusPrgv(zeile: string): number {
return Math.min(100, Math.floor((gesamt * 100) / maximum)) return Math.min(100, Math.floor((gesamt * 100) / maximum))
} }
// ── PRGC/PRGT: Was MakeMKV gerade tut ───────────────────────────────────
// PRGC:code,id,"name" ist der aktuelle Schritt („Analysieren von seamless
// segments"), PRGT:code,id,"name" die Gesamtaufgabe („Öffnen der DVD-Disc").
// Für die Lebenszeichen im Titel-Dialog (5.7.1, Der Patriot).
const PRG_ZEILE = /^PRG([CT]):(\d+),(\d+),"((?:[^"\\]|\\.)*)"/
export function aufgabeAusPrg(zeile: string): { art: 'schritt' | 'aufgabe'; name: string } | null {
const treffer = PRG_ZEILE.exec(zeile.trim())
if (treffer === null) return null
return { art: treffer[1] === 'C' ? 'schritt' : 'aufgabe', name: treffer[4].replaceAll('\\"', '"') }
}
/** MakeMKV kündigt den langen Weg an: IFO file for VTS #1 is corrupt, VOB
* file needs to be scanned. This may take a long time" deutsch IFO-Datei
* für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden". Dann liest
* MakeMKV die ganze VOB durch, so lange wie die Disc braucht (Der Patriot,
* Commander 12.09.2026: 15 Minuten reichten nicht, das GUI schaffte es). */
export function istVobScanMeldung(text: string): boolean {
return /\bIFO\b/i.test(text) && /\bVOB\b/i.test(text)
}
// ── TINFO: Titel-Liste ────────────────────────────────────────────────── // ── TINFO: Titel-Liste ──────────────────────────────────────────────────
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration // Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
// ("H:MM:SS"), 11 = DiskSizeBytes. // ("H:MM:SS"), 11 = DiskSizeBytes.
@@ -111,22 +135,44 @@ export function parseTitelInfo(ausgabe: string): TitelInfo[] {
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM // ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält // Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
// jede Disc für englisch. // jede Disc für englisch.
// ⚠️ 5.6.1 (Commander-Fund 03.09.2026, Digimon Last Evolution Kizuna): Auch
// der TEXT von Attribut 1 ist MakeMKVs Anzeigesprache — deutsch eingestellt
// schreibt es `SINFO:1,5,1,6203,"Untertitel"`, nicht „Subtitles". „Audio"
// heißt zufällig in beiden Sprachen gleich, deshalb fehlten NUR die
// Untertitel. Verlässlich ist der CODE im vierten Feld: 6201 Video,
// 6202 Audio, 6203 Untertitel (gemessen an makemkvcon64 auf diesem PC).
export const STREAM_CODE_VIDEO = 6201
export const STREAM_CODE_AUDIO = 6202
export const STREAM_CODE_UNTERTITEL = 6203
export interface StreamInfo { export interface StreamInfo {
nr: number nr: number
typ: string typ: string
/** Typ-Code aus Attribut 1 (6201/6202/6203), 0 wenn keiner dastand. */
typCode: number
lang: string lang: string
sprache: string sprache: string
codec: string codec: string
kanaele: string kanaele: string
beschreibung: string beschreibung: string
/** Attribut 2: „Surround 5.1", „Stereo" (5.7.0). */
layout: string
/** Attribut 22: Flags. Bit 0x800 heißt abgeleitet" an der Kizuna-Disc
* gemessen (03.09.2026): 2304 (0x900) am DTS-Kern der DTS-HD-Spur, 6144
* (0x1800) an der nur erzwungene"-Fassung eines Untertitels, 1024 und 0
* an den vollen Spuren (5.7.0). */
flags: number
} }
/** Bit 0x800 in Attribut 22: eine von MakeMKV abgeleitete Spur. */
export const STREAM_FLAG_ABGELEITET = 0x800
export interface TitelStreams { export interface TitelStreams {
audio: StreamInfo[] audio: StreamInfo[]
untertitel: StreamInfo[] untertitel: StreamInfo[]
} }
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/ const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),(\d+),"(.*)"\s*$/
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> { export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const titel = new Map<number, Map<number, StreamInfo>>() const titel = new Map<number, Map<number, StreamInfo>>()
@@ -136,7 +182,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const tNr = Number(treffer[1]) const tNr = Number(treffer[1])
const sNr = Number(treffer[2]) const sNr = Number(treffer[2])
const attr = Number(treffer[3]) const attr = Number(treffer[3])
const wert = treffer[4] const code = Number(treffer[4])
const wert = treffer[5]
let streams = titel.get(tNr) let streams = titel.get(tNr)
if (streams === undefined) { if (streams === undefined) {
streams = new Map() streams = new Map()
@@ -144,14 +191,19 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
} }
let eintrag = streams.get(sNr) let eintrag = streams.get(sNr)
if (eintrag === undefined) { if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' } eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '', layout: '', flags: 0 }
streams.set(sNr, eintrag) streams.set(sNr, eintrag)
} }
if (attr === 1) eintrag.typ = wert if (attr === 1) {
eintrag.typ = wert
eintrag.typCode = code
}
else if (attr === 2) eintrag.layout = wert
else if (attr === 3) eintrag.lang = wert else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert else if (attr === 14) eintrag.kanaele = wert
else if (attr === 22 && /^\d+$/.test(wert)) eintrag.flags = Number(wert)
else if (attr === 30) eintrag.beschreibung = wert else if (attr === 30) eintrag.beschreibung = wert
} }
@@ -161,9 +213,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const untertitel: StreamInfo[] = [] const untertitel: StreamInfo[] = []
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) { for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
const s = streams.get(sNr)! const s = streams.get(sNr)!
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen. // Der Code entscheidet (5.6.1); das Wort nur als Notnagel für Ausgaben
if (s.typ === 'Audio') audio.push(s) // ohne Code („Audio", „Subtitles", „Untertitel").
else if (s.typ.startsWith('Subtitle')) untertitel.push(s) if (s.typCode === STREAM_CODE_AUDIO || (s.typCode === 0 && s.typ === 'Audio')) audio.push(s)
else if (s.typCode === STREAM_CODE_UNTERTITEL || (s.typCode === 0 && (s.typ.startsWith('Subtitle') || s.typ.startsWith('Untertitel')))) untertitel.push(s)
} }
ergebnis.set(tNr, { audio, untertitel }) ergebnis.set(tNr, { audio, untertitel })
} }
@@ -172,8 +225,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt // EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/. // die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
import type { SprachEintrag } from '../../gemeinsam/nachrichten' import type { SprachEintrag, SpurAngabe } from '../../gemeinsam/nachrichten'
export type { SprachEintrag } export type { SprachEintrag, SpurAngabe }
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt, /** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */ * Reihenfolge stabil (wie auf der Disc). */
@@ -199,6 +252,24 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') } return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
} }
/** Jede Spur EINES Titels als SpurAngabe (5.7.0, für den Auswahl-Dialog):
* Ton und Untertitel getrennt, in Disc-Reihenfolge, abgeleitete Spuren
* markiert. Ohne Streams leer. */
export function spurenFuerTitel(streams: TitelStreams | undefined): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } {
const angabe = (s: StreamInfo): SpurAngabe => ({
nr: s.nr,
lang: s.lang,
sprache: s.sprache.length > 0 ? s.sprache : s.lang,
codec: s.codec,
kanaele: s.kanaele,
layout: s.layout,
beschreibung: s.beschreibung,
abgeleitet: (s.flags & STREAM_FLAG_ABGELEITET) !== 0,
})
if (streams === undefined) return { audio: [], untertitel: [] }
return { audio: streams.audio.map(angabe), untertitel: streams.untertitel.map(angabe) }
}
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) dieselbe /** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams * Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */ * (Titel unbekannt) leer, nicht erfunden. */
+185
View File
@@ -0,0 +1,185 @@
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
// Nullen für das, was die Disc nicht mehr hergibt:
//
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
//
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
// nicht (Commander 01.09.2026).
//
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
import { open } from 'node:fs/promises'
import { writeFileSync } from 'node:fs'
export const BLOCK_BYTES = 1024 * 1024
export const FEIN_BYTES = 64 * 1024
export const SEKTOR_BYTES = 2048
export interface UnlesbareStelle {
offset: number
bytes: number
}
export type RettungsErgebnis =
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
* viele es wurden. Wirft bei einem Lesefehler. */
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
export interface RettungsOptionen {
leser: Leser
schreiber: Schreiber
erwarteteBytes: number
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
abbruch?: AbortSignal
/** Versuche je Stück — Vorgabe 2. */
versuche?: number
}
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
for (let i = 0; i < versuche; i++) {
try {
const n = await leser(puffer, laenge, position)
if (n === laenge) return true
} catch {
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
}
}
return false
}
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
const letzte = stellen[stellen.length - 1]
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
letzte.bytes += bytes
return
}
stellen.push({ offset, bytes })
}
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
const versuche = o.versuche ?? 2
const puffer = Buffer.alloc(BLOCK_BYTES)
const feinPuffer = Buffer.alloc(FEIN_BYTES)
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
const stellen: UnlesbareStelle[] = []
let unlesbareSektoren = 0
let gelesen = 0
let letzterProzent = -1
const melden = (): void => {
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
if (prozent !== letzterProzent) {
letzterProzent = prozent
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
}
}
if (o.erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
}
try {
while (gelesen < o.erwarteteBytes) {
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
const m = Math.min(FEIN_BYTES, n - teil)
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
feinPuffer.copy(puffer, teil, 0, m)
continue
}
for (let s = 0; s < m; s += SEKTOR_BYTES) {
const k = Math.min(SEKTOR_BYTES, m - s)
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
sektorPuffer.copy(puffer, teil + s, 0, k)
} else {
puffer.fill(0, teil + s, teil + s + k)
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
stelleHinzu(stellen, gelesen + teil + s, k)
}
}
}
}
await o.schreiber(puffer, n)
gelesen += n
melden()
}
} catch (fehler) {
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
}
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
}
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
const zeilen = [
`Rettungs-Abbild von ${quelle}`,
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
]
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
return zeilen.join('\n') + '\n'
}
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
export async function rettungsAbbildDatei(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
abbruch?: AbortSignal,
): Promise<RettungsErgebnis> {
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
const q = quelle
const z = ziel
let ergebnis: RettungsErgebnis
try {
ergebnis = await rettungsAbbild({
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
schreiber: async (puffer, laenge) => {
await z.write(puffer, 0, laenge)
},
erwarteteBytes,
aufFortschritt,
abbruch,
})
} finally {
await q.close()
await z.close()
}
try {
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
} catch {
// Der Bericht ist Beigabe — das Abbild zählt.
}
return ergebnis
}
+30 -2
View File
@@ -22,7 +22,8 @@
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in // 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
// `.neu`, geprüft, erst dann getauscht. // `.neu`, geprüft, erst dann getauscht.
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs' import { createHash } from 'node:crypto'
import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { win32 as pfad } from 'node:path' import { win32 as pfad } from 'node:path'
import { werkzeugOrdner } from './katalog' import { werkzeugOrdner } from './katalog'
@@ -285,6 +286,22 @@ export interface BeschaffungErgebnis {
pfad: string pfad: string
quelle: string quelle: string
version: string version: string
/** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei damit man sie
* gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */
bytes: number
sha256: string
}
/** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */
export function pruefsumme(dateiPfad: string): string {
return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex')
}
/** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener
* Basis. Das Internet Archive liefert eine unsignierte EXE, die mit
* Adminrechten läuft das entscheidet der Nutzer, nicht Rippy (F16). */
export function brauchtFreigabe(quellenName: string): boolean {
return quellenName === 'Internet Archive'
} }
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys /** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
@@ -297,6 +314,9 @@ export async function makemkvInstallerHolen(
holen: typeof fetch = fetch, holen: typeof fetch = fetch,
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'), ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
pruefen: typeof dateiPruefen = dateiPruefen, pruefen: typeof dateiPruefen = dateiPruefen,
/** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne
* diese Funktion wird sie übersprungen nie still geladen. */
freigabe?: (quellenName: string, url: string) => Promise<boolean>,
): Promise<BeschaffungErgebnis> { ): Promise<BeschaffungErgebnis> {
mkdirSync(ordner, { recursive: true }) mkdirSync(ordner, { recursive: true })
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`) const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
@@ -304,6 +324,14 @@ export async function makemkvInstallerHolen(
const fehlschlaege: string[] = [] const fehlschlaege: string[] = []
for (const [name, url] of makemkvQuellen(version, basis)) { for (const [name, url] of makemkvQuellen(version, basis)) {
if (brauchtFreigabe(name)) {
const erlaubt = freigabe !== undefined && (await freigabe(name, url))
if (!erlaubt) {
fehlschlaege.push(`${name}: nicht freigegeben`)
melden(`${name} wurde nicht freigegeben.`, null)
continue
}
}
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0) melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
try { try {
await dateiLaden(url, vorlaeufig, melden, holen) await dateiLaden(url, vorlaeufig, melden, holen)
@@ -312,7 +340,7 @@ export async function makemkvInstallerHolen(
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute // Erst tauschen, wenn geprüft — ein halber Download darf eine gute
// Datei nicht ersetzen. // Datei nicht ersetzen.
renameSync(vorlaeufig, ziel) renameSync(vorlaeufig, ziel)
return { pfad: ziel, quelle: name, version } return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) }
} catch (fehler) { } catch (fehler) {
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`) fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
melden(`${name} hat nicht geliefert.`, null) melden(`${name} hat nicht geliefert.`, null)
+141 -33
View File
@@ -3,20 +3,32 @@
// am Commander-PC gemessen): // am Commander-PC gemessen):
// //
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles. // 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053). // 2. Sonst: Ein Schlüssel, der schon im Register steht und angenommen
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf // wird, bleibt (5.7.0 — vorher überschrieb Rippy jeden vorhandenen
// Schlüssel mit dem Forum-Key, auch einen gekauften aus dem GUI).
// 3. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
// 4. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry. // ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/ // 5. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als // 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN. // keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV // Ohne angeschlossenes Laufwerk sagt makemkvcon NICHTS zur Lizenz
// (12.09.2026 gemessen: nur 5042/5010) — dann heißt das Urteil
// „nicht prüfbar", nicht „angenommen".
// 6. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen). // die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
import { spawn, spawnSync } from 'node:child_process' import { spawn, spawnSync } from 'node:child_process'
import { textVon } from '../rip/parser' import { textVon } from '../rip/parser'
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser' import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053' export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/ // 5.7.0 (Durchsicht 12.09.2026, F1): Der September-Key lautet
// „T-vwvn…6Ox4BI@N5rPp" — 68 Zeichen MIT einem @. Das alte Muster
// /T-[A-Za-z0-9_]{50,}/ endete am @ und lieferte 62 Zeichen; MakeMKV lehnte
// den Stumpf ab (5020), Rippy nahm ihn zurück, und MakeMKV lief ohne
// Schlüssel. Jetzt zählt alles bis zum nächsten Leerraum oder HTML-Zeichen —
// Rippy rät nicht, welche Zeichen der Hersteller in seine Schlüssel setzt.
export const KEY_MUSTER = /T-[^\s<>"'&;]{50,}/
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV' const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
const KEY_NAME = 'app_Key' const KEY_NAME = 'app_Key'
@@ -28,12 +40,15 @@ export function extractKey(html: string): string {
/** valid until/through <Datum>" aus dem Thread best effort, Rohtext. /** valid until/through <Datum>" aus dem Thread best effort, Rohtext.
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt * § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */ * aber makemkvcon selbst (MSG 5051 = abgelaufen). Der Thread schreibt
* mal September 30th, 2026", mal „end of September 2026" (so am
* 12.09.2026 gemessen) beide Formen kommen an. */
export function extractGueltigBis(html: string): string { export function extractGueltigBis(html: string): string {
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec( const treffer =
/valid\s+(?:until|through)\s+((?:(?:the\s+)?end\s+of\s+|(?:the\s+)?beginning\s+of\s+|mid-?\s*)?[A-Za-zäöüÄÖÜ]+(?:\s+\d{1,2}(?:st|nd|rd|th)?,?)?\s+\d{4})/i.exec(
html ?? '', html ?? '',
) )
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1') return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1').replace(/\s+/g, ' ')
} }
export interface ForumKey { export interface ForumKey {
@@ -79,6 +94,19 @@ export function keyZuruecknehmen(): boolean {
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
} }
/** Den Wert aus einer `reg query`-Ausgabe lesen (pur, testbar):
* app_Key REG_SZ T-" '' wenn keiner dasteht. */
export function keyAusQuery(ausgabe: string): string {
const treffer = new RegExp(`${KEY_NAME}\\s+REG_SZ\\s+(\\S+)`).exec(ausgabe ?? '')
return treffer === null ? '' : treffer[1].trim()
}
/** Der Schlüssel, der gerade im Register steht — '' wenn keiner. */
export function keyLesen(): string {
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', KEY_NAME])
return vorhanden.ok ? keyAusQuery(vorhanden.ausgabe) : ''
}
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt wer den Web-Kontakt /** app_UpdateEnable=1 setzen, NUR wenn es fehlt wer den Web-Kontakt
* bewusst abgeschaltet hat, behält seine Entscheidung. */ * bewusst abgeschaltet hat, behält seine Entscheidung. */
export function updateKanalSicherstellen(): boolean { export function updateKanalSicherstellen(): boolean {
@@ -89,6 +117,9 @@ export function updateKanalSicherstellen(): boolean {
export interface KeyUrteil { export interface KeyUrteil {
ok: boolean ok: boolean
/** false: makemkvcon hat die Lizenz gar nicht angesehen es sah kein
* Laufwerk (5042). Dann ist `ok` keine Aussage über den Schlüssel. */
pruefbar: boolean
code: number | null code: number | null
grund: string grund: string
/** Die MakeMKV-Version aus MSG 1005 (MakeMKV v1.18.4 ") '' wenn /** Die MakeMKV-Version aus MSG 1005 (MakeMKV v1.18.4 ") '' wenn
@@ -98,7 +129,9 @@ export interface KeyUrteil {
/** Nimmt MakeMKV den hinterlegten Schlüssel an? es selbst gefragt: /** Nimmt MakeMKV den hinterlegten Schlüssel an? es selbst gefragt:
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung * `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026). * kommt trotzdem (makemkv_aufruf.py, 29.08.2026) ABER nur mit einem
* angeschlossenen Laufwerk (12.09.2026 ohne Laufwerk gemessen: 5042 und
* 5010, keine Lizenzzeile).
* *
* ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND * ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
* 5021 zusammen (30.08.2026 live gemessen) 5020 allein klingt nach * 5021 zusammen (30.08.2026 live gemessen) 5020 allein klingt nach
@@ -108,7 +141,7 @@ export interface KeyUrteil {
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> { export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
return new Promise((fertig) => { return new Promise((fertig) => {
if (programm.length === 0) { if (programm.length === 0) {
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' }) fertig({ ok: false, pruefbar: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
return return
} }
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], { const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
@@ -121,7 +154,7 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
const zeiger = setTimeout(() => kind.kill(), timeoutMs) const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => { kind.on('error', (fehler) => {
clearTimeout(zeiger) clearTimeout(zeiger)
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' }) fertig({ ok: false, pruefbar: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
}) })
kind.on('close', () => { kind.on('close', () => {
clearTimeout(zeiger) clearTimeout(zeiger)
@@ -130,10 +163,14 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
}) })
} }
/** MakeMKV sah kein Laufwerk — ohne eines prüft es die Lizenz nicht. */
export const KEIN_LAUFWERK = 5042
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */ /** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
export function urteilAusAusgabe(ausgabe: string): KeyUrteil { export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
const codes: number[] = [] const codes: number[] = []
let version = '' let version = ''
let keinLaufwerk = false
for (const zeile of ausgabe.split(/\r?\n/)) { for (const zeile of ausgabe.split(/\r?\n/)) {
if (!zeile.startsWith('MSG:')) continue if (!zeile.startsWith('MSG:')) continue
const code = Number(zeile.slice(4).split(',', 1)[0]) const code = Number(zeile.slice(4).split(',', 1)[0])
@@ -142,21 +179,29 @@ export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile) const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
if (treffer !== null) version = treffer[1] if (treffer !== null) version = treffer[1]
} }
if (code === KEIN_LAUFWERK) keinLaufwerk = true
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code) if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
} }
if (codes.length === 0) return { ok: true, code: null, grund: '', version } if (codes.length === 0) {
// Keine Lizenzzeile: entweder angenommen — oder gar nicht angesehen.
return keinLaufwerk
? { ok: true, pruefbar: false, code: KEIN_LAUFWERK, grund: 'MakeMKV sah kein Laufwerk und hat die Lizenz nicht geprüft', version }
: { ok: true, pruefbar: true, code: null, grund: '', version }
}
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020 // 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen. // ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
const code = codes.includes(5021) ? 5021 : codes[0] const code = codes.includes(5021) ? 5021 : codes[0]
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version } return { ok: false, pruefbar: true, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
} }
export interface SchluesselStand { export interface SchluesselStand {
/** 'dauerlizenz' | 'forum' | 'keiner' */ /** 'dauerlizenz' | 'vorhanden' | 'forum' | 'keiner' */
quelle: string quelle: string
/** true angenommen · false abgelehnt/fehlt · null nicht prüfbar (kein Laufwerk). */
angenommen: boolean | null angenommen: boolean | null
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja das ist eine /** Laufen Rips trotzdem? Ohne angenommenen Schlüssel: DVDs ja (laut
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */ * Hersteller dauerhaft frei), Blu-rays nur in MakeMKVs Gnadenfrist
* das ist ein AUSFALL für Blu-ray, keine Warnung (5.7.0). */
weiterBetrieb: boolean weiterBetrieb: boolean
gueltigBis: string gueltigBis: string
geholtAm: string geholtAm: string
@@ -165,19 +210,51 @@ export interface SchluesselStand {
version: string version: string
} }
/** Die ganze Kette: Dauerlizenz Forum-Key ablegen PRÜFEN /** Was ein abgelehnter oder fehlender Schlüssel bedeutet EIN Satz für
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */ * alle Wege, damit das Fenster nie Rips laufen" sagt, wo Blu-rays nicht
* laufen. */
export const OHNE_SCHLUESSEL_FOLGE =
'Ohne angenommenen Schlüssel liest MakeMKV DVDs weiter (dauerhaft frei), Blu-rays und 4K aber nur in seiner Gnadenfrist nach dem Testzeitraum — danach verweigert es sie.'
/** Die ganze Kette: Dauerlizenz vorhandener Schlüssel Forum-Key
* ablegen PRÜFEN bei Ablehnung zurücknehmen. Gibt den Stand fürs
* Dashboard zurück. */
export async function schluesselKette( export async function schluesselKette(
makemkvcon: string, makemkvcon: string,
dauerlizenz: string, dauerlizenz: string,
jetztIso: string, jetztIso: string,
holen: typeof fetch = fetch, holen: typeof fetch = fetch,
registerLesen: () => string = keyLesen,
): Promise<SchluesselStand> { ): Promise<SchluesselStand> {
updateKanalSicherstellen() updateKanalSicherstellen()
const versionsText = (urteil: KeyUrteil): string => (urteil.version.length > 0 ? ` ${urteil.version}` : '')
const nichtPruefbar = (quelle: string, gueltigBis: string, urteil: KeyUrteil, wer: string): SchluesselStand => ({
quelle,
angenommen: null,
weiterBetrieb: true,
gueltigBis,
geholtAm: jetztIso,
text: `${wer} hinterlegt — ob MakeMKV${versionsText(urteil)} ihn annimmt, lässt sich erst mit angeschlossenem Laufwerk prüfen (${urteil.grund}).`,
version: urteil.version,
})
if (dauerlizenz.trim().length > 0) { if (dauerlizenz.trim().length > 0) {
keyAblegen(dauerlizenz) // 5.7.0: Das Ergebnis von reg add zählt — „hinterlegt" stand vorher
// auch da, wenn nichts geschrieben wurde.
if (!keyAblegen(dauerlizenz)) {
return {
quelle: 'dauerlizenz',
angenommen: false,
weiterBetrieb: false,
gueltigBis: '',
geholtAm: jetztIso,
text: 'Eigener Schlüssel konnte nicht ins Register geschrieben werden (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
version: '',
}
}
const urteil = await keyPruefen(makemkvcon) const urteil = await keyPruefen(makemkvcon)
if (!urteil.pruefbar) return nichtPruefbar('dauerlizenz', '', urteil, 'Eigener Schlüssel')
return { return {
quelle: 'dauerlizenz', quelle: 'dauerlizenz',
angenommen: urteil.ok, angenommen: urteil.ok,
@@ -185,54 +262,85 @@ export async function schluesselKette(
gueltigBis: '', gueltigBis: '',
geholtAm: jetztIso, geholtAm: jetztIso,
text: urteil.ok text: urteil.ok
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.` ? `Eigener Schlüssel hinterlegt und von MakeMKV${versionsText(urteil)} angenommen.`
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`, : `Eigener Schlüssel abgelehnt: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
version: urteil.version, version: urteil.version,
} }
} }
// 5.7.0: Ein Schlüssel, der schon steht und angenommen wird, bleibt —
// egal ob gekauft (im GUI eingetragen) oder ein älterer Forum-Key.
const vorhanden = registerLesen()
if (vorhanden.length > 0) {
const urteil = await keyPruefen(makemkvcon)
if (!urteil.pruefbar) return nichtPruefbar('vorhanden', '', urteil, 'Ein Schlüssel ist')
if (urteil.ok) {
return {
quelle: 'vorhanden',
angenommen: true,
weiterBetrieb: true,
gueltigBis: '',
geholtAm: jetztIso,
text: `Der hinterlegte Schlüssel wird von MakeMKV${versionsText(urteil)} angenommen — Rippy lässt ihn stehen.`,
version: urteil.version,
}
}
// Abgelehnt: dann darf der Forum-Key ihn ersetzen (unten).
}
const forum = await betaKeyHolen(holen) const forum = await betaKeyHolen(holen)
if (forum === null) { if (forum === null) {
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und // Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
// wird geprüft, damit der Stand ehrlich ist. // wird geprüft, damit der Stand ehrlich ist.
const urteil = await keyPruefen(makemkvcon) const urteil = await keyPruefen(makemkvcon)
if (!urteil.pruefbar) return nichtPruefbar('keiner', '', urteil, 'Forum nicht erreichbar; der vorhandene Stand ist')
return { return {
quelle: 'keiner', quelle: 'keiner',
angenommen: urteil.ok, angenommen: urteil.ok,
weiterBetrieb: true, weiterBetrieb: urteil.ok,
gueltigBis: '', gueltigBis: '',
geholtAm: jetztIso, geholtAm: jetztIso,
text: urteil.ok text: urteil.ok
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.' ? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`, : `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
version: urteil.version, version: urteil.version,
} }
} }
keyAblegen(forum.key) if (!keyAblegen(forum.key)) {
return {
quelle: 'forum',
angenommen: false,
weiterBetrieb: false,
gueltigBis: forum.gueltigBis,
geholtAm: jetztIso,
text: 'Beta-Key geholt, aber nicht ins Register schreibbar (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
version: '',
}
}
const urteil = await keyPruefen(makemkvcon) const urteil = await keyPruefen(makemkvcon)
if (!urteil.pruefbar) return nichtPruefbar('forum', forum.gueltigBis, urteil, 'Beta-Key aus dem Forum')
if (!urteil.ok) { if (!urteil.ok) {
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen. // Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV // (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
// alles; OHNE läuft es im Beta-Modus weiter und rippt.) // alles; OHNE läuft es im Beta-Modus weiter und rippt — DVDs immer,
// Blu-rays nur bis zum Ende des Testzeitraums.)
keyZuruecknehmen() keyZuruecknehmen()
} }
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
return { return {
quelle: 'forum', quelle: 'forum',
angenommen: urteil.ok, angenommen: urteil.ok,
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen — weiterBetrieb: urteil.ok,
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
weiterBetrieb: true,
gueltigBis: forum.gueltigBis, gueltigBis: forum.gueltigBis,
geholtAm: jetztIso, geholtAm: jetztIso,
text: urteil.ok text: urteil.ok
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.` ? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText(urteil)} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
: urteil.code === 5021 : urteil.code === 5021
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` + ? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText(urteil)} ist ZU ALT für ihn. ` +
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' + 'Rippy hat ihn wieder entfernt. ' +
OHNE_SCHLUESSEL_FOLGE +
' Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.' ' Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`, : `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. ${OHNE_SCHLUESSEL_FOLGE}`,
version: urteil.version, version: urteil.version,
} }
} }
@@ -22,12 +22,20 @@
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts). // eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest // Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg). // dort, die settings.conf ist der Linux-Weg).
//
// 5.7.0: Seit der Dialog jede Spur einzeln anbietet, dürfen auch die
// Stereo-Doppel bleiben (havemulti — „Deutsch · DD · 2.0 (Kommentar)"
// neben „Deutsch · DTS-HD MA · 5.1"): Nur die abgeleiteten DTS-Kerne
// (havecore) fallen weg, die zeigt der Dialog gar nicht erst. Stand
// Rippys ALTE Regel in der Registry, wird sie ersetzt; eine fremde bleibt.
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV' export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString' export const REGEL_NAME = 'app_DefaultSelectionString'
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */ /** Alles behalten, nur die Kerne weg — sprachunabhängig (5.7.0). */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all' export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:havecore,=100:all'
/** Rippys frühere Regeln (5.4.05.6.1) — werden beim Start ersetzt. */
export const RIPPY_REGELN_ALT: readonly string[] = ['-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all']
export interface SpurauswahlStand { export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) | /** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
@@ -37,6 +45,16 @@ export interface SpurauswahlStand {
text: string text: string
} }
/** Was mit einer vorgefundenen Regel geschieht (pur, testbar):
* 'rippy' = steht schon · 'ersetzen' = Rippys alte Regel · 'eigen' =
* fremd, bleibt · 'setzen' = keine da. */
export function regelUrteil(bisher: string | null): 'rippy' | 'ersetzen' | 'eigen' | 'setzen' {
if (bisher === null || bisher.length === 0) return 'setzen'
if (bisher === RIPPY_REGEL) return 'rippy'
if (RIPPY_REGELN_ALT.includes(bisher)) return 'ersetzen'
return 'eigen'
}
function reg(args: string[]): { ok: boolean; ausgabe: string } { function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' }) const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') } return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
@@ -56,14 +74,15 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
} }
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME]) const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
if (bisher !== null && bisher.length > 0) { const urteil = regelUrteil(bisher)
if (bisher === RIPPY_REGEL) { if (urteil === 'rippy') {
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' } return { stand: 'rippy', regel: bisher ?? '', text: 'MakeMKV behält alle Spuren (Rippys Auswahlregel steht).' }
} }
if (urteil === 'eigen') {
return { return {
stand: 'eigen', stand: 'eigen',
regel: bisher, regel: bisher ?? '',
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`, text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Spuren in der Roh-Datei, liegt es daran.`,
} }
} }
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f']) const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
@@ -73,6 +92,9 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
return { return {
stand: 'gesetzt', stand: 'gesetzt',
regel: RIPPY_REGEL, regel: RIPPY_REGEL,
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).', text:
urteil === 'ersetzen'
? 'MakeMKV-Auswahlregel erneuert: jede Spur kommt in die Roh-Datei, auch Stereo-Fassungen und Kommentare (nur DTS-Kerne nicht).'
: 'MakeMKV-Auswahlregel gesetzt: jede Spur kommt in die Roh-Datei (nur DTS-Kerne nicht).',
} }
} }
+25
View File
@@ -172,3 +172,28 @@ describe('Encoder- und Preset-Listen', () => {
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K']) expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
}) })
}) })
// 5.7.0 (Durchsicht F3): Nur die Dateien DIESER Disc werden benannt — der
// Season-Ordner darf voll sein.
describe('episodenUmbenennen mit nurDiese (5.7.0)', () => {
it('Bestand im Ordner stört nicht', () => {
const umbenannt: Array<[string, string]> = []
const meldungen = episodenUmbenennen(
'S',
'Akte X',
1,
[3, 4],
{
listen: () => ['Akte X S01E01.mkv', 'Akte X S01E02.mkv', 'title_t00.mkv', 'title_t01.mkv'],
existiert: () => false,
umbenennen: (von, nach) => umbenannt.push([von, nach]),
},
['title_t00.mkv', 'title_t01.mkv'],
)
expect(umbenannt).toEqual([
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
])
expect(meldungen).toHaveLength(2)
})
})
+190 -6
View File
@@ -4,7 +4,7 @@
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler // gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je // lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei. // Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -121,7 +121,7 @@ describe('komprimierenUndAblegen', () => {
expect(ergebnis.komprimiert).toBe(true) expect(ergebnis.komprimiert).toBe(true)
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)')) expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'title_t00.mkv')]) expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')])
expect(phasen).toContain('komprimiert') expect(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab') expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira']) expect(bibliothek).toEqual(['Akira'])
@@ -139,7 +139,7 @@ describe('komprimierenUndAblegen', () => {
expect(ergebnis.status).toBe('fertig') expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(false) expect(ergebnis.komprimiert).toBe(false)
expect(ergebnis.text).toContain('ohne Kompression') expect(ergebnis.text).toContain('ohne Kompression')
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([]) expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0) expect(hb.aufrufe).toHaveLength(0)
@@ -199,7 +199,7 @@ describe('komprimierenUndAblegen', () => {
const ergebnis = await komprimierenUndAblegen( const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }), auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { umgebung(ablage, {
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }), spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch', codec: 'dtshd', kanaele: 6 }], untertitel: [], dauerS: 3167 }),
}), }),
() => {}, () => {},
KEIN_ABBRUCH, KEIN_ABBRUCH,
@@ -269,8 +269,8 @@ describe('komprimierenUndAblegen', () => {
() => {}, () => {},
KEIN_ABBRUCH, KEIN_ABBRUCH,
) )
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.mkv'))).toBe(true)
}) })
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => { it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
@@ -299,3 +299,187 @@ describe('komprimierenUndAblegen', () => {
expect(prozente).toContain(87) expect(prozente).toContain(87)
}) })
}) })
describe('Wiederholen ersetzt die alte Fassung', () => {
it('Serie: die alten S01E03/E04 fallen erst nach dem Encode, dann greift die Umbenennung', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
const ziel = join(ablage, 'Serien', 'Spartacus', 'Season 01')
mkdirSync(ziel, { recursive: true })
const alt3 = join(ziel, 'Spartacus S01E03.mkv')
const alt4 = join(ziel, 'Spartacus S01E04.mkv')
writeFileSync(alt3, 'alt')
writeFileSync(alt4, 'alt')
const ergebnis = await komprimierenUndAblegen(
auftrag({
rohOrdner: roh,
dateien,
zuordnung: zuordnung('tv', 'Spartacus', 2010),
staffel: 1,
ersteFolge: 3,
zielOrdner: ziel,
ersetzt: [alt3, alt4],
}),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(readdirSync(ziel).filter((n) => n.endsWith('.mkv')).sort()).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
expect(readFileSync(alt3, 'utf8')).toBe('encoded')
expect(ergebnis.hinweise.join(' ')).toContain('Alte Fassung ersetzt')
expect(ergebnis.hinweise.join(' ')).not.toContain('existiert schon')
})
it('scheitert der zweite Lauf, bleibt die alte Fassung stehen', async () => {
hb.ergebnis = 'error'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ziel = join(ablage, 'Filme', 'Akira (1988)')
mkdirSync(ziel, { recursive: true })
const alt = join(ziel, 'title_t00.mkv')
writeFileSync(alt, 'alt')
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, ersetzt: [alt] }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fehler')
expect(readFileSync(alt, 'utf8')).toBe('alt')
})
})
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => {
// F3: Disc 2 derselben Staffel landet im selben Season-Ordner wie Disc 1.
// Vorher zählte die Umbenennung ALLE MKVs des Ordners („4 Dateien, 2
// Zuordnungen") und tat nichts — der Serien-Merker lief ins Leere.
it('F3: zweite Disc in denselben Season-Ordner — nur ihre Folgen werden benannt', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
mkdirSync(season, { recursive: true })
writeFileSync(join(season, 'Spartacus S01E01.mkv'), 'disc1')
writeFileSync(join(season, 'Spartacus S01E02.mkv'), 'disc1')
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3 }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(readdirSync(season).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S01E01.mkv',
'Spartacus S01E02.mkv',
'Spartacus S01E03.mkv',
'Spartacus S01E04.mkv',
])
expect(readFileSync(join(season, 'Spartacus S01E01.mkv'), 'utf8')).toBe('disc1')
expect(ergebnis.folgenVergeben).toEqual([3, 4])
})
// F3: Liegt im Season-Ordner noch ein title_t00.mkv der ersten Disc
// (deren Umbenennung ausfiel), darf die zweite es NICHT überschreiben.
it('F3: gleichnamige Datei im Ziel wird nie überschrieben — „(2)" statt Verlust', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'], ['folge'])
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
mkdirSync(season, { recursive: true })
writeFileSync(join(season, 'title_t00.mkv'), 'disc1-unbenannt')
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1 }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(readFileSync(join(season, 'title_t00.mkv'), 'utf8')).toBe('disc1-unbenannt')
expect(existsSync(join(season, 'title_t00 (2).mkv'))).toBe(true)
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv'])
})
})
// ── 5.7.0: Spuren einzeln ──────────────────────────────────────────────────
describe('Spuren einzeln (5.7.0)', () => {
const jpnHd = { nr: 1, lang: 'jpn', sprache: 'Japanese', codec: 'DTS-HD MA', kanaele: '6', layout: 'Surround 5.1', beschreibung: '', abgeleitet: false }
const deuKommentar = { nr: 4, lang: 'deu', sprache: 'German', codec: 'DD', kanaele: '2', layout: 'Stereo', beschreibung: 'DD Stereo German (Kommentar)', abgeleitet: false }
const deuPgs = { nr: 5, lang: 'deu', sprache: 'German', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS German', abgeleitet: false }
const engPgs = { nr: 7, lang: 'eng', sprache: 'English', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS English', abgeleitet: false }
const scan = {
audio: [
{ nr: 1, lang: 'jpn', beschreibung: '', codec: 'dtshd', kanaele: 6 },
{ nr: 2, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 6 },
{ nr: 3, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 2 },
],
untertitel: [
{ nr: 1, lang: 'deu', beschreibung: '', codec: 'PGS', kanaele: 0 },
{ nr: 2, lang: 'eng', beschreibung: '', codec: 'PGS', kanaele: 0 },
],
dauerS: 3167,
}
it('gewählte Spuren erreichen HandBrake als Nummern — japanischer Ton stellt die deutschen Untertitel vor und wählt sie vor', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
dateien[0].audioSprachen = ['jpn', 'deu']
dateien[0].untertitelSprachen = ['eng', 'deu']
dateien[0].audioSpuren = [jpnHd, deuKommentar]
dateien[0].untertitelSpuren = [engPgs, deuPgs]
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { spurenScannen: async () => scan }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(hb.aufrufe[0].audioNr).toEqual([1, 3])
expect(hb.aufrufe[0].untertitelNr).toEqual([1, 2])
expect(hb.aufrufe[0].untertitelStandardNr).toBe(1)
expect(ergebnis.hinweise.join(' ')).not.toContain('NICHT in der Roh-Datei')
})
it('eine gewählte Spur fehlt in der Roh-Datei → Hinweis, der Rest läuft', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
dateien[0].audioSpuren = [fra, deuKommentar]
dateien[0].untertitelSpuren = []
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { spurenScannen: async () => scan }),
() => {},
KEIN_ABBRUCH,
)
expect(hb.aufrufe[0].audioNr).toEqual([3])
expect(hb.aufrufe[0].untertitelNr).toEqual([])
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur French DTS-HD MA ist NICHT in der Roh-Datei')
})
it('Scan der Roh-Datei scheitert → Sprachen der gewählten Spuren als Liste (wie bisher)', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
dateien[0].audioSpuren = [jpnHd]
dateien[0].untertitelSpuren = [deuPgs]
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { spurenScannen: async () => null }),
() => {},
KEIN_ABBRUCH,
)
expect(hb.aufrufe[0].audioNr).toBeUndefined()
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu'])
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
expect(ergebnis.hinweise.join(' ')).toContain('ließen sich nicht messen')
})
it('keine gewählte Tonspur findbar → Sprachlisten statt stummer Datei', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
dateien[0].audioSpuren = [fra]
dateien[0].untertitelSpuren = [deuPgs]
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { spurenScannen: async () => scan }),
() => {},
KEIN_ABBRUCH,
)
expect(hb.aufrufe[0].audioNr).toBeUndefined()
expect(hb.aufrufe[0].audioSprachen).toEqual(['fra'])
expect(ergebnis.hinweise.join(' ')).toContain('keine der gewählten Tonspuren gefunden')
})
})
+15
View File
@@ -160,4 +160,19 @@ describe('Schlüsselkette (§ 9.1)', () => {
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026') expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
expect(extractGueltigBis('nichts')).toBe('') expect(extractGueltigBis('nichts')).toBe('')
}) })
// 5.7.0 (Durchsicht 12.09.2026, F1): So steht es WIRKLICH auf der Forum-
// Seite — der September-Key trägt ein @, und das Datum heißt „end of".
// Das alte Muster endete am @ und lieferte 62 von 68 Zeichen.
it('der echte September-2026-Key mit @ kommt ganz an', () => {
const key = 'T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp'
const html = `<div class="content">The current beta key is:<br><code>${key}</code><br>The key is valid until end of September 2026. Please check back for updated key on this page.</div>`
expect(extractKey(html)).toBe(key)
expect(extractKey(html)).toHaveLength(68)
expect(extractGueltigBis(html)).toBe('end of September 2026')
})
it('der Key endet am HTML-Zeichen, nicht mitten im Wort', () => {
const key = 'T-' + 'a'.repeat(66)
expect(extractKey(`<code>${key}</code><p>valid</p>`)).toBe(key)
expect(extractKey(`key: ${key} (valid until end of October 2026)`)).toBe(key)
})
}) })
+21
View File
@@ -99,6 +99,7 @@ describe('Auftrag als JSON — und wieder zurück', () => {
begonnen: '2026-08-30T12:00:00.000Z', begonnen: '2026-08-30T12:00:00.000Z',
preset: '', preset: '',
zielOrdner: '', zielOrdner: '',
ersetzt: [],
} }
it('kommt unverändert zurück', () => { it('kommt unverändert zurück', () => {
expect(auftragAusJson(auftragZuJson(a))).toEqual(a) expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
@@ -116,3 +117,23 @@ describe('Auftrag als JSON — und wieder zurück', () => {
expect(b.preset).toBe('') expect(b.preset).toBe('')
}) })
}) })
describe('sprachwahl — Untertitel-Vorwahl je Extra (5.6.0, immer optional)', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('eine gesetzte Vorwahl schlägt die Regel: nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['deu'], untertitelSprachen: ['eng', 'deu'], untertitelVorwahl: 'deu' }, global)).toEqual({
audio: ['deu'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('null heißt ausdrücklich keine Vorwahl — auch bei fremdem Ton', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'], untertitelVorwahl: null }, global).untertitelStandard).toBe(false)
})
it('fehlend heißt Regel wie bisher', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(true)
})
it('eine Vorwahl, die nicht in der Liste steht, wird ergänzt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'], untertitelVorwahl: 'deu' }, global).untertitel).toEqual(['deu', 'eng'])
})
})
+215
View File
@@ -0,0 +1,215 @@
// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag
// als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf
// mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur
// sagen, warum; Stopp und Jetzt.
import { describe, expect, it } from 'vitest'
import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik'
import type { KernKontext } from '../src/kern/kontext'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] }, spuren: { audio: [], untertitel: [] } }
}
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler }
}
function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo {
return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' }
}
function zuordnung(confidence: number): Zuordnung {
return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never }
}
describe('auftragAusVorschlag', () => {
it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => {
const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')]))
expect(auftrag).toEqual<RipAuftragTitel[]>([
{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 },
{ nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 },
])
})
})
describe('optionenAus', () => {
it('ohne alles: nichts vorgeben', () => {
expect(optionenAus(null, null)).toEqual<RipAuftragOptionen>({ staffel: null, ersteFolge: null })
})
it('die Staffel der Disc schlägt den Merker', () => {
expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 })
})
it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => {
expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null })
})
it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => {
expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 })
expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 })
})
})
function aufbau(sekunden = 3) {
const nachrichten: AutomatikStand[] = []
const hinweise: string[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => {
if (n.art === 'automatik') nachrichten.push(n.stand)
},
anHauptUndFenster: () => {},
hinweis: (t: string) => hinweise.push(t),
fehler: () => {},
protokoll: { schreiben: () => {} } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = []
const benachrichtigt: string[] = []
let tick: (() => void) | null = null
let gestoppt = 0
let an = true
let ripLaeuft = false
let titelStand: TitelStand = stand([titel(0, true), titel(1, false)])
let angaben: { staffel: number | null; discNr: number | null } | null = null
let merker: { staffel: number; naechsteFolge: number } | null = null
const abh: AutomatikAbhaengigkeiten = {
an: () => an,
sekunden: () => sekunden,
ripLaeuft: () => ripLaeuft,
titelLesen: async () => titelStand,
ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }),
discAngaben: () => angaben,
serienMerker: () => merker,
benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`),
takt: (fn) => {
tick = fn
return 1 as unknown as NodeJS.Timeout
},
taktStoppen: () => {
gestoppt += 1
tick = null
},
}
const dienst = new AutomatikDienst(kontext, abh)
return {
dienst,
nachrichten,
hinweise,
gestartet,
benachrichtigt,
tick: () => {
if (tick === null) throw new Error('kein Takt')
tick()
},
gestopptZahl: () => gestoppt,
setAn: (w: boolean) => (an = w),
setRipLaeuft: (w: boolean) => (ripLaeuft = w),
setTitel: (s: TitelStand) => (titelStand = s),
setAngaben: (a: typeof angaben) => (angaben = a),
setMerker: (m: typeof merker) => (merker = m),
}
}
describe('AutomatikDienst', () => {
it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => {
const a = aufbau(3)
await a.dienst.pruefen(geraet(), zuordnung(0.95), null)
expect(a.nachrichten[0].text).toContain('liest die Titel')
expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 })
a.tick()
a.tick()
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 })
expect(a.gestartet).toHaveLength(0)
a.tick()
expect(a.gestartet).toHaveLength(1)
expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }])
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' })
expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet')
expect(a.gestopptZahl()).toBe(1)
})
it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null)
expect(a.nachrichten).toHaveLength(1)
expect(a.nachrichten[0]).toMatchObject({ aktiv: false })
expect(a.nachrichten[0].text).toContain('nicht sicher genug')
expect(a.gestartet).toHaveLength(0)
})
it('schon gerippt: wartet und nennt das Datum', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z')
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
expect(a.gestartet).toHaveLength(0)
})
it('aus oder laufender Rip: gar nichts — CD und unbekannte Disc sagen, warum (5.7.0, F28)', async () => {
const a = aufbau()
a.setAn(false)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.setRipLaeuft(true)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(0)
a.setRipLaeuft(false)
a.setAn(true)
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(2)
expect(a.nachrichten[0].text).toContain('Audio-CD')
expect(a.nachrichten[1].text).toContain('unbekannt')
expect(a.gestartet).toHaveLength(0)
})
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
const a = aufbau()
a.setTitel(stand([titel(0, true), titel(1, false)], true))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen')
expect(a.gestartet).toHaveLength(0)
})
it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => {
const a = aufbau(30)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' })
expect(() => a.tick()).toThrow('kein Takt')
// Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand.
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(0)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)]))
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(1)
})
it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => {
const a = aufbau(1)
a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')]))
a.setAngaben({ staffel: null, discNr: 2 })
a.setMerker({ staffel: 1, naechsteFolge: 3 })
a.dienst.zuordnungMerken('G', zuordnung(0.99))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.tick()
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
})
it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.leeren('G')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' })
// Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts.
const b = aufbau(5)
b.dienst.leeren('G')
expect(b.nachrichten).toHaveLength(0)
})
it('Disc raus: Countdown weg, Stand weg', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.dienst.stand('G')).not.toBeNull()
a.dienst.discEntnommen('G')
expect(a.dienst.stand('G')).toBeNull()
expect(a.gestopptZahl()).toBe(1)
})
})
+112
View File
@@ -0,0 +1,112 @@
// Tests der Benachrichtigung (kern/dienste/benachrichtigung.ts, 5.5.0):
// Was an Discord und Telegram geht (Form laut deren Doku), welche Kanäle
// eingerichtet sind, und dass ein Fehlschlag gesagt wird, aber nichts
// kippt.
import { describe, expect, it } from 'vitest'
import type { KernNachricht } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { BenachrichtigungsDienst, discordAnfrage, kanaeleAus, kanaeleEingerichtet, telegramAnfrage } from '../src/kern/dienste/benachrichtigung'
import type { EinstellungsDienst } from '../src/kern/dienste/einstellungen'
import type { KernKontext } from '../src/kern/kontext'
function fakeKontext() {
const fehler: string[] = []
const hinweise: string[] = []
const zeilen: string[] = []
const nachrichten: KernNachricht[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => nachrichten.push(n),
anHauptUndFenster: (n: KernNachricht) => nachrichten.push(n),
hinweis: (t: string) => hinweise.push(t),
fehler: (t: string) => fehler.push(t),
protokoll: { schreiben: (t: string) => zeilen.push(t) } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
return { kontext, fehler, hinweise, zeilen, nachrichten }
}
function fakeEinstellungen(werte: Record<string, string>): EinstellungsDienst {
return { lesen: (s: string, vorgabe = '') => werte[s] ?? vorgabe } as unknown as EinstellungsDienst
}
describe('discordAnfrage / telegramAnfrage', () => {
it('Discord: POST an den Webhook mit { content, username } — Titel fett, dann Text', () => {
const a = discordAnfrage('https://discord.com/api/webhooks/1/abc', 'Rip fertig', 'Akira (1988)')
expect(a.url).toBe('https://discord.com/api/webhooks/1/abc')
const body = JSON.parse(a.body) as { content: string; username: string }
expect(body.content).toBe('**Rip fertig**\nAkira (1988)')
expect(body.username).toBe('Rippy')
})
it('Discord: content bleibt unter 2000 Zeichen', () => {
const a = discordAnfrage('https://x', 'T', 'x'.repeat(5000))
expect((JSON.parse(a.body) as { content: string }).content.length).toBeLessThanOrEqual(2000)
})
it('Telegram: bot<TOKEN>/sendMessage mit { chat_id, text }', () => {
const a = telegramAnfrage('123:ABC', '42', 'Rip fertig', 'Akira')
expect(a.url).toBe('https://api.telegram.org/bot123:ABC/sendMessage')
expect(JSON.parse(a.body)).toEqual({ chat_id: '42', text: 'Rip fertig\nAkira' })
})
})
describe('kanaeleAus / kanaeleEingerichtet', () => {
it('trimmt und erkennt: Discord allein reicht, Telegram nur mit Token UND Chat-ID', () => {
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'discordWebhook' ? ' https://d ' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : '')))).toBe(false)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : s === 'telegramChatId' ? '1' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus(() => ''))).toBe(false)
})
})
describe('BenachrichtigungsDienst', () => {
it('schickt über jeden eingerichteten Kanal und schreibt ins Protokoll', async () => {
const { kontext, fehler, zeilen } = fakeKontext()
const aufrufe: Array<{ url: string; body: string }> = []
const holen = (async (url: string | URL | Request, init?: RequestInit) => {
aufrufe.push({ url: String(url), body: String(init?.body) })
return { status: 204 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d', telegramToken: 't', telegramChatId: '9' }), holen)
expect(await d.senden('Titel', 'Text')).toEqual([])
expect(aufrufe.map((a) => a.url)).toEqual(['https://d', 'https://api.telegram.org/bott/sendMessage'])
expect(fehler).toEqual([])
expect(zeilen[0]).toContain('Benachrichtigt (Discord, Telegram): Titel')
})
it('ein HTTP-Fehler wird gesagt (kern-fehler), aber nicht geworfen', async () => {
const { kontext, fehler } = fakeKontext()
const holen = (async () => ({ status: 404 })) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: HTTP 404'])
expect(fehler).toEqual(['Benachrichtigung Discord: HTTP 404'])
})
it('ein Netzfehler ebenso', async () => {
const { kontext } = fakeKontext()
const holen = (async () => {
throw new Error('fetch failed')
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: fetch failed'])
})
it('ausgeschaltet: nichts geht raus — außer erzwungen (Testnachricht)', async () => {
const { kontext } = fakeKontext()
let aufrufe = 0
const holen = (async () => {
aufrufe += 1
return { status: 200 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ benachrichtigungen: 'aus', discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual([])
expect(aufrufe).toBe(0)
expect(await d.senden('T', 'x', true)).toEqual([])
expect(aufrufe).toBe(1)
})
it('testen ohne Kanal: sagt, was fehlt; mit Kanal: „Testnachricht verschickt."', async () => {
const ohne = fakeKontext()
await new BenachrichtigungsDienst(ohne.kontext, fakeEinstellungen({}), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(ohne.fehler[0]).toContain('kein Kanal eingerichtet')
const mit = fakeKontext()
await new BenachrichtigungsDienst(mit.kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(mit.hinweise).toEqual(['Testnachricht verschickt.'])
})
})
+24 -2
View File
@@ -136,10 +136,11 @@ describe('dateiPruefen', () => {
describe('makemkvInstallerHolen', () => { describe('makemkvInstallerHolen', () => {
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)]) const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => { it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt (mit Freigabe)', async () => {
const ordner = tempOrdner() const ordner = tempOrdner()
const holen = (async (url: string | URL | Request) => const holen = (async (url: string | URL | Request) =>
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
const gefragt: string[] = []
const ergebnis = await makemkvInstallerHolen( const ergebnis = await makemkvInstallerHolen(
'1.18.4', '1.18.4',
() => {}, () => {},
@@ -147,16 +148,35 @@ describe('makemkvInstallerHolen', () => {
holen, holen,
ordner, ordner,
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'), (p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
async (quelle) => {
gefragt.push(quelle)
return true
},
) )
expect(gefragt).toEqual(['Internet Archive'])
expect(ergebnis.quelle).toBe('Internet Archive') expect(ergebnis.quelle).toBe('Internet Archive')
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length) expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
expect(ergebnis.bytes).toBe(GROSS.length)
expect(ergebnis.sha256).toMatch(/^[0-9a-f]{64}$/)
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false) expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
}) })
// 5.7.0 (Durchsicht F16): Ohne Freigabe wird das Archiv NIE benutzt — es
// liefert eine unsignierte EXE, die mit Adminrechten läuft.
it('ohne Freigabe bleibt das Archiv aus — und der Bericht sagt es', async () => {
const ordner = tempOrdner()
const holen = (async (url: string | URL | Request) =>
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(/Internet Archive: nicht freigegeben/)
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => false)).rejects.toThrow(
/Internet Archive: nicht freigegeben/,
)
})
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => { it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
const ordner = tempOrdner() const ordner = tempOrdner()
const holen = (async () => antwort('weg', 404)) as typeof fetch const holen = (async () => antwort('weg', 404)) as typeof fetch
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow( await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => true)).rejects.toThrow(
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/, /Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
) )
}) })
@@ -166,6 +186,8 @@ describe('Nachrichten-Schema der Update-Strecke', () => {
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => { it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true) expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true) expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
expect(istFensterNachricht({ art: 'beschaffung-antwort', ja: true })).toBe(true)
expect(istFensterNachricht({ art: 'beschaffung-antwort' })).toBe(false)
}) })
it('Haupt → Kern: installer-ergebnis', () => { it('Haupt → Kern: installer-ergebnis', () => {
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true) expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)

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