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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

261 Tests gruen, Typpruefung sauber. Version 5.3.2.

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

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

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

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

Auf drei Ebenen behoben:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

237 Tests gruen, Typpruefung sauber. Version 5.1.5.

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

Notiert: Der Sprung AUF 5.1.4 ist einmalig Handarbeit.

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

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

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

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

Ursache, auf drei Beinen belegt:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

230 Tests gruen, Typpruefung sauber. Version 5.1.3.

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

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

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

Was jetzt da ist:

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

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

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

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

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

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

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

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

Vier Ursachen, alle behoben:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

192 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

181 Tests gruen, Smoke gruen.

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

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

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

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

165 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

96 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:38:39 +02:00
234 changed files with 44808 additions and 100 deletions
+11
View File
@@ -52,6 +52,17 @@ jobs:
if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt =="
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
if [ "$node_major" -lt 22 ]; then
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
rot=1
fi
while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
d=$(dirname "$pkg")
+3
View File
@@ -55,3 +55,6 @@ logs/
*.db
*.sqlite
dist/
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
design-entwuerfe/
+1131
View File
File diff suppressed because it is too large Load Diff
+1929 -41
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
überhaupt vorhanden?
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
```
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
Kern beendet mit 0
```
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
Messung zuvor ebenfalls grün.)
Nachstellen:
```
npm install electron@44 --no-save
node node_modules/electron/install.js
./node_modules/electron/dist/electron.exe sqlite-main.js
```
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
---
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
Tages jemand an der falschen Stelle.
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

+109
View File
@@ -0,0 +1,109 @@
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
// NUR LESEND — kein Auswerfen, kein Verriegeln.
const koffi = require('koffi');
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
const ctl = (typ, fn, methode, zugriff) =>
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
const kernel32 = koffi.load('kernel32.dll');
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
const DRIVE_CDROM = 5;
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
const maske = GetLogicalDrives();
const optische = [];
for (let i = 0; i < 26; i++) {
if (!(maske & (1 << i))) continue;
const buchstabe = String.fromCharCode(65 + i);
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
}
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
const lw = optische[0];
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
function oeffnen(zugriff) {
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
const adr = koffi.address(h);
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
return { h, fehler: 0 };
}
const ohneMedium = oeffnen(0);
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
const mitLesen = oeffnen(GENERIC_READ);
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
const h = ohneMedium.h;
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
const rueck = Buffer.alloc(4);
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
const anfrage = Buffer.alloc(12);
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
const antwort = Buffer.alloc(1024);
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
const text = (off) => {
const start = antwort.readUInt32LE(off);
if (!start || start >= antwort.length) return '';
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
return antwort.toString('latin1', start, ende).trim();
};
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
} else {
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
}
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
// ── 5. Audio- oder Datenspur?
const typBuf = Buffer.alloc(1);
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
const t = typBuf[0];
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
} else {
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
}
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
const laenge = Buffer.alloc(8);
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
const bytes = laenge.readBigUInt64LE(0);
const gb = Number(bytes) / 1e9;
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
} else {
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
}
CloseHandle(h);
if (mitLesen.h) CloseHandle(mitLesen.h);
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
+114
View File
@@ -0,0 +1,114 @@
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
//
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
// electron-updater ihn per spawn(…, { detached: true }) startet und
// `detached` unter Windows KEIN Breakaway setzt.
//
// Aufbau (drei Läufe, der äußere ruft die inneren):
// node beweise/leine-breakaway.js
// -> startet zweimal sich selbst als „innen":
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
// welcher Enkel noch lebt.
//
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
//
// Ausführen: node beweise/leine-breakaway.js
const { execFileSync, spawnSync } = require('node:child_process')
const path = require('node:path')
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
const KILL_ON_JOB_CLOSE = 0x2000
const BREAKAWAY_OK = 0x0800
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
const CREATE_NO_WINDOW = 0x08000000
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
)
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
)
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
const job = CreateJobObjectW(null, null)
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
AssignProcessToJobObject(job, GetCurrentProcess())
const si = Buffer.alloc(STARTUPINFO_GROESSE)
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
if (!ok) {
console.log(`PID=0 FEHLER=${GetLastError()}`)
return
}
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
}
function lebt(pid) {
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
return (ergebnis.stdout || '').includes(String(pid))
}
function aussen() {
const faelle = [
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
]
for (const [name, modus, erwartetLebend] of faelle) {
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
// zugeschnappt, falls sie zuschnappt.
const nochDa = pid > 0 ? lebt(pid) : false
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
console.log(
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
)
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
}
}
const modus = process.argv[2] ?? ''
if (modus === 'alt') innen(false, false)
else if (modus === 'erlaubt-normal') innen(true, false)
else if (modus === 'erlaubt-aus') innen(true, true)
else aussen()
+50
View File
@@ -0,0 +1,50 @@
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
// makemkvcon Rippy und haelt das Laufwerk fest.
const koffi = require('koffi');
const { spawn } = require('child_process');
const kernel32 = koffi.load('kernel32.dll');
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
// JobObjectExtendedLimitInformation = 9 (winnt.h)
const JobObjectExtendedLimitInformation = 9;
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
const job = CreateJobObjectW(null, null);
if (koffi.address(job) === 0n) {
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
const info = Buffer.alloc(144);
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
console.log('ELTERN-PID=' + process.pid);
console.log('KIND-PID=' + kind.pid);
// Offen halten, bis uns jemand hart abschiesst.
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
@@ -0,0 +1,375 @@
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
DRV:1,256,999,0,"","",""
DRV:2,256,999,0,"","",""
DRV:3,256,999,0,"","",""
DRV:4,256,999,0,"","",""
DRV:5,256,999,0,"","",""
DRV:6,256,999,0,"","",""
DRV:7,256,999,0,"","",""
DRV:8,256,999,0,"","",""
DRV:9,256,999,0,"","",""
DRV:10,256,999,0,"","",""
DRV:11,256,999,0,"","",""
DRV:12,256,999,0,"","",""
DRV:13,256,999,0,"","",""
DRV:14,256,999,0,"","",""
DRV:15,256,999,0,"","",""
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
TCOUNT:2
CINFO:1,6209,"Blu-ray-Disk"
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
CINFO:28,0,"eng"
CINFO:29,0,"English"
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
CINFO:33,0,"0"
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
TINFO:0,8,0,"6"
TINFO:0,9,0,"0:08:51"
TINFO:0,10,0,"1.1 GB"
TINFO:0,11,0,"1219086336"
TINFO:0,16,0,"00011.mpls"
TINFO:0,25,0,"6"
TINFO:0,26,0,"9,10,11,12,13,14"
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
TINFO:0,28,0,"eng"
TINFO:0,29,0,"English"
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
TINFO:0,33,0,"0"
SINFO:0,0,1,6201,"Video"
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
SINFO:0,0,6,0,"Mpeg4"
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
SINFO:0,0,19,0,"1920x1080"
SINFO:0,0,20,0,"16:9"
SINFO:0,0,21,0,"23.976 (24000/1001)"
SINFO:0,0,22,0,"0"
SINFO:0,0,28,0,"eng"
SINFO:0,0,29,0,"English"
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,0,33,0,"0"
SINFO:0,0,38,0,""
SINFO:0,0,42,5088,"( Lossless conversion )"
SINFO:0,1,1,6202,"Audio"
SINFO:0,1,2,5091,"Stereo"
SINFO:0,1,3,0,"deu"
SINFO:0,1,4,0,"German"
SINFO:0,1,5,0,"A_DTS"
SINFO:0,1,6,0,"DTS-HD MA"
SINFO:0,1,7,0,"DTS-HD Master Audio"
SINFO:0,1,14,0,"2"
SINFO:0,1,17,0,"48000"
SINFO:0,1,18,0,"16"
SINFO:0,1,22,0,"1024"
SINFO:0,1,28,0,"eng"
SINFO:0,1,29,0,"English"
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,1,33,0,"100"
SINFO:0,1,38,0,""
SINFO:0,1,40,0,"stereo"
SINFO:0,1,42,5088,"( Lossless conversion )"
SINFO:0,2,1,6202,"Audio"
SINFO:0,2,2,5091,"Stereo"
SINFO:0,2,3,0,"deu"
SINFO:0,2,4,0,"German"
SINFO:0,2,5,0,"A_DTS"
SINFO:0,2,6,0,"DTS"
SINFO:0,2,7,0,"DTS"
SINFO:0,2,13,0,"768 Kb/s"
SINFO:0,2,14,0,"2"
SINFO:0,2,17,0,"48000"
SINFO:0,2,22,0,"2304"
SINFO:0,2,28,0,"eng"
SINFO:0,2,29,0,"English"
SINFO:0,2,30,0,"DTS Stereo German"
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,2,33,0,"100"
SINFO:0,2,38,0,"d"
SINFO:0,2,39,0,"Default"
SINFO:0,2,40,0,"stereo"
SINFO:0,2,42,5088,"( Lossless conversion )"
SINFO:0,3,1,6202,"Audio"
SINFO:0,3,2,5091,"Stereo"
SINFO:0,3,3,0,"deu"
SINFO:0,3,4,0,"German"
SINFO:0,3,5,0,"A_DTS"
SINFO:0,3,6,0,"DTS-HD MA"
SINFO:0,3,7,0,"DTS-HD Master Audio"
SINFO:0,3,14,0,"2"
SINFO:0,3,17,0,"48000"
SINFO:0,3,18,0,"16"
SINFO:0,3,22,0,"1024"
SINFO:0,3,28,0,"eng"
SINFO:0,3,29,0,"English"
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,3,33,0,"100"
SINFO:0,3,38,0,""
SINFO:0,3,40,0,"stereo"
SINFO:0,3,42,5088,"( Lossless conversion )"
SINFO:0,4,1,6202,"Audio"
SINFO:0,4,2,5091,"Stereo"
SINFO:0,4,3,0,"deu"
SINFO:0,4,4,0,"German"
SINFO:0,4,5,0,"A_DTS"
SINFO:0,4,6,0,"DTS"
SINFO:0,4,7,0,"DTS"
SINFO:0,4,13,0,"768 Kb/s"
SINFO:0,4,14,0,"2"
SINFO:0,4,17,0,"48000"
SINFO:0,4,22,0,"2304"
SINFO:0,4,28,0,"eng"
SINFO:0,4,29,0,"English"
SINFO:0,4,30,0,"DTS Stereo German"
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,4,33,0,"100"
SINFO:0,4,38,0,""
SINFO:0,4,40,0,"stereo"
SINFO:0,4,42,5088,"( Lossless conversion )"
SINFO:0,5,1,6203,"Untertitel"
SINFO:0,5,3,0,"deu"
SINFO:0,5,4,0,"German"
SINFO:0,5,5,0,"S_HDMV/PGS"
SINFO:0,5,6,0,"PGS"
SINFO:0,5,7,0,"HDMV PGS Subtitles"
SINFO:0,5,22,0,"0"
SINFO:0,5,28,0,"eng"
SINFO:0,5,29,0,"English"
SINFO:0,5,30,0,"PGS German"
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,5,33,0,"100"
SINFO:0,5,38,0,""
SINFO:0,5,42,5088,"( Lossless conversion )"
SINFO:0,6,1,6203,"Untertitel"
SINFO:0,6,3,0,"deu"
SINFO:0,6,4,0,"German"
SINFO:0,6,5,0,"S_HDMV/PGS"
SINFO:0,6,6,0,"PGS"
SINFO:0,6,7,0,"HDMV PGS Subtitles"
SINFO:0,6,22,0,"6144"
SINFO:0,6,28,0,"eng"
SINFO:0,6,29,0,"English"
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,6,33,0,"100"
SINFO:0,6,38,0,"d"
SINFO:0,6,39,0,"Default"
SINFO:0,6,42,5088,"( Lossless conversion )"
SINFO:0,7,1,6203,"Untertitel"
SINFO:0,7,3,0,"deu"
SINFO:0,7,4,0,"German"
SINFO:0,7,5,0,"S_HDMV/PGS"
SINFO:0,7,6,0,"PGS"
SINFO:0,7,7,0,"HDMV PGS Subtitles"
SINFO:0,7,22,0,"0"
SINFO:0,7,28,0,"eng"
SINFO:0,7,29,0,"English"
SINFO:0,7,30,0,"PGS German"
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,7,33,0,"100"
SINFO:0,7,38,0,""
SINFO:0,7,42,5088,"( Lossless conversion )"
SINFO:0,8,1,6203,"Untertitel"
SINFO:0,8,3,0,"deu"
SINFO:0,8,4,0,"German"
SINFO:0,8,5,0,"S_HDMV/PGS"
SINFO:0,8,6,0,"PGS"
SINFO:0,8,7,0,"HDMV PGS Subtitles"
SINFO:0,8,22,0,"6144"
SINFO:0,8,28,0,"eng"
SINFO:0,8,29,0,"English"
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
SINFO:0,8,33,0,"100"
SINFO:0,8,38,0,""
SINFO:0,8,42,5088,"( Lossless conversion )"
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
TINFO:1,8,0,"13"
TINFO:1,9,0,"1:34:45"
TINFO:1,10,0,"16.4 GB"
TINFO:1,11,0,"17668571136"
TINFO:1,16,0,"00003.mpls"
TINFO:1,25,0,"1"
TINFO:1,26,0,"7"
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
TINFO:1,28,0,"eng"
TINFO:1,29,0,"English"
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
TINFO:1,33,0,"0"
SINFO:1,0,1,6201,"Video"
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
SINFO:1,0,6,0,"Mpeg4"
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
SINFO:1,0,19,0,"1920x1080"
SINFO:1,0,20,0,"16:9"
SINFO:1,0,21,0,"23.976 (24000/1001)"
SINFO:1,0,22,0,"0"
SINFO:1,0,28,0,"eng"
SINFO:1,0,29,0,"English"
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,0,33,0,"0"
SINFO:1,0,38,0,""
SINFO:1,0,42,5088,"( Lossless conversion )"
SINFO:1,1,1,6202,"Audio"
SINFO:1,1,2,0,"Surround 5.1"
SINFO:1,1,3,0,"deu"
SINFO:1,1,4,0,"German"
SINFO:1,1,5,0,"A_DTS"
SINFO:1,1,6,0,"DTS-HD MA"
SINFO:1,1,7,0,"DTS-HD Master Audio"
SINFO:1,1,14,0,"6"
SINFO:1,1,17,0,"48000"
SINFO:1,1,18,0,"16"
SINFO:1,1,22,0,"1024"
SINFO:1,1,28,0,"eng"
SINFO:1,1,29,0,"English"
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,1,33,0,"100"
SINFO:1,1,38,0,""
SINFO:1,1,40,0,"5.1(side)"
SINFO:1,1,42,5088,"( Lossless conversion )"
SINFO:1,2,1,6202,"Audio"
SINFO:1,2,2,0,"Surround 5.1"
SINFO:1,2,3,0,"deu"
SINFO:1,2,4,0,"German"
SINFO:1,2,5,0,"A_DTS"
SINFO:1,2,6,0,"DTS"
SINFO:1,2,7,0,"DTS"
SINFO:1,2,13,0,"1.5 Mb/s"
SINFO:1,2,14,0,"6"
SINFO:1,2,17,0,"48000"
SINFO:1,2,22,0,"2304"
SINFO:1,2,28,0,"eng"
SINFO:1,2,29,0,"English"
SINFO:1,2,30,0,"DTS Surround 5.1 German"
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,2,33,0,"100"
SINFO:1,2,38,0,"d"
SINFO:1,2,39,0,"Default"
SINFO:1,2,40,0,"5.1(side)"
SINFO:1,2,42,5088,"( Lossless conversion )"
SINFO:1,3,1,6202,"Audio"
SINFO:1,3,2,5091,"Stereo"
SINFO:1,3,3,0,"jpn"
SINFO:1,3,4,0,"Japanese"
SINFO:1,3,5,0,"A_DTS"
SINFO:1,3,6,0,"DTS-HD MA"
SINFO:1,3,7,0,"DTS-HD Master Audio"
SINFO:1,3,14,0,"2"
SINFO:1,3,17,0,"48000"
SINFO:1,3,18,0,"16"
SINFO:1,3,22,0,"1024"
SINFO:1,3,28,0,"eng"
SINFO:1,3,29,0,"English"
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,3,33,0,"100"
SINFO:1,3,38,0,""
SINFO:1,3,40,0,"stereo"
SINFO:1,3,42,5088,"( Lossless conversion )"
SINFO:1,4,1,6202,"Audio"
SINFO:1,4,2,5091,"Stereo"
SINFO:1,4,3,0,"jpn"
SINFO:1,4,4,0,"Japanese"
SINFO:1,4,5,0,"A_DTS"
SINFO:1,4,6,0,"DTS"
SINFO:1,4,7,0,"DTS"
SINFO:1,4,13,0,"768 Kb/s"
SINFO:1,4,14,0,"2"
SINFO:1,4,17,0,"48000"
SINFO:1,4,22,0,"2304"
SINFO:1,4,28,0,"eng"
SINFO:1,4,29,0,"English"
SINFO:1,4,30,0,"DTS Stereo Japanese"
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,4,33,0,"100"
SINFO:1,4,38,0,""
SINFO:1,4,40,0,"stereo"
SINFO:1,4,42,5088,"( Lossless conversion )"
SINFO:1,5,1,6203,"Untertitel"
SINFO:1,5,3,0,"deu"
SINFO:1,5,4,0,"German"
SINFO:1,5,5,0,"S_HDMV/PGS"
SINFO:1,5,6,0,"PGS"
SINFO:1,5,7,0,"HDMV PGS Subtitles"
SINFO:1,5,22,0,"0"
SINFO:1,5,28,0,"eng"
SINFO:1,5,29,0,"English"
SINFO:1,5,30,0,"PGS German"
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,5,33,0,"100"
SINFO:1,5,38,0,""
SINFO:1,5,42,5088,"( Lossless conversion )"
SINFO:1,6,1,6203,"Untertitel"
SINFO:1,6,3,0,"deu"
SINFO:1,6,4,0,"German"
SINFO:1,6,5,0,"S_HDMV/PGS"
SINFO:1,6,6,0,"PGS"
SINFO:1,6,7,0,"HDMV PGS Subtitles"
SINFO:1,6,22,0,"6144"
SINFO:1,6,28,0,"eng"
SINFO:1,6,29,0,"English"
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,6,33,0,"100"
SINFO:1,6,38,0,"d"
SINFO:1,6,39,0,"Default"
SINFO:1,6,42,5088,"( Lossless conversion )"
SINFO:1,7,1,6203,"Untertitel"
SINFO:1,7,3,0,"deu"
SINFO:1,7,4,0,"German"
SINFO:1,7,5,0,"S_HDMV/PGS"
SINFO:1,7,6,0,"PGS"
SINFO:1,7,7,0,"HDMV PGS Subtitles"
SINFO:1,7,22,0,"0"
SINFO:1,7,28,0,"eng"
SINFO:1,7,29,0,"English"
SINFO:1,7,30,0,"PGS German"
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,7,33,0,"100"
SINFO:1,7,38,0,""
SINFO:1,7,42,5088,"( Lossless conversion )"
SINFO:1,8,1,6203,"Untertitel"
SINFO:1,8,3,0,"deu"
SINFO:1,8,4,0,"German"
SINFO:1,8,5,0,"S_HDMV/PGS"
SINFO:1,8,6,0,"PGS"
SINFO:1,8,7,0,"HDMV PGS Subtitles"
SINFO:1,8,22,0,"6144"
SINFO:1,8,28,0,"eng"
SINFO:1,8,29,0,"English"
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
SINFO:1,8,33,0,"100"
SINFO:1,8,38,0,""
SINFO:1,8,42,5088,"( Lossless conversion )"
+12
View File
@@ -0,0 +1,12 @@
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
try {
const s = require('node:sqlite');
const db = new s.DatabaseSync(':memory:');
db.exec('CREATE TABLE t (a TEXT)');
db.prepare('INSERT INTO t VALUES (?)').run('geht');
const wert = db.prepare('SELECT a FROM t').get().a;
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
} catch (e) {
process.parentPort.postMessage({ ok: false, fehler: e.message });
}
+20
View File
@@ -0,0 +1,20 @@
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
//
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
const { app, utilityProcess } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
kind.on('message', (antwort) => {
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
app.exit(antwort.ok ? 0 : 1);
});
kind.on('exit', (code) => {
console.log('Kern beendet mit ' + code);
if (code !== 0) app.exit(1);
});
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+24 -4
View File
@@ -1507,9 +1507,19 @@ async def storage_targets():
"free_gb": frei_gb,
})
# Der Laufwerksbuchstaben-Block stand hier für die Windows-
# Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT-
# WINDOWS.md Entscheid 7); im Container gab es hier nie etwas.
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
if frei_blaettern():
from rippy import betrieb
bekannt = {z["path"].rstrip("\\/").lower() for z in ziele}
for laufwerk in betrieb.windows_laufwerke():
if laufwerk.rstrip("\\/").lower() in bekannt:
continue
ziele.append({
"name": "Laufwerk %s" % laufwerk.rstrip("\\/"),
"path": laufwerk,
"is_mount": False,
"free_gb": platz(laufwerk),
})
return ziele
return await asyncio.to_thread(sammle)
@@ -2135,7 +2145,7 @@ async def browse(path: str = ""):
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
"""
if not path.strip():
laufwerke = []
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
if laufwerke:
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
path = medien_wurzel()
@@ -2193,6 +2203,16 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str
return "" if oben == pfad else oben
def betrieb_laufwerke() -> list:
"""Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers."""
from rippy import betrieb
try:
return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk}
for laufwerk in betrieb.windows_laufwerke()]
except Exception: # noqa: BLE001
return []
class MkdirRequest(BaseModel):
path: str
name: str
+3
View File
@@ -77,3 +77,6 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
assert main.medien_wurzel()
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
assert isinstance(main.betrieb_laufwerke(), list)
+56 -4
View File
@@ -1114,14 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
log_cb=log_cb)
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
output_dir: str = None, auswerfen: bool = True) -> dict:
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
"""
from rippy.drives import windows as win
from rippy.rip import audio_cd
class Leser:
toc = staticmethod(win.audio_toc)
@staticmethod
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
fortschritt)
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
# Disc, die niemand kennt.
from rippy.rip import musicbrainz_cd
try:
spuren = win.audio_toc(device_path)
antwort = musicbrainz_cd.abfragen(
musicbrainz_cd.kennung_aus_spuren(spuren))
except Exception: # noqa: BLE001
antwort = None
tags = musicbrainz_cd.tags_je_spur(antwort)
ordner = musicbrainz_cd.album_ordner(antwort)
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
werkzeuge.finden("flac"),
fortschritt=progress_cb, tags_je_spur=tags)
if auswerfen and ergebnis.get("status") == "success":
wirf_disc_aus(device_path)
return ergebnis
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x).
Auswurf" (abcde macht das selbst per -x)."""
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
#
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
#
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
# Wochen so im SAVEPOINT.
if os.name == "nt":
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
auswerfen)
Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach
Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3) —
diese Fassung rippt CDs nur noch im Container, mit abcde."""
if not check_abcde_installed():
return {
"status": "error",
+254
View File
@@ -0,0 +1,254 @@
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
## Aufruf
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
* pyinstaller, pystray, pillow
* Das UI muss gebaut sein: cd docker/ui && npm run build
## Warum die Versions-Ressource nicht optional ist
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
und Windows SmartScreen sieht das genauso.
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
daneben aus dieser Ressource.
## Was mit hinein muss
docker/api/ die API-Module (main.py und Nachbarn)
docker/ui/dist/ die gebaute Weboberflaeche
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
"""
import argparse
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
VERSION_VORLAGE = """
VSVersionInfo(
ffi=FixedFileInfo(
filevers=({v0}, {v1}, {v2}, 0),
prodvers=({v0}, {v1}, {v2}, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(u'040704b0', [
StringStruct(u'CompanyName', u'Rippy'),
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
StringStruct(u'FileVersion', u'{version}'),
StringStruct(u'InternalName', u'Rippy'),
StringStruct(u'OriginalFilename', u'Rippy.exe'),
StringStruct(u'ProductName', u'Rippy'),
StringStruct(u'ProductVersion', u'{version}')])
]),
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
# Windows die Angaben je nach Systemsprache gar nicht an.
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
]
)
"""
def versionsdatei(version: str, ordner: str) -> str:
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
pfad = os.path.join(ordner, "version_info.txt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(VERSION_VORLAGE.format(
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
return pfad
def pruefen() -> list:
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
aussieht wie fertig und es nicht ist.
"""
maengel = []
if sys.platform != "win32":
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
maengel.append("docker/api/main.py fehlt.")
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
"die App nicht rippen.")
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
maengel.append(
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
"Abhilfe: cd docker/ui && npm ci && npm run build")
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
if not os.path.isfile(icon):
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
else:
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
# noch keine Datei, die funktioniert.
import sys as _sys
_sys.path.insert(0, HIER)
from icon import fehlende_pflichtgroessen
fehlt = fehlende_pflichtgroessen(icon)
if fehlt:
maengel.append(
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (fehlt,))
try:
import PyInstaller # noqa: F401
except ImportError:
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
for modul, paket, wofuer in (
("pystray", "pystray", "kein Tray-Symbol"),
("PIL", "pillow", "kein Tray-Symbol"),
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
):
try:
__import__(modul)
except ImportError:
maengel.append(
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
return maengel
def bauen(ausgabe: str, version: str) -> str:
arbeit = os.path.join(ausgabe, "_bau")
os.makedirs(arbeit, exist_ok=True)
trenner = ";" # Windows-Trennzeichen fuer --add-data
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
import sys as _sys
_sys.path.insert(0, HIER)
from werkzeuge import paket_ordner_bauen
werkzeug_ordner = paket_ordner_bauen(ausgabe)
if not werkzeug_ordner:
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
befehl = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--onefile",
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
# Sekunden auf, bis Python so weit ist.
#
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
"--windowed",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
"--distpath", ausgabe,
"--workpath", os.path.join(arbeit, "work"),
"--specpath", arbeit,
"--paths", os.path.join(REPO, "src"),
"--paths", os.path.join(REPO, "docker", "api"),
"--paths", os.path.join(REPO, "docker", "worker"),
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "rippy.drives.windows",
"--hidden-import", "pystray._win32",
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
# genau dorthin, wovon wir gerade weg wollten.
"--hidden-import", "webview",
"--hidden-import", "webview.platforms.winforms",
"--hidden-import", "webview.platforms.edgechromium",
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
# und beides zieht Ballast nach.
"--exclude-module", "psycopg2",
"--exclude-module", "tkinter",
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
]
if werkzeug_ordner:
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
print("PyInstaller wird aufgerufen …\n")
ergebnis = subprocess.run(befehl, cwd=REPO)
if ergebnis.returncode != 0:
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
exe = os.path.join(ausgabe, "RippySetup.exe")
if not os.path.isfile(exe):
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
return exe
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
print("Rippy — Windows-Bau")
print(" Repo %s" % REPO)
print(" Ausgabe %s" % args.ausgabe)
print(" Version %s" % args.version)
print()
maengel = pruefen()
if maengel:
print("Der Bau kann so nicht laufen:")
for m in maengel:
print(" X %s" % m)
return 1
print("Alle Voraussetzungen erfuellt.")
if args.nur_pruefen:
return 0
os.makedirs(args.ausgabe, exist_ok=True)
exe = bauen(args.ausgabe, args.version)
groesse = os.path.getsize(exe) / 1024**2
print()
print("Fertig: %s (%.1f MB)" % (exe, groesse))
arbeit = os.path.join(args.ausgabe, "_bau")
shutil.rmtree(arbeit, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
"""Einstiegspunkt der gebuendelten RippySetup.exe.
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
## Warum hier trotzdem etwas zur Ausgabe steht
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026:
„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
schreibt — uvicorn tut das —, wuerde mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
zwar mitten im Start, ohne dass irgendwo etwas stuende.
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
1. In einem Terminal gestartet? -> dort hineinschreiben
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
"""
import multiprocessing
import sys
def _ausgabe_richten() -> None:
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return # aus dem Quellcode gestartet
if winlauf.an_elternkonsole_haengen():
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
# sys.stdout trotz Konsole None.
try:
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
buffering=1)
sys.stderr = sys.stdout
return
except OSError:
pass
winlauf.ausgabe_umleiten()
def main() -> int:
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
multiprocessing.freeze_support()
_ausgabe_richten()
from rippy.windows_app import main as rippy_main
return rippy_main()
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
## Der Befund (28.08.2026)
Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
Leeres Blatt)."*
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
Bilder = 1
256x256 32 bit 5657 Bytes PNG
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
die passende Größe aus der Datei:
Taskleiste, Verknüpfung klein 16x16
Desktop, Startmenü 32x32 / 48x48
Große Kacheln, Alt-Tab 256x256
Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
enthält, passiert das zuverlässig.
## Was diese Datei tut
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
Aufruf (macht der Bau selbst, siehe `build.py`):
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
"""
import argparse
import os
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def groessen_in(pfad: str) -> list:
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
Fehler, den es hier zu verhindern gilt.
"""
import struct
with open(pfad, "rb") as f:
roh = f.read()
if len(roh) < 6:
return []
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
if reserviert != 0 or typ != 1:
return []
gefunden = []
for i in range(anzahl):
block = roh[6 + i * 16: 22 + i * 16]
if len(block) < 16:
break
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
gefunden.append(breite)
return sorted(gefunden)
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
PFLICHTGROESSEN = (16, 32, 48, 256)
def fehlende_pflichtgroessen(pfad: str) -> tuple:
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
vorhanden = set(groessen_in(pfad))
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
def bauen(quelle: str = None, ziel: str = None) -> str:
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt
genau zu dem leeren Blatt, das hier abgestellt werden soll.
"""
from PIL import Image
quelle = quelle or QUELLE
ziel = ziel or quelle
bild = Image.open(quelle)
bild = bild.convert("RGBA")
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
# Groesse, und aus 16x16 laesst sich kein 256er machen.
if hasattr(bild, "size") and bild.size[0] < 256:
try:
bild = Image.open(quelle)
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
bild = bild.convert("RGBA")
except Exception: # noqa: BLE001
pass
ebenen = []
for kante in GROESSEN:
if kante >= bild.size[0]:
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
else:
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
# zwischen einem erkennbaren Zeichen und Pixelbrei.
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
ebenen[-1].save(ziel, format="ICO",
sizes=[(k, k) for k in GROESSEN])
return ziel
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
p.add_argument("--quelle", default=QUELLE)
p.add_argument("--ziel", default=None)
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
vorher = groessen_in(args.quelle)
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
if args.nur_pruefen:
return 0 if len(vorher) > 1 else 1
ziel = bauen(args.quelle, args.ziel)
print("Nachher: %s" % groessen_in(ziel))
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
#
# py -3.12 -m venv venv
# venv\Scripts\pip install -r docker/api/requirements.txt
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
# venv\Scripts\python packaging/windows/build.py
#
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
# ist eine Entscheidung, kein Zufall.
pyinstaller==6.22.2
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
# zu beenden, ausser ueber den Taskmanager.
pystray==0.19.5
pillow==12.3.0
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
pywebview==6.2.1
pythonnet==3.1.0
+76
View File
@@ -0,0 +1,76 @@
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
## Der Befund (28.08.2026)
Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
Leeres Blatt)."*
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
Windows holt sich fuer jede Stelle die passende Groesse:
Taskleiste, kleine Verknuepfung 16x16
Desktop, Startmenue 32x32 / 48x48
Alt-Tab, grosse Kacheln 256x256
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
regelmaessig.
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
"""
import os
import pytest
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def _icon_modul():
import sys
sys.path.insert(0, HIER)
import icon
return icon
def _groessen():
return _icon_modul().groessen_in(ICON)
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
# fordern, die dieser Test nicht prueft — oder umgekehrt.
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
def test_icon_ist_da():
assert os.path.isfile(ICON), ICON
def test_icon_ist_eine_gueltige_ico():
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
def test_pflichtgroesse_ist_enthalten(kante):
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
vorhanden = _groessen()
assert kante in vorhanden, (
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (kante, kante, vorhanden)
)
def test_eine_einzige_groesse_reicht_nie():
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
assert len(_groessen()) > 1, (
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
"gefuehrt hat."
)
+135
View File
@@ -0,0 +1,135 @@
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden
oder während des Setups separat installiert werden! Ohne das ist das tool
NICHT einsatzfähig."*
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
die Lizenz, nicht die Bequemlichkeit:
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
startet dessen Installer — der Nutzer bezieht sie also weiterhin von
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
## Warum die Datei nicht im Repo liegt
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket —
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
"""
import os
import shutil
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
# verlangt ihn bei jeder Weitergabe.
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
steht unter der GPL-2. Das bedeutet fuer dich:
* Du darfst es weitergeben und veraendern.
* Der Quelltext ist oeffentlich verfuegbar unter:
https://github.com/HandBrake/HandBrake
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
Mitgelieferte Fassung: {version}
Bezogen von: {url}
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
installiert.
"""
def vendor_ordner(basis: str = None) -> str:
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist
leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll
möglich bleiben, muss dann aber laut sagen, was fehlt.
"""
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import beschaffen
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
melden(" HandBrakeCLI wird geholt …")
try:
pfad = beschaffen.handbrake_holen(
ziel_ordner=ordner,
fortschritt=lambda t, anteil=None: melden(" " + t))
except Exception as e: # noqa: BLE001
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
return {"pfad": "", "version": "", "geladen": False}
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
def _version_von(exe: str) -> str:
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import katalog
try:
return katalog.version_von("handbrake", exe) or ""
except Exception: # noqa: BLE001
return ""
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
Struktur — genau die, in der `katalog.kandidaten()` sucht:
tools/handbrake/HandBrakeCLI.exe
tools/handbrake/LIZENZ-HandBrake.txt
"""
hb = handbrake_bereitstellen(ziel_basis, melden)
if not hb["pfad"]:
return ""
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
ordner = os.path.join(basis, "handbrake")
os.makedirs(ordner, exist_ok=True)
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
shutil.copy2(hb["pfad"], ziel_exe)
from rippy.tools import beschaffen
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
encoding="utf-8") as f:
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
url=beschaffen.HANDBRAKE_API))
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
return basis
if __name__ == "__main__":
ordner = paket_ordner_bauen()
print("Paket-Ordner:", ordner or "(leer)")
raise SystemExit(0 if ordner else 1)
+11
View File
@@ -0,0 +1,11 @@
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
node_modules/
out/
*.tsbuildinfo
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
dist-setup*/
vendor/
.electron-cache/
+114
View File
@@ -0,0 +1,114 @@
# Rippy v5 bauen — Anleitung
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
> die Messungen dazu liegen in `beweise/`.
## Voraussetzungen
| Werkzeug | Version | Anmerkung |
|----------|---------|-----------|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
| npm | 11 | kommt mit Node 24 |
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
## Einrichten — zwei Schritte, nicht einer
```
npm install
node node_modules/electron/install.js
```
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
## Die Läufe
| Befehl | Was er tut |
|--------|------------|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an.
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
`SAVEPOINT.md` mit Ausgabe belegt.
## Das Setup bauen (W-6)
```
npm run paket
```
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
**Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
lassen, die -Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
(`npx electron-builder --win --config electron-builder.yml
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
den gesperrten Ordner löschen, sobald er wieder frei ist.
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
liegen nicht im Git, 74 MB):
```
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
Aufruf ohne eine Zeile Ausgabe)
```
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
in `bau/lizenzen/QUELLEN.md`.
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
ERSETZEN) — `electron-updater` prüft
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
erkennen.
## Festgelegte Versionen und warum
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
Release prüfen, ob eine neue Linie ansteht.
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 57) und
Vitest 4 (verträgt 68), am 30.08.2026 per `npm view` ermittelt.
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+362
View File
@@ -0,0 +1,362 @@
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
// Ablage → Roh-Dateien → löschen.
//
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
// (Doku »Electron« bei playwright.dev, _electron.launch).
//
// npm run klick (baut vorher)
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
import { _electron as electron } from 'playwright'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage')
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
const arbeit = join(profil, 'arbeit')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
{
const db = new DatabaseSync(join(profil, 'rippy.db'))
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
setzen.run('setup.done', 'true')
setzen.run('ablage', ablage)
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
setzen.run('audioSprachen', 'deu,eng')
setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false')
setzen.run('arbeitsordner', arbeit)
// Die Automatik bleibt aus: Der Beweis klickt selbst.
setzen.run('automatik', 'aus')
db.close()
}
const start = (env) =>
electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
timeout: 60_000,
})
let app = await start({})
async function fenster() {
const s = await app.firstWindow()
s.setDefaultTimeout(30_000)
s.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
return s
}
let seite = await fenster()
let nr = 0
async function bild(name) {
nr += 1
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
await seite.screenshot({ path: pfad, fullPage: true })
console.log(`KLICK bild: ${pfad}`)
}
async function schritt(name, tun) {
try {
await tun()
console.log(`KLICK ✓ ${name}`)
} catch (fehler) {
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
await app.close().catch(() => {})
process.exit(2)
}
}
const dialog = () => seite.locator('div.fixed.inset-0').last()
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
await seite.getByText('LAUFWERK Q:').waitFor()
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
})
await bild('uebersicht')
await schritt('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
await seite.getByText('SPEICHER UND BILANZ').waitFor()
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
await seite.getByText('ABLAGE (SERVER)').waitFor()
await seite.getByText(/^FREI [0-9]/).waitFor()
})
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
})
await schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor()
})
await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 0', { exact: true }).waitFor()
await dialog().getByText('Titel 1', { exact: true }).waitFor()
await dialog().getByText('Titel 2', { exact: true }).waitFor()
})
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
})
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
await chips.first().click()
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
})
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
await dialog().getByText(/2 von 3 gewählt/).waitFor()
})
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('hauptfilm')
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
await rollen.nth(1).selectOption('extra')
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
const vorwahl = dialog().locator('select').nth(2)
await vorwahl.selectOption('deu')
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
})
await bild('dialog-vorwahl')
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge')
await rollen.nth(1).selectOption('folge')
await dialog().getByText('AB FOLGE').waitFor()
await dialog().getByPlaceholder('automatisch').fill('3')
})
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Serien\\/).waitFor()
await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).waitFor()
})
await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
})
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
})
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
})
await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 })
})
await bild('fertig')
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
const serien = join(ablage, 'Serien')
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
const gefunden = []
const gehen = (o) => {
for (const e of readdirSync(o, { withFileTypes: true })) {
const p = join(o, e.name)
if (e.isDirectory()) gehen(p)
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
}
}
gehen(serien)
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
throw new Error(`gefunden: ${namen.join(', ')}`)
}
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
})
await schritt('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
const roh = join(arbeit, 'roh')
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
const fertig = join(arbeit, 'fertig')
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
const rohInAblage = join(ablage, 'roh')
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
})
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
await seite.getByText('VERLAUF', { exact: true }).waitFor()
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
})
await schritt('Bilanz zählt eine Disc', async () => {
await seite.getByText('DISCS', { exact: true }).waitFor()
await seite.getByText('1 fertig', { exact: true }).waitFor()
})
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor()
await seite.getByText(/Belegung jetzt/).waitFor()
await seite.getByText(/Roh: \d/).first().waitFor()
})
await bild('roh-dateien')
await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
await seite.locator('#kompression').locator('select').first().waitFor()
})
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.locator('#allgemein').waitFor()
await seite.locator('#update').waitFor()
await seite.locator('#automatik').waitFor()
await seite.locator('#benachrichtigung').waitFor()
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
})
await bild('einstellungen')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
await seite.locator('#allgemein').locator('select').first().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').locator('select').first().selectOption('dunkel')
await seite.locator('html[data-design="dunkel"]').waitFor()
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
})
await bild('ereignisse')
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
await app.close().catch(() => {})
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
seite = await fenster()
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
await seite.getByText('Willkommen bei Rippy').waitFor()
const spalten = await seite.locator('.poster-spalte').count()
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
})
await bild('einrichtung-willkommen')
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
await weiter()
await seite.getByText(/MakeMKV gefunden/).waitFor()
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
})
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
await weiter()
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.getByText(/frei von/).first().waitFor()
})
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
await weiter()
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
await seite.getByText('4K-UHD', { exact: true }).waitFor()
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
})
await bild('einrichtung-qualitaet')
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
await weiter()
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
await weiter()
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
await weiter()
await seite.getByText('Countdown', { exact: true }).waitFor()
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
})
await schritt('Fertig: der Knopf Los gehts', async () => {
await weiter()
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
})
await bild('einrichtung-fertig')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
@@ -0,0 +1,98 @@
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
// HandBrake 1.11 gemessen haben:
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert;
// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das
// Binary sie formatiert (5.6.0, Restzeit).
const fs = require('node:fs')
const args = process.argv.slice(2)
const out = (zeile) => process.stdout.write(zeile + '\n')
const wert = (schalter) => {
const i = args.indexOf(schalter)
return i >= 0 ? args[i + 1] : undefined
}
if (args.includes('--help') || args.includes('-h')) {
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
out('')
out('Video Options -----------------------------------------------------------------')
out('')
out(' -e, --encoder <string> Select video encoder:')
out(' x264')
out(' x265')
out(' nvenc_h264')
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
out('')
process.exit(0)
}
if (args.includes('--preset-list')) {
out('General/')
out(' Very Fast 1080p30')
out(' Fast 1080p30')
out(' HQ 1080p30 Surround')
out('')
process.exit(0)
}
if (args.includes('--scan')) {
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 1, 100.00 %')
out('JSON Title Set: {')
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
out('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
out(']')
out('}')
out(']')
out('}')
out('HandBrake has exited.')
process.exit(0)
}
const eingabe = wert('--input') ?? wert('-i')
const ausgabe = wert('--output') ?? wert('-o')
if (eingabe === undefined || ausgabe === undefined) {
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
process.exit(1)
}
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0
const stufen = [5, 30, 55, 80, 100]
const tick = () => {
const prozent = stufen[schritt].toFixed(2)
if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`)
else {
// Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)"
const rest = (stufen.length - 1 - schritt) * 300
out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`)
}
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 500)
return
}
fs.copyFileSync(eingabe, ausgabe)
out('[12:00:05] libhb: work result = 0')
out('')
out('Encode done!')
out('HandBrake has exited.')
process.exit(0)
}
setTimeout(tick, 200)
@@ -0,0 +1,79 @@
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
//
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
//
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
const fs = require('node:fs')
const path = require('node:path')
const args = process.argv.slice(2)
const befehl = args.find((a) => a === 'info' || a === 'mkv')
const out = (zeile) => process.stdout.write(zeile + '\n')
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
const TITEL = [
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
]
if (befehl === 'info') {
out(`TCOUNT:${TITEL.length}`)
for (const t of TITEL) {
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
out(`SINFO:${t.nr},1,3,0,"jpn"`)
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
out(`SINFO:${t.nr},2,3,0,"deu"`)
out(`SINFO:${t.nr},2,4,0,"German"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},3,3,0,"deu"`)
out(`SINFO:${t.nr},3,4,0,"German"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},4,3,0,"eng"`)
out(`SINFO:${t.nr},4,4,0,"English"`)
}
}
process.exit(0)
}
if (befehl === 'mkv') {
const i = args.indexOf('mkv')
const nr = Number(args[i + 2])
const ziel = args[i + 3]
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
out('MSG:5010,0,0,"Falscher Aufruf"')
process.exit(1)
}
let schritt = 0
const stufen = [10, 35, 60, 85, 100]
const tick = () => {
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 250)
return
}
fs.mkdirSync(ziel, { recursive: true })
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
process.exit(0)
}
setTimeout(tick, 200)
} else {
out('MSG:5010,0,0,"Unbekannter Befehl"')
process.exit(1)
}
@@ -0,0 +1,29 @@
Copyright (C) 2000-2009 Josh Coalson
Copyright (C) 2011-2025 Xiph.Org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.Org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+35
View File
@@ -0,0 +1,35 @@
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
holt es beim Einrichten vom Hersteller.
## HandBrakeCLI
- Lizenz: GNU General Public License v2 — Volltext in
`HANDBRAKE-GPL-2.0.txt` (bezogen von
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
- Quelltext und Projekt: https://handbrake.fr bzw.
https://github.com/HandBrake/HandBrake — der Quelltext der
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
## FLAC (flac.exe + libFLAC.dll)
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
`FLAC-COPYING.Xiph` (bezogen von
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
30.08.2026).
- Projekt: https://xiph.org/flac/
-`flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
keine Schriften aus dem Netz.
@@ -0,0 +1,93 @@
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+168
View File
@@ -0,0 +1,168 @@
Version 5.6.1
Untertitel-Spuren werden wieder erkannt.
MakeMKV schreibt den Typ eines Streams in seiner Anzeigesprache - auf einem
deutsch eingestellten MakeMKV "Untertitel" statt "Subtitles". Rippy prueft
seit 5.4.0 auf das englische Wort und fand deshalb keine Untertitel mehr
(Ton heisst in beiden Sprachen "Audio", darum fiel es dort nicht auf).
Jetzt zaehlt der Typ-Code (6202 Ton, 6203 Untertitel), der ist
sprachunabhaengig. Gemessen an der Blu-ray "Digimon Adventure: Last
Evolution Kizuna": drei deutsche PGS-Spuren, darunter eine nur fuer
erzwungene Untertitel.
Version 5.6.0
Mehrere Filme auf einer Disc.
Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche
(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen
eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der
Verlauf zeigt beide.
Ordner und Dateien tragen den Filmnamen.
Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit
Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988)
- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner
extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie
benennt; unerkannte Discs behalten den Zeitstempel-Namen.
Untertitel-Vorwahl je Extra.
Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest,
welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur,
nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton ->
Wunschsprache) bleibt die Vorgabe.
Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung".
Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen,
Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor).
Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der
aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet,
danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch
~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur
noch eine Zeile, wo es weitergeht.
Hell-Modus.
Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal),
Hell (Papier und Gold) oder wie Windows.
Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim
Scrollen stehen.
Version 5.5.0
Uebersicht und Einstellungen neu sortiert.
Die Bibliothek ist weg (Jellyfin IST die Bibliothek). Dafuer zeigt die
Uebersicht den Verlauf der letzten Vorgaenge, den freien Platz auf
Arbeitsordner und Ablage samt Bilanz (Discs, gesicherte GB, Dauer je Disc)
und eine Fussleiste mit Kern, Datenbank, Werkzeugen, Schluessel, freiem
Platz und Update-Stand. Der MakeMKV-Kopf ist in die Einstellungen gezogen.
Dort gibt es eigene Karten fuer Automatik, Benachrichtigung, Allgemein und
Update; "Allgemeines Preset" ist weg, Ton- und Untertitelsprachen sind
Auswahl-Chips mit Dropdown in Wunschreihenfolge; Werkzeuge haben
"Durchsuchen ...".
Arbeitsordner lokal, Ablage auf dem Server.
Roh-Dateien und Kompression liegen im Arbeitsordner (lokale Platte), nur
das fertige Ergebnis wandert in die Ablage. Vor jedem Rip prueft Rippy den
Platz: zu wenig fuer den verlustfreien Rip heisst kein Start, knapp heisst
Hinweis.
Automatik mit Countdown.
Ist Rippy sicher (ab 90 %) und die Disc noch nicht gerippt, liest es die
Titel und startet nach 30 s (einstellbar) mit seinem Vorschlag - mit
"Stopp" und "Jetzt" in der Kachel. Bei Unsicherheit wartet es und sagt
warum.
Serien-Merker ueber Discs hinweg.
Disc 1 endet mit Folge 4, Disc 2 zaehlt ab 5 weiter - der Dialog schlaegt
es vor, aenderbar.
Rettungs-Abbild fuer defekte Discs.
Hat Rippy Lesefehler gesehen (Defekt-Waechter, Lesefehler-Meldung), bietet
die Kachel "Rettungs-Abbild" an: Rippys eigener Sektor-Leser liest die Disc
Stueck fuer Stueck, wiederholt in immer kleineren Stuecken und fuellt nur
das wirklich Unlesbare mit Nullen; danach laeuft derselbe Rip aus dem
Abbild, an dem MakeMKV nicht mehr haengen bleibt. Ein Bericht daneben
nennt jede genullte Stelle. Bei einer heilen Disc passiert das nie.
Warteschlange pausieren, Benachrichtigung, Protokoll.
"Pausieren" laesst den laufenden Film fertig rechnen und haelt die Schlange
an. Discord-Webhook oder Telegram-Bot melden fertige Rips, Kompressionen
und Fehler aufs Handy (Testknopf). Haupt und Kern schreiben eine
Protokoll-Datei je Tag (14 Tage), "Protokoll oeffnen" unter Allgemein;
Ordner oeffnen sich aus Verlauf, Roh-Dateien und Speicher-Karte im Explorer.
Erst-Einrichtung mit Poster-Kulisse.
Acht Schritte mit denselben Bausteinen wie die Einstellungen (Ordner,
Qualitaet mit Sprachen, Titel, Meldungen, Automatik) vor einem langsam
laufenden Poster-Raster - Platzhalter bis zum TMDb-Schluessel, danach
echte Poster.
Dazu: Das Fenster oeffnet mit 80 % des Bildschirms, mittig, mindestens
1100 x 720, und merkt sich Lage und Groesse (nur, wenn sie noch auf einem
Bildschirm liegt). Oberflaeche und Kern sind in Bereiche aufgeteilt.
Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
Roh-Dateien raeumen sich auf.
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
Sprachen je Titel.
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
dass alle Sprachen mitkommen.
Staffel und Folge von Hand.
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
Erkennung v2 und Kino-Banner.
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
Hardware?
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
ganzen Weg durch das echte Programm.
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
Version 5.3.2
Die Titelliste bleibt jetzt stehen.
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
auf derselben Disc gegenseitig aus.
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

+42
View File
@@ -0,0 +1,42 @@
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
appId: de.krbrz.rippy
productName: Rippy
directories:
output: dist-setup
buildResources: bau
files:
- out/**
- package.json
extraResources:
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
- from: vendor
to: vendor
- from: bau/lizenzen
to: lizenzen
- from: bau/tray.png
to: bau/tray.png
win:
icon: bau/icon.ico
target:
- nsis
nsis:
oneClick: true
perMachine: false
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
allowToChangeInstallationDirectory: false
deleteAppDataOnUninstall: false
artifactName: RippySetup-${version}.${ext}
publish:
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
# ersten echten Release gemessen — § 3.6 ⚠).
provider: generic
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
electronDownload:
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
# lädt die Binärdateien selbst über diesen Cache-Weg.
cache: .electron-cache
+70
View File
@@ -0,0 +1,70 @@
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
// haupt → out/haupt/index.js (Electron Main)
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
// bruecke → out/bruecke/index.js (Preload des Fensters)
// fenster → out/fenster/ (React)
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import type { Plugin } from 'vite'
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
function cspNurImBau(): Plugin {
return {
name: 'csp-nur-im-bau',
apply: 'build',
transformIndexHtml(html) {
return html.replace(
'<head>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
// als data:-URI ein, und die blockte die CSP — als Fehler in der
// Konsole, unsichtbar im Fenster.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
)
},
}
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/haupt',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/haupt/index.ts'),
kern: resolve(__dirname, 'src/kern/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/bruecke',
rollupOptions: {
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
},
},
},
renderer: {
root: 'src/fenster',
plugins: [react(), tailwindcss(), cspNurImBau()],
build: {
outDir: 'out/fenster',
rollupOptions: {
input: { index: resolve(__dirname, 'src/fenster/index.html') },
},
},
},
})
+7403
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.6.1",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev": "electron-vite dev",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
"build": "npm run typecheck && electron-vite build",
"test": "vitest run",
"smoke": "electron-vite build && electron . --smoke",
"klick": "electron-vite build && node bau/klick-smoke.mjs",
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
"werkzeug:electron": "node node_modules/electron/install.js"
},
"dependencies": {
"electron-updater": "^6.8.9",
"koffi": "^3.1.6"
},
"devDependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource/instrument-sans": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^5.2.0",
"electron": "44.0.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
"typescript": "5.9.3",
"vite": "^7.3.6",
"vitest": "^4.1.11"
}
}
+86
View File
@@ -0,0 +1,86 @@
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
// Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
//
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
let seiteBereit = false
const wartendePorts: IpcRendererEvent[] = []
ipcRenderer.on('kern-port', (ereignis) => {
if (seiteBereit) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
} else {
wartendePorts.push(ereignis)
}
})
let letzterStatus: unknown = null
const statusHorcher = new Set<(status: unknown) => void>()
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
letzterStatus = status
for (const horcher of statusHorcher) horcher(status)
})
contextBridge.exposeInMainWorld('rippy', {
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
bereit(): void {
seiteBereit = true
for (const ereignis of wartendePorts.splice(0)) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
}
},
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
aufStatus(horcher: (status: unknown) => void): void {
statusHorcher.add(horcher)
if (letzterStatus !== null) horcher(letzterStatus)
},
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
melden(art: string): void {
ipcRenderer.send('fenster-meldung', String(art))
},
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
},
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
dateiWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
},
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
ordnerOeffnen(pfad: string): Promise<string> {
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
},
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
protokollOeffnen(): Promise<string> {
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
},
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
},
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
* „schliessen" versteckt nur — Rippy lebt im Tray weiter. */
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
},
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
updatePruefen(): Promise<unknown> {
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
},
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
},
})
+194
View File
@@ -0,0 +1,194 @@
// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026,
// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz,
// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die
// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit
// großer Prozentzahl, Mono-Protokoll, LED-Fußleiste.
//
// Seit 5.5.0 ist diese Datei nur noch der RAHMEN (Separation of Concerns):
// Kopfzeile, Bereichswahl, Fußleiste — die Bereiche selbst liegen in
// uebersicht/, einstellungen/, einrichtung/, die Dialoge in dialoge/, das
// Gemeinsame in bausteine.tsx. Die Regeln bleiben: EINE Einstellungs-Quelle
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 (Fehler
// stehen im Fenster — bei Systemfehlern als rote Karte).
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../gemeinsam/nachrichten'
import {
FensterKnopf,
IconDisc,
IconMaximieren,
IconMinimieren,
IconSchliessen,
IconWiederherstellen,
LAUFENDE_PHASEN,
gbKurz,
} from './bausteine'
import { ErstEinrichtung } from './einrichtung/ErstEinrichtung'
import { Einstellungen } from './einstellungen/Einstellungen'
import { LEERE_VERBINDUNG, aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
import { SEHR_WENIG_FREI_BYTES, WENIG_FREI_BYTES } from './uebersicht/SpeicherKarte'
import { Uebersicht } from './uebersicht/Uebersicht'
type Bereich = 'uebersicht' | 'einstellungen'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return typeof wert === 'object' && wert !== null && typeof (wert as Record<string, unknown>).version === 'string'
}
/** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext —
* statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */
function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) {
const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25'
return (
<span className="inline-flex items-center gap-1.5" title={titel}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${farbe}`} />
{text}
</span>
)
}
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
// #einstellungen als Startanker — der Smoke-Beweis fotografiert damit
// auch die anderen Bereiche. `start` springt in den Einstellungen zu
// einer Karte („alle unter Roh-Dateien →").
const [bereich, setBereich] = useState<{ name: Bereich; start?: string }>(() => {
const anker = window.location.hash.replace('#', '')
return { name: anker === 'einstellungen' ? 'einstellungen' : 'uebersicht' }
})
const [verbindung, setVerbindung] = useState<Verbindung>(LEERE_VERBINDUNG)
useEffect(() => {
window.rippy.aufStatus((status) => {
if (istHauptStatus(status)) setHaupt(status)
})
verbindungStarten()
return aufVerbindung(setVerbindung)
}, [])
// 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf
// <html> (stil.css überschreibt dann die Farb-Tokens); „system" folgt
// Windows.
const design = verbindung.einstellungen['design'] ?? 'dunkel'
useEffect(() => {
const anwenden = (): void => {
const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel'
}
anwenden()
if (design !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: light)')
mq.addEventListener('change', anwenden)
return () => mq.removeEventListener('change', anwenden)
}, [design])
const db = verbindung.kern?.datenbank ?? null
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
// R4: Systemfehler stehen als rote Karte in der Übersicht — die
// LED-Leiste unten zeigt nur den Daumenwert.
const systemFehler: string[] = []
if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`)
if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`)
if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.')
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH).
const setupOffen =
window.location.hash === '#einrichtung' || (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
<button
onClick={() => setBereich({ name: ziel })}
className={`rounded-full px-4 py-1.5 text-[13px] ${
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
}`}
>
{text}
</button>
)
// ── Fußleiste (5.5.0): der Daumenwert des Systems in sechs LEDs ─────
const w = verbindung.werkzeuge
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
const s = verbindung.schluessel
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.weiterBetrieb ? 'warn' : 'rot'
const arbeit = verbindung.speicher?.arbeit ?? null
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
return (
<div className="relative flex min-h-screen flex-col bg-buehne text-schnee">
<div className="buehnenlicht pointer-events-none fixed inset-0" />
<div className="filmkorn pointer-events-none fixed inset-0" />
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
{/* 5.6.0: Die Kopfzeile bleibt beim Scrollen stehen (Commander) —
sticky statt relative, mit Bühnen-Hintergrund und Weichzeichner. */}
<header className="ziehbar sticky top-0 z-40 flex-none border-b border-tinte/8 bg-buehne/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
<div className="flex min-w-0 items-center gap-3">
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
<span className="hidden rounded-md border border-tinte/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
{einRipLaeuft && (
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
REC
</span>
)}
</div>
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
{knopf('uebersicht', 'Übersicht')}
{knopf('einstellungen', 'Einstellungen')}
</nav>
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
<IconMinimieren />
</FensterKnopf>
<FensterKnopf titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'} aufKlick={() => void window.rippy.fensterSteuern('maximieren')}>
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
</FensterKnopf>
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
<FensterKnopf titel="In den Infobereich (Rippy läuft weiter)" rot aufKlick={() => void window.rippy.fensterSteuern('schliessen')}>
<IconSchliessen />
</FensterKnopf>
</div>
</div>
</header>
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
{!setupOffen && bereich.name === 'uebersicht' && (
<Uebersicht verbindung={verbindung} haupt={haupt} systemFehler={systemFehler} aufRohDateien={() => setBereich({ name: 'einstellungen', start: 'roh' })} />
)}
{!setupOffen && bereich.name === 'einstellungen' && <Einstellungen verbindung={verbindung} haupt={haupt} start={bereich.start} />}
</main>
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-tinte/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
<Led ton={verbindung.kern !== null ? 'gruen' : haupt?.kern.laeuft === true ? 'warn' : 'rot'} text="KERN" titel={verbindung.kern !== null ? `läuft (PID ${verbindung.kern.pid})` : 'wartet'} />
<Led ton={db === null ? 'grau' : db.ok ? 'gruen' : 'rot'} text="DATENBANK" titel={db !== null && db.ok ? db.pfad : db?.fehler} />
<Led
ton={werkzeugTon}
text="WERKZEUGE"
titel={w === null ? 'werden gesucht' : `makemkvcon ${w.makemkvcon.length > 0 ? 'da' : 'FEHLT'} · HandBrake ${w.handbrake.length > 0 ? 'da' : 'fehlt'}`}
/>
<Led ton={schluesselTon} text="SCHLÜSSEL" titel={s?.text} />
<Led
ton={speicherTon}
text={arbeit !== null && arbeit.ok ? `FREI ${gbKurz(arbeit.freiBytes)}` : 'SPEICHER'}
titel={arbeit === null ? 'wird gemessen' : arbeit.ok ? `${verbindung.speicher?.getrennt === true ? 'Arbeitsordner' : 'Ablage'}: ${arbeit.pfad}` : arbeit.fehler}
/>
<Led
ton={updateTon}
text={haupt !== null && haupt.update.neueVersion.length > 0 ? `UPDATE ${haupt.update.neueVersion}` : `v${haupt?.version ?? '…'}${haupt !== null && haupt.update.letzteErfolgreichePruefung.length > 0 ? ' AKTUELL' : ''}`}
titel={haupt?.update.text}
/>
<span className="ml-auto">Rippy v5 · LucyAI · Claude · KrBrZ</span>
</footer>
</div>
)
}
+473
View File
@@ -0,0 +1,473 @@
// 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`
}
export function datumKurz(iso: string | null): string {
if (iso === null || iso.length < 16) return '—'
return iso.slice(0, 16).replace('T', ' ')
}
/** 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])
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={() => {
if (entwurf !== wert) aufAendern(entwurf)
}}
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)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
<div
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
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,500 @@
// ── 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.
import { useState } from 'react'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
SpeicherStand,
SprachEintrag,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten } 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>
)
}
/** 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.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 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 ? (
<p className="mt-4 font-mono text-xs text-nebel">
Rippy liest die Titel der Disc das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
</p>
) : stand.fehler.length > 0 ? (
<p className="mt-4 font-mono text-xs text-rot"> {stand.fehler}</p>
) : (
<>
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
{stand.unsicher ? '⚠ ' : ''}
{stand.einordnung}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<button
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
)
}
className="rounded-lg border border-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'
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>
{/* 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. */}
{an && (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} (${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={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)
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, 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">{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,664 @@
// 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 { 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,
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 => 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.
</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)
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 && (
<button onClick={() => vorgangRohLoeschen(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 jetzt löschen">
Roh-Dateien löschen
</button>
)}
{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>
<button onClick={() => fremdenOrdnerLoeschen(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>
</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>
) : (
<>
<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.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
</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: {s.geholtAm.slice(0, 16).replace('T', ' ')}
{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.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"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
</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">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: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>}
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden die Karte in der Übersicht zeigt, was neu ist.</p>}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => void window.rippy.updatePruefen()}
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
className={KNOPF_LEISE}
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
>
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
</button>
</div>
</SektionsKarte>
)
}
function WerkzeugZeile({
name,
pfad,
fehlt,
pflicht,
schluessel,
wert,
}: {
name: string
pfad: string | null
fehlt: string
pflicht: boolean
schluessel: string
wert: string
}) {
return (
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
</p>
<PfadFeld
name="Eigener Pfad"
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
wert={wert}
art="programm"
dialogTitel={`${name} wählen`}
aufAendern={(w) => setzen(schluessel, w)}
/>
</div>
)
}
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const backends = verbindung.werkzeuge?.backends ?? []
return (
<SektionsKarte
id="werkzeuge"
titel="WERKZEUGE"
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
>
<WerkzeugZeile
name="makemkvcon"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
pflicht={true}
schluessel="werkzeug.makemkv"
wert={werte['werkzeug.makemkv'] ?? ''}
/>
<WerkzeugZeile
name="HandBrakeCLI"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
pflicht={false}
schluessel="werkzeug.handbrake"
wert={werte['werkzeug.handbrake'] ?? ''}
/>
<WerkzeugZeile
name="flac"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
fehlt="nicht gefunden — nur für Audio-CDs nötig"
pflicht={false}
schluessel="werkzeug.flac"
wert={werte['werkzeug.flac'] ?? ''}
/>
{verbindung.werkzeuge !== null && (
<p className="font-mono text-[10.5px] text-nebel">
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
</p>
)}
</SektionsKarte>
)
}
@@ -0,0 +1,68 @@
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
export function SprachAuswahl({
name,
hinweis,
wert,
aufAendern,
}: {
name: string
hinweis: string
wert: string
aufAendern: (wert: string) => void
}) {
const gewaehlt = sprachlisteAus(wert)
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
const verschieben = (code: string, richtung: -1 | 1): void => {
const i = gewaehlt.indexOf(code)
const j = i + richtung
if (i < 0 || j < 0 || j >= gewaehlt.length) return
const neu = [...gewaehlt]
;[neu[i], neu[j]] = [neu[j], neu[i]]
setzen(neu)
}
return (
<div>
<span className="text-xs font-semibold text-dunst">{name}</span>
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-tinte/12 bg-samt px-2 py-1.5">
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
{gewaehlt.map((code, i) => (
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
<span>{sprachName(code)}</span>
<span className="font-mono text-[10px] text-nebel">{code}</span>
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
</button>
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
</button>
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
×
</button>
</span>
))}
<select
value=""
onChange={(e) => {
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
}}
className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
>
<option value="">+ Sprache </option>
{frei.map((s) => (
<option key={s.code} value={s.code}>
{s.name} ({s.code})
</option>
))}
</select>
</div>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
export {}
declare global {
interface Window {
rippy: {
bereit(): void
aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null>
dateiWaehlen(titel: string): Promise<string | null>
ordnerOeffnen(pfad: string): Promise<string>
protokollOeffnen(): Promise<string>
autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
import '@fontsource-variable/bricolage-grotesque'
import '@fontsource/instrument-sans/400.css'
import '@fontsource/instrument-sans/500.css'
import '@fontsource/instrument-sans/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/700.css'
import './stil.css'
createRoot(document.getElementById('wurzel')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="./haupt.tsx"></script>
</body>
</html>
+422
View File
@@ -0,0 +1,422 @@
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type AutomatikStand,
type BeschaffungStand,
type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo,
type KernNachricht,
type LaufwerkBericht,
type MetaVorschlag,
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus,
type SpeicherStand,
type TitelStand,
type VorgaengeStand,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
* Vorgang beginnt. */
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
version: string
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null,
* solange der Kern noch nichts geschickt hat. */
vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht>
/** Freier Platz an Arbeitsordner und Ablage (5.5.0). */
speicher: SpeicherStand | null
/** Automatik-Countdown je Laufwerk (5.5.0). */
automatik: Record<string, AutomatikStand>
/** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */
poster: string[]
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 30
export const LEERE_VERBINDUNG: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
speicher: null,
automatik: {},
poster: [],
meldungen: [],
}
const stand: Verbindung = { ...LEERE_VERBINDUNG }
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
gesundheit: { ...stand.gesundheit },
automatik: { ...stand.automatik },
poster: [...stand.poster],
meldungen: [...stand.meldungen],
}
}
function melden(): void {
for (const h of horcher) h(kopie())
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
break
case 'pong':
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'kern-hinweis':
meldung(nachricht.text)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
stand.automatik = rest
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'disc-info': {
const vorher = stand.discInfos[nachricht.info.id]
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
if (neuErkannt) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
for (const h of nachricht.info.hinweise) {
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
}
}
break
}
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'beschaffung-status':
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'vorgaenge':
stand.vorgaenge = nachricht.stand
break
case 'kompression-ergebnis':
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
break
case 'laufwerk-gesundheit':
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
}
break
case 'speicher':
stand.speicher = nachricht.stand
break
case 'automatik':
stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand }
break
case 'poster-vorschau':
stand.poster = nachricht.pfade
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
// wiederholen den Text — nur echte Änderungen zählen).
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
stand.ripProtokoll = {
...stand.ripProtokoll,
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
}
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
)
} else if (nachricht.status.phase === 'fehler') {
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
} else if (nachricht.status.phase === 'abgebrochen') {
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
}
break
}
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'vorgaenge-laden' })
senden({ art: 'speicher-laden' })
melden()
}
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
export function verbindungStarten(): void {
if (gestartet) return
gestartet = true
window.addEventListener('message', (ereignis: MessageEvent) => {
const daten: unknown = ereignis.data
if (
typeof daten === 'object' &&
daten !== null &&
(daten as Record<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h(kopie())
return () => horcher.delete(h)
}
function senden(nachricht: FensterNachricht): void {
port?.postMessage(nachricht)
}
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
function anKern(nachricht: FensterNachricht, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
/** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
export function ripUeberspringen(id: string): void {
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function rettungStarten(id: string): void {
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
senden({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
senden({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
}
export function schluesselPruefen(): void {
senden({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
}
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
export function vorgaengeLaden(): void {
senden({ art: 'vorgaenge-laden' })
}
export function vorgangRohLoeschen(vorgangId: string): void {
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
}
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
}
export function kompressionAbbrechen(vorgangId: string): void {
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
}
export function rohAufraeumen(): void {
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
}
export function fremdenOrdnerLoeschen(ordner: string): void {
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
}
export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
}
// ── 5.5.0 ───────────────────────────────────────────────────────────────
export function kompressionPause(an: boolean): void {
anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter')
}
export function automatikStopp(id: string): void {
senden({ art: 'automatik-stopp', id })
}
export function automatikJetzt(id: string): void {
anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt')
}
export function benachrichtigungTesten(): void {
anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht')
}
export function posterLaden(): void {
senden({ art: 'poster-laden' })
}
export function speicherLaden(): void {
senden({ art: 'speicher-laden' })
}
+126
View File
@@ -0,0 +1,126 @@
@import 'tailwindcss';
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
Geräte-Präzision — Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
@theme {
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
--font-sans: 'Instrument Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', Consolas, monospace;
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
--color-buehne: oklch(0.13 0.012 60);
--color-karte: oklch(0.17 0.015 60);
--color-samt: oklch(0.16 0.015 60);
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
--color-gold-hell: oklch(0.88 0.09 80);
--color-gold: oklch(0.83 0.11 75);
--color-gold-tief: oklch(0.74 0.13 60);
/* Die Aufnahme-LED aus dem Schaltpult. */
--color-rec: oklch(0.72 0.17 45);
/* Schriftstufen — warmes Weiß bis Nebel. */
--color-schnee: oklch(0.93 0.01 80);
--color-dunst: oklch(0.68 0.015 80);
--color-nebel: oklch(0.55 0.015 80);
/* Zustandsfarben, alle in der warmen Familie. */
--color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25);
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
--color-tinte: oklch(1 0 0);
}
:root {
color-scheme: dark;
}
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer — liegt als
festes Bühnenbild hinter allem. */
.buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
}
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
.filmkorn {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
opacity: 0.05;
mix-blend-mode: overlay;
}
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
.segment-gluehen {
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
}
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
Windows-Rahmen mehr (frame:false) — gezogen wird an der Kopfzeile.
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
.ziehbar {
-webkit-app-region: drag;
}
.nicht-ziehbar {
-webkit-app-region: no-drag;
}
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
::-webkit-scrollbar-track { background: transparent; }
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
nach oben; die Liste ist verdoppelt, bei -50 % beginnt sie nahtlos von
vorn. Dauer und Versatz setzt die Komponente je Spalte. */
@keyframes poster-lauf {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
.poster-spalte {
animation-name: poster-lauf;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
/* ── Hell-Modus (5.6.0, Commander: „White Mode") ─────────────────────────
Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als
dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet
über data-design auf <html> (App.tsx, Einstellung „design": dunkel /
hell / system). */
:root[data-design="hell"] {
color-scheme: light;
--color-buehne: oklch(0.965 0.008 80);
--color-karte: oklch(0.99 0.004 80);
--color-samt: oklch(0.94 0.01 80);
--color-gold-hell: oklch(0.6 0.12 72);
--color-gold: oklch(0.66 0.13 70);
--color-gold-tief: oklch(0.56 0.13 58);
--color-rec: oklch(0.6 0.19 40);
--color-schnee: oklch(0.2 0.012 60);
--color-dunst: oklch(0.4 0.015 60);
--color-nebel: oklch(0.55 0.015 60);
--color-gruen: oklch(0.52 0.15 150);
--color-warn: oklch(0.58 0.13 80);
--color-rot: oklch(0.55 0.2 25);
--color-tinte: oklch(0.15 0.01 60);
}
:root[data-design="hell"] .buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%);
}
:root[data-design="hell"] .filmkorn {
opacity: 0.03;
}
@@ -0,0 +1,81 @@
// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und
// Kompression sind entkoppelt — hier steht, was gerade rechnet und was
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
// wird fertig, der nächste wartet.
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste'
import { restText } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
const l = stand.laeuft
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-tinte/25'}`} />
<span className="text-dunst">KOMPRESSION</span>
<span>·</span>
<span>
{l !== null ? 'läuft' : stand.pausiert ? 'angehalten' : 'wartet'}
{stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`}
</span>
<button
onClick={() => kompressionPause(!stand.pausiert)}
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-tinte/15 text-dunst hover:text-schnee'
}`}
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
>
{stand.pausiert ? '▶ Weiter' : '⏸ Pausieren'}
</button>
</div>
{l !== null && (
<div className="mt-3 rounded-xl border border-tinte/10 bg-samt/80 p-4">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
<div className="mt-2">
<VorgangsLeiste stand={leisteAusKompression(l)} restText={restText(l.restS)} />
</div>
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
&gt; {l.text}
</p>
{stand.pausiert && <p className="mt-1 font-mono text-[10.5px] text-warn">Pause: Dieser Film wird fertig, danach wartet die Schlange.</p>}
</div>
{l.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{l.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
<button
onClick={() => kompressionAbbrechen(l.vorgangId)}
className="mt-3 rounded-lg border border-rot/50 bg-rot/10 px-3 py-1 text-[11px] font-semibold text-rot hover:bg-rot/20"
title="Die Roh-Dateien bleiben liegen — unter Einstellungen → Roh-Dateien geht es später weiter"
>
Kompression abbrechen
</button>
</div>
)}
{stand.wartend.length > 0 && (
<div className="mt-3 space-y-1">
{stand.wartend.map((w, i) => (
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-1.5">
<VorgangsLeiste stand={leisteAusKompression(w)} kompakt />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-dunst">
Platz {i + 1} · {w.titel}
{stand.pausiert ? ' · wartet auf Weiter' : ''}
</span>
<button onClick={() => kompressionAbbrechen(w.vorgangId)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-rot">
aus der Schlange nehmen
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,475 @@
// 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, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
IconDisc,
IconEject,
IconPlay,
IconStift,
IconStop,
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
TYP_NAMEN,
backdropUrl,
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] ?? ''
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
}
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 auswaehlen — 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 ${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 naechsten weitermachen"
>
Titel ueberspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
{datei}
</p>
))}
{/* Nach dem Rip (5.6.0, Commander: „Nur in der Kompressions-Karte"):
hier nur noch EINE Zeile, wo es weitergeht — Balken, Prozent,
Restzeit und Ziel stehen in der Kompressions-Karte, danach im
Verlauf. Fehler bleiben sichtbar (R4). */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<p className={`mt-2 font-mono text-[11px] ${vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? 'text-warn' : 'text-nebel'}`}>
{kompression !== null
? 'Kompression läuft — Stand in der Kompressions-Karte unten.'
: wartePlatz > 0
? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).`
: vorgang.phase === 'fertig'
? 'Kompression fertig — das Ergebnis steht im Verlauf.'
: vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen'
? `${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}`
: `Kompression: ${vorgang.phase}`}
</p>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{rip.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
{protokoll.length > 1 && (
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
</button>
)}
{protokollOffen && (
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
{protokoll.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
&gt; {zeile}
</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,86 @@
// Speicher und Statistik in der Übersicht (5.5.0, Commander: „Ist mir zu
// leer … wie viel Speicher noch auf dem Server oder auf dem Arbeitsordner
// frei ist"). Gemessen im Kern mit fs.statfs — lokal wie auf der Freigabe.
import type { SpeicherPlatz, SpeicherStand, Statistik } from '../../gemeinsam/nachrichten'
import { FuellBalken, dauerGrob, gbKurz } from '../bausteine'
import { speicherLaden } from '../kernverbindung'
/** Ab hier wird gewarnt: eine Blu-ray braucht rund 50 GB (Roh + Kompression). */
export const WENIG_FREI_BYTES = 60e9
export const SEHR_WENIG_FREI_BYTES = 20e9
function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; hinweis: string }) {
const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold'
const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0
return (
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[10px] tracking-[0.14em] text-nebel">{name}</span>
<span className={`font-mono text-[13px] font-semibold ${ton === 'rot' ? 'text-rot' : ton === 'warn' ? 'text-warn' : 'text-schnee'}`}>
{platz.ok ? `${gbKurz(platz.freiBytes)} frei` : 'nicht messbar'}
</span>
</div>
<div className="mt-2">
<FuellBalken anteil={belegt} ton={ton} />
</div>
<p className="mt-1.5 break-all font-mono text-[10.5px] text-nebel" title={platz.pfad}>
{platz.pfad}
{platz.ok && ` · ${gbKurz(platz.gesamtBytes)} gesamt`}
{!platz.ok && platz.fehler.length > 0 && ` · ${platz.fehler}`}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-[10.5px] text-nebel/80">{hinweis}</span>
{platz.ok && (
<button onClick={() => void window.rippy.ordnerOeffnen(platz.pfad)} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Im Explorer zeigen">
Ordner öffnen
</button>
)}
</div>
</div>
)
}
export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) {
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">SPEICHER UND BILANZ</span>
<button onClick={() => speicherLaden()} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Jetzt neu messen">
neu messen
</button>
</div>
{speicher === null ? (
<p className="mt-3 font-mono text-xs text-nebel">wird gemessen </p>
) : (
<div className={`mt-3 grid gap-3 ${speicher.getrennt ? 'sm:grid-cols-2' : ''}`}>
{speicher.getrennt ? (
<>
<Platz name="ARBEITSORDNER (LOKAL)" platz={speicher.arbeit} hinweis="Roh-Dateien und Kompression — eine Blu-ray braucht rund 50 GB." />
<Platz name="ABLAGE (SERVER)" platz={speicher.ablage} hinweis="Fertige Filme, Serien und Musik." />
</>
) : (
<Platz name="ABLAGE UND ARBEIT" platz={speicher.ablage} hinweis="Roh-Dateien, Kompression und Fertiges am selben Ort — ein eigener Arbeitsordner geht unter Einstellungen → Ablage." />
)}
</div>
)}
{statistik !== null && statistik.discs > 0 && (
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Zahl label="DISCS" wert={String(statistik.discs)} zusatz={`${statistik.fertige} fertig`} />
<Zahl label="VERLUSTFREI GESICHERT" wert={gbKurz(statistik.rohBytes)} zusatz="Summe aller Rips" />
<Zahl label="FERTIG ABGELEGT" wert={gbKurz(statistik.fertigBytes)} zusatz="komprimiert oder verschoben" />
<Zahl label="JE DISC" wert={statistik.fertige > 0 ? dauerGrob(statistik.dauerS / statistik.fertige) : '—'} zusatz="Rip bis Ablage, im Schnitt" />
</div>
)}
</div>
)
}
function Zahl({ label, wert, zusatz }: { label: string; wert: string; zusatz: string }) {
return (
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className="mt-0.5 font-display text-[19px] font-bold leading-tight text-gold-hell">{wert}</div>
<div className="font-mono text-[10px] text-nebel/80">{zusatz}</div>
</div>
)
}
@@ -0,0 +1,184 @@
// Die Übersicht — Laufwerke, Warteschlange, Speicher und Bilanz, Verlauf,
// Ereignisse. Und die Dialoge: Sie leben im Zustand DIESES Bereichs, nicht
// in der Kachel (5.4.0: ein leerer Takt der Wache räumte sonst Kachel UND
// Dialog ab).
import { useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import { IconDisc, LAUFENDE_PHASEN } from '../bausteine'
import { AendernDialog } from '../dialoge/AendernDialog'
import { TitelWahlDialog } from '../dialoge/TitelWahlDialog'
import { automatikStopp, titelLesen, type Verbindung } from '../kernverbindung'
import { KompressionsKarte } from './KompressionsKarte'
import { LaufwerkKachel } from './LaufwerkKachel'
import { SpeicherKarte } from './SpeicherKarte'
import { VerlaufKarte } from './VerlaufKarte'
export function Uebersicht({
verbindung,
haupt,
systemFehler,
aufRohDateien,
}: {
verbindung: Verbindung
haupt: HauptStatus | null
systemFehler: string[]
aufRohDateien: () => void
}) {
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
const [updateHinweis, setUpdateHinweis] = useState('')
const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null)
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<>
{systemFehler.length > 0 && (
<div className="rounded-2xl border border-rot/40 bg-rot/10 p-4">
{systemFehler.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-rot">
{zeile}
</p>
))}
</div>
)}
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
Satz in den Einstellungen. Der Commander musste es am
01.09.2026 suchen — gefunden hat er es nicht. */}
{haupt !== null && haupt.update.neueVersion.length > 0 && (
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-lg text-gold-hell">Update verfügbar Version {haupt.update.neueVersion}</span>
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-tinte/10">
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
</div>
)}
{haupt.update.bereit && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setUpdateHinweis('')
void window.rippy.updateInstallieren().then((urteil) => {
if (!urteil.ok) setUpdateHinweis(urteil.grund)
})
}}
disabled={einRipLaeuft}
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
title={einRipLaeuft ? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.' : 'Rippy beendet sich, installiert und startet wieder.'}
>
Jetzt neu starten und installieren
</button>
<span className="font-mono text-[11px] text-nebel"> oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.</span>
</div>
)}
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
</div>
)}
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-tinte/10 bg-karte/50 p-6">
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
</div>
) : (
<div className="space-y-4">
{verbindung.laufwerke.map((geraet) => {
const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? ''
const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null
const wartend = verbindung.vorgaenge?.kompression.wartend ?? []
return (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
ripStartMs={verbindung.ripStart[geraet.id]}
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
verbindung={verbindung}
bericht={verbindung.gesundheit[geraet.id]}
vorgang={verbindung.vorgaenge?.vorgaenge.find((v) => v.id === ripVorgangId)}
kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null}
wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1}
automatik={verbindung.automatik[geraet.id]}
aufRippen={() => {
// Der Nutzer entscheidet selbst — ein Countdown endet.
automatikStopp(geraet.id)
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
setDialog({ art: 'titel', id: geraet.id })
titelLesen(geraet.id)
}}
aufAendern={() => {
automatikStopp(geraet.id)
setDialog({ art: 'aendern', id: geraet.id })
}}
/>
)
})}
</div>
)}
</section>
{verbindung.vorgaenge !== null && (
<section>
<KompressionsKarte stand={verbindung.vorgaenge.kompression} />
</section>
)}
<section>
<SpeicherKarte speicher={verbindung.speicher} statistik={verbindung.vorgaenge?.statistik ?? null} />
</section>
{verbindung.vorgaenge !== null && (
<section>
<VerlaufKarte vorgaenge={verbindung.vorgaenge.vorgaenge} aufAlle={aufRohDateien} />
</section>
)}
{dialog !== null && dialog.art === 'titel' && (
<TitelWahlDialog
id={dialog.id}
typ={verbindung.laufwerke.find((l) => l.id === dialog.id)?.typ ?? 'bluray'}
stand={verbindung.discTitel[dialog.id]}
info={verbindung.discInfos[dialog.id]}
einstellungen={verbindung.einstellungen}
regel={verbindung.vorgaenge?.regel ?? ''}
speicher={verbindung.speicher}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{dialog !== null && dialog.art === 'aendern' && (
<AendernDialog
id={dialog.id}
startText={verbindung.discInfos[dialog.id]?.titel ?? ''}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{verbindung.meldungen.length > 0 && (
<section>
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-tinte/10 bg-karte/50 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[11px] text-dunst">
{zeile}
</p>
))}
</div>
</section>
)}
</>
)
}
@@ -0,0 +1,64 @@
// Der Verlauf (5.5.0) — die letzten Vorgänge auf einen Blick, statt der
// Bibliothek (Commander: „Brauchen wir die wirklich?" — nein: Jellyfin IST
// die Bibliothek; die Wiedererkennung braucht keine Ansicht).
import type { GeraeteInfo, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { leisteAusVorgang } from '../../gemeinsam/vorgangsleiste'
import { Badge, TYP_NAMEN, datumKurz, gb } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
const PHASEN_TEXT: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
wartet: 'wartet',
komprimiert: 'komprimiert',
'legt-ab': 'legt ab',
fertig: 'fertig',
fehler: 'Fehler',
abgebrochen: 'abgebrochen',
}
export function phasenTon(p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' {
return p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau'
}
export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag[]; aufAlle: () => void }) {
if (vorgaenge.length === 0) return null
const letzte = vorgaenge.slice(0, 6)
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">VERLAUF</span>
<span>·</span>
<span>die letzten {letzte.length} Vorgänge</span>
<button onClick={aufAlle} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst">
alle unter Roh-Dateien
</button>
</div>
<div className="mt-3 space-y-1">
{letzte.map((v) => (
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-display text-[14px] font-bold text-schnee" title={v.titel}>
{v.titel}
{v.jahr !== null && <span className="ml-1 font-medium text-dunst">({v.jahr})</span>}
</span>
<VorgangsLeiste stand={leisteAusVorgang(v)} kompakt />
<Badge text={PHASEN_TEXT[v.phase]} ton={phasenTon(v.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[v.typ as GeraeteInfo['typ']] ?? v.typ).toUpperCase()} · {datumKurz(v.begonnen)}
{v.phase === 'fertig' && v.fertigBytes > 0 && ` · ${gb(v.fertigBytes)}`}
{!v.rohGeloescht && v.rohBytes > 0 && ` · Roh ${gb(v.rohBytes)}`}
</span>
{v.phase === 'fertig' && v.zielOrdner.length > 0 && (
<button
onClick={() => void window.rippy.ordnerOeffnen(v.zielOrdner)}
className="font-mono text-[10.5px] text-nebel hover:text-dunst"
title={v.zielOrdner}
>
Ordner öffnen
</button>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,90 @@
// Die Vorgangs-Leiste (5.6.0) — der ganze Weg einer Disc in Stationen, mit
// dem Segment-Balken in der aktiven Station. Logik in
// gemeinsam/vorgangsleiste.ts; hier nur das Zeichnen. `kompakt` = Punkte
// (Verlauf, Warteschlange).
import { Fragment } from 'react'
import { STATIONS_NAMEN, stationen, stationsZustand, type LeisteStand } from '../../gemeinsam/vorgangsleiste'
import { SegmentBalken } from '../bausteine'
export function VorgangsLeiste({ stand, restText, kompakt }: { stand: LeisteStand; restText?: string; kompakt?: boolean }) {
const liste = stationen(stand.rettung)
const zustaende = stationsZustand(liste, stand.station, stand.zustand)
const titel = `${STATIONS_NAMEN[stand.station]}: ${
stand.zustand === 'laeuft' ? 'läuft' : stand.zustand === 'wartet' ? 'wartet' : stand.zustand === 'fertig' ? 'fertig' : stand.zustand === 'fehler' ? 'Fehler' : 'abgebrochen'
}`
if (kompakt) {
return (
<span className="inline-flex flex-none items-center gap-1" title={titel} aria-label={titel}>
{liste.map((s, i) => (
<span
key={s}
className={`inline-block h-1.5 w-1.5 rounded-full ${
zustaende[i] === 'fertig'
? 'bg-gold'
: zustaende[i] === 'aktiv'
? stand.zustand === 'wartet'
? 'bg-gold/50'
: 'animate-pulse bg-gold-hell'
: zustaende[i] === 'fehler'
? 'bg-rot'
: 'bg-tinte/20'
}`}
/>
))}
</span>
)
}
return (
<div className="flex flex-wrap items-center gap-1.5" title={titel}>
{liste.map((s, i) => {
const z = zustaende[i]
const aktiv = z === 'aktiv'
const rahmen =
z === 'fehler'
? 'border-rot/50 bg-rot/10'
: aktiv
? 'min-w-[200px] flex-1 border-gold/50 bg-gold/10'
: z === 'fertig'
? 'border-gold/30 bg-gold/5'
: 'border-tinte/10'
return (
<Fragment key={s}>
{i > 0 && <span className={`h-px w-3 flex-none ${zustaende[i - 1] === 'fertig' ? 'bg-gold/60' : 'bg-tinte/15'}`} />}
<div className={`flex items-center gap-2 rounded-lg border px-2 py-1 ${rahmen}`}>
<span
className={`flex h-4 w-4 flex-none items-center justify-center rounded-full font-mono text-[9px] ${
z === 'fertig'
? 'bg-gold text-buehne'
: aktiv
? 'bg-gold-hell text-buehne'
: z === 'fehler'
? 'bg-rot text-buehne'
: 'border border-tinte/20 text-nebel'
}`}
>
{z === 'fertig' ? '✓' : z === 'fehler' ? '!' : String(i + 1)}
</span>
<span className={`font-mono text-[10px] tracking-[0.12em] ${aktiv ? 'text-gold-hell' : z === 'fertig' ? 'text-gold/80' : z === 'fehler' ? 'text-rot' : 'text-nebel'}`}>
{STATIONS_NAMEN[s].toUpperCase()}
</span>
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && (
<div className="min-w-[80px] flex-1">
<SegmentBalken prozent={stand.prozent} />
</div>
)}
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && <span className="font-mono text-[10.5px] text-gold-hell">{stand.prozent} %</span>}
{aktiv && stand.zustand === 'laeuft' && stand.prozent < 0 && stand.hinweis.length === 0 && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-gold-hell" />
)}
{aktiv && restText !== undefined && restText.length > 0 && <span className="font-mono text-[10px] text-nebel">{restText}</span>}
{aktiv && stand.hinweis.length > 0 && <span className="font-mono text-[10px] text-nebel">{stand.hinweis}</span>}
{z === 'fehler' && <span className="font-mono text-[10px] text-rot">{stand.zustand === 'abgebrochen' ? 'abgebrochen' : 'Fehler'}</span>}
</div>
</Fragment>
)
})}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
// baut auf diesen Namen ihre echten Ordner.
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
export function staffelOrdnerName(staffel: number): string {
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
}
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
const s = String(Math.trunc(staffel)).padStart(2, '0')
const e = String(Math.trunc(folge)).padStart(2, '0')
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
}
export interface VorschauEingabe {
ablage: string
alsSerie: boolean
titel: string
jahr: number | null
staffel: number | null
ersteFolge: number | null
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
hauptTitel: number
extras: number
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
vorgangsName: string
}
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
* weil die Vorschau eine Windows-Ablage beschreibt. */
export function zielVorschau(e: VorschauEingabe): [string, string] {
const wurzel = e.ablage.replace(/[\\/]+$/, '')
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
if (e.alsSerie) {
const staffel = e.staffel ?? 1
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
const n = Math.max(1, e.hauptTitel)
let inhalt: string
if (e.ersteFolge !== null && e.ersteFolge > 0) {
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
inhalt =
n === 1
? erste
: `${erste}${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
} else {
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
}
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
return [ordner, inhalt]
}
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
const teile: string[] = []
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
}
+746
View File
@@ -0,0 +1,746 @@
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
//
// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union
// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die
// Nachricht, und niemand erfährt es.
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
| 'rettet'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) die Kompression läuft
* danach unter dieser Kennung in der Warteschlange weiter. */
vorgangId?: string
/** 5.5.0: Rippy hat an dieser Disc Lesefehler GESEHEN (Defekt-Wächter,
* Lesefehler-Meldung, Leseabbruch) erst dann wird das Rettungs-Abbild
* angeboten. Eine heile Disc braucht es nicht (Commander 01.09.2026). */
rettungMoeglich?: boolean
/** 5.5.0: Stand des Rettungs-Abbilds — unlesbare Sektoren und Stellen. */
rettung?: { unlesbareSektoren: number; stellen: number }
}
/** Was ein Titel IST davon hängt ab, WOHIN er abgelegt wird:
* hauptfilm Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
* extra Filme/<Titel> (Jahr)/extras/ (Jellyfin/Emby/Kodi)
* folge Serien/<Titel>/Season NN/ S01E02.mkv
* EINE Definition für Kern und Fenster. */
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat
* (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren
* dieser Sprache es gibt (Stereo + 5.1 = 2). */
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
/** Ein Titel im Rip-Auftrag: welcher, als was und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
* bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
nr: number
rolle: TitelRolle
dauerS: number
/** Größe laut Info-Lauf gewichtet den durchgehenden Fortschrittsbalken
* (5.4.0). Fehlt: alle Titel zählen gleich. */
groesseBytes?: number
/** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
audioSprachen?: string[]
untertitelSprachen?: string[]
/** 5.6.0, nur für Extras: WELCHE Untertitel-Sprache beim Abspielen
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
* fehlend = Rippys Regel (fremder Ton Wunschsprache). */
untertitelVorwahl?: string | null
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
* (Double Feature) er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
film?: MetaVorschlag
}
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
* Staffel und erste Folge von Hand Rippy rät bewusst nicht, aber der
* Nutzer weiß, dass auf Disc 2 die Folgen 34 sind. */
export interface RipAuftragOptionen {
/** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */
staffel?: number | null
/** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend:
* Zuordnung über die TMDb-Laufzeiten wie bisher. */
ersteFolge?: number | null
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
rolle: TitelRolle
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
* deshalb trägt der Stand ein eigenes `laeuft` das Fenster steht in der
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
export interface TitelStand {
id: string
laeuft: boolean
titel: DiscTitelVorschlag[]
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
einordnung: string
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
fehler: string
}
/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */
export interface Darsteller {
name: string
rolle: string
/** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */
bildPfad: string
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
beschreibung: string
/** Datum des früheren Rips dieser Disc die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst 0, wenn es keines
* gibt. Ab zwei gilt Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
/** 'movie' | 'tv' | '' — was die Zuordnung sagt. */
typ: string
// ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ──
/** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */
logoPfad: string
tagline: string
/** TMDb vote_average (0 = keine). */
bewertung: number
/** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */
fsk: string
laufzeitMinuten: number
genres: string[]
besetzung: Darsteller[]
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat (Laufzeit
* bestätigt: 124 min"), Klartext-Zeilen. */
hinweise: string[]
/** 5.5.0 Serien-Merker: Was Rippy von der VORIGEN Disc dieser Serie weiß
* Staffel und die Folge, mit der es weitergeht. null, wenn nichts. */
serienMerker: { staffel: number; naechsteFolge: number; wann: string } | null
}
/** Ein Eintrag der Bibliothek (§ 6.6) seit 5.5.0 nur noch die
* Wiedererkennung (schon gerippt am "), keine eigene Ansicht mehr. */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad — '' wenn keiner bekannt. */
posterPfad: string
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand {
/** Welches Werkzeug — bisher nur 'makemkv'. */
werkzeug: string
laeuft: boolean
text: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
}
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die
* Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER
* verschoben). */
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen Roh-Dateien,
* Übersicht Verlauf). */
export interface VorgangEintrag {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
/** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */
rohBytes: number
rohGeloescht: boolean
zielOrdner: string
fertigBytes: number
komprimiert: boolean
preset: string
fehler: string
titelZahl: number
/** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */
neuKomprimierbar: boolean
/** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */
faelligAm: string
}
export interface KompressionStand {
vorgangId: string
titel: string
/** 0100; -1 = keine Angabe. */
prozent: number
text: string
phase: VorgangPhase
/** 5.6.0: Restzeit der laufenden Datei in Sekunden, wie HandBrake sie
* meldet (ETA); fehlend/-1 = noch keine Angabe. */
restS?: number
}
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
* 5.4.0 oder von Hand hineingelegt) nur von Hand löschbar. */
export interface FremderOrdner {
ordner: string
bytes: number
}
/** 5.5.0: Was Rippy insgesamt geschafft hat (aus den Vorgängen). */
export interface Statistik {
/** Vorgänge insgesamt (Video-Discs). */
discs: number
/** Davon fertig abgelegt. */
fertige: number
/** Verlustfrei gesichert, Summe aller Rips (auch wenn Roh inzwischen weg ist). */
rohBytes: number
/** Fertig abgelegt, Summe. */
fertigBytes: number
/** Summe der Dauer je fertigem Vorgang (Rip-Beginn bis Ablage-Ende) in Sekunden. */
dauerS: number
}
export interface VorgaengeStand {
vorgaenge: VorgangEintrag[]
/** Summe aller Roh-Ordner, die noch da sind. */
rohGesamtBytes: number
fremde: FremderOrdner[]
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
regel: string
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[]; pausiert: boolean }
statistik: Statistik
}
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
export interface LaufwerkBericht {
/** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */
urteil: string
text: string
fehlerhafteBloecke: number
controllerfehler: number
sonstige: number
/** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */
letzte: string[]
/** Betrachteter Zeitraum in Stunden. */
stunden: number
}
/** 5.5.0: Freier Platz an EINEM Ort — gemessen mit fs.statfs (Node 24). */
export interface SpeicherPlatz {
pfad: string
ok: boolean
freiBytes: number
gesamtBytes: number
/** '' wenn ok — sonst, warum nicht messbar (Pfad fehlt, Netz weg). */
fehler: string
}
export interface SpeicherStand {
/** Wo Roh-Dateien und Kompression liegen (Arbeitsordner oder Ablage). */
arbeit: SpeicherPlatz
/** Wo Fertiges landet (die Ablage — beim Commander der Server). */
ablage: SpeicherPlatz
/** true, wenn ein eigener Arbeitsordner gesetzt ist. */
getrennt: boolean
}
/** 5.5.0 Automatik: Countdown bis zum Rip ohne Klick (KONZEPT § 5 Zero-Click). */
export interface AutomatikStand {
id: string
aktiv: boolean
sekunden: number
text: string
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
/** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): Roh-
* Ordner von X gelöscht", „Kompression eingereiht" Auskunft, kein Alarm. */
| { art: 'kern-hinweis'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'installer-starten'; pfad: string }
| {
art: 'schluessel-status'
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
version: string
}
}
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
* (5.4.0) geht auch ans Haupt (Taskleiste, Updates warten). */
| { art: 'vorgaenge'; stand: VorgaengeStand }
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
/** 5.5.0: freier Platz an Arbeitsordner und Ablage. */
| { art: 'speicher'; stand: SpeicherStand }
/** 5.5.0: Automatik-Countdown je Laufwerk. */
| { art: 'automatik'; stand: AutomatikStand }
/** 5.5.0: Poster beliebter Filme für den Hintergrund der Erst-Einrichtung. */
| { art: 'poster-vorschau'; pfade: string[] }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
/** Titel-Lauf anstoßen (§ 6.2): dauert 20120 s, deshalb erst auf Zuruf
* und nicht schon beim Einlegen (der 30.08.-Fund das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
| { art: 'vorgaenge-laden' }
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
| { art: 'vorgang-roh-loeschen'; vorgangId: string }
/** Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel
* kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */
| { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string }
/** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */
| { art: 'kompression-abbrechen'; vorgangId: string }
/** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */
| { art: 'roh-aufraeumen' }
/** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */
| { art: 'fremden-ordner-loeschen'; ordner: string }
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
| { art: 'laufwerk-pruefen'; id: string }
// ── 5.5.0 ──
/** Die Kompressions-Warteschlange anhalten (an) oder weiterlaufen lassen. */
| { art: 'kompression-pause'; an: boolean }
/** Den Automatik-Countdown dieses Laufwerks stoppen bzw. sofort rippen. */
| { art: 'automatik-stopp'; id: string }
| { art: 'automatik-jetzt'; id: string }
/** Testnachricht über die eingerichteten Kanäle (Discord, Telegram). */
| { art: 'benachrichtigung-testen' }
/** Poster beliebter Filme laden (Erst-Einrichtung). */
| { art: 'poster-laden' }
/** Rettungs-Abbild für eine Disc mit GESEHENEN Lesefehlern. */
| { art: 'rettung-start'; id: string }
/** Freien Platz neu messen. */
| { art: 'speicher-laden' }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
| { art: 'fenster-port' }
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
| { art: 'installer-ergebnis'; fehler: string | null }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean
/** Selbst-Update (§ 6.8). */
update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
/** 5.5.0: Wo die Protokoll-Dateien liegen — für „Protokoll öffnen". */
protokollOrdner: string
}
/** Der Stand des Selbst-Updates (§ 6.8) EINE Definition, die sich Haupt
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
neueVersion: string
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
neuerungen: string
/** Geladen und wartet auf den Neustart. */
bereit: boolean
/** Eine Prüfung oder ein Download läuft gerade. */
laeuft: boolean
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
fortschritt: number
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
function istText(wert: unknown): wert is string {
return typeof wert === 'string'
}
function istKennung(wert: unknown): wert is string {
return typeof wert === 'string' && wert.length > 0
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
case 'kern-hinweis':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
case 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
)
case 'installer-starten':
return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
case 'vorgaenge':
return (
istObjekt(wert.stand) &&
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
istObjekt((wert.stand as Record<string, unknown>).statistik)
)
case 'kompression-ergebnis':
return (
istKennung(wert.vorgangId) &&
typeof wert.titel === 'string' &&
typeof wert.ok === 'boolean' &&
typeof wert.text === 'string'
)
case 'laufwerk-gesundheit':
return (
istKennung(wert.id) &&
istObjekt(wert.bericht) &&
typeof (wert.bericht as Record<string, unknown>).urteil === 'string' &&
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
)
case 'speicher':
return (
istObjekt(wert.stand) &&
istObjekt((wert.stand as Record<string, unknown>).arbeit) &&
istObjekt((wert.stand as Record<string, unknown>).ablage) &&
typeof (wert.stand as Record<string, unknown>).getrennt === 'boolean'
)
case 'automatik':
return (
istObjekt(wert.stand) &&
istKennung((wert.stand as Record<string, unknown>).id) &&
typeof (wert.stand as Record<string, unknown>).aktiv === 'boolean' &&
typeof (wert.stand as Record<string, unknown>).sekunden === 'number' &&
typeof (wert.stand as Record<string, unknown>).text === 'string'
)
case 'poster-vorschau':
return Array.isArray(wert.pfade) && wert.pfade.every(istText)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
typeof wert.id === 'string' &&
typeof wert.phase === 'string' &&
typeof wert.prozent === 'number' &&
typeof wert.text === 'string' &&
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean'
)
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt die Sprachlisten
* sind optional, aber wenn sie da sind, müssen es Textlisten sein. */
export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (!istObjekt(wert)) return false
if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false
if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false
if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false
for (const feld of ['audioSprachen', 'untertitelSprachen']) {
const liste = wert[feld]
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false
}
return true
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'rip-start') {
if (!istKennung(wert.id)) return false
if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) {
return false
}
if (wert.optionen !== undefined) {
if (!istObjekt(wert.optionen)) return false
const o = wert.optionen
const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number'
if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false
}
return true
}
if (
wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen' ||
wert.art === 'laufwerk-pruefen' ||
wert.art === 'automatik-stopp' ||
wert.art === 'automatik-jetzt' ||
wert.art === 'rettung-start'
) {
return istKennung(wert.id)
}
if (wert.art === 'kompression-pause') {
return typeof wert.an === 'boolean'
}
if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return istKennung(wert.id) && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag)
}
if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') {
return istKennung(wert.vorgangId)
}
if (wert.art === 'vorgang-neu-komprimieren') {
return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string')
}
if (wert.art === 'fremden-ordner-loeschen') {
return istKennung(wert.ordner)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen' ||
wert.art === 'benachrichtigung-testen' ||
wert.art === 'poster-laden' ||
wert.art === 'speicher-laden'
)
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'fenster-port') return true
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
return false
}
+33
View File
@@ -0,0 +1,33 @@
// Die Platz-Prüfung vor dem Rip (5.5.0) — EINE Rechnung für Kern und
// Fenster: Roh plus geschätzt 25 % für die Kompression, plus 2 GB Luft.
// Der Kern urteilt beim Start (index.ts ripMitPlatz — auch für die
// Automatik), der Dialog zeigt dasselbe Urteil schon vor dem Klick
// (TitelWahlDialog).
export const PLATZ_LUFT_BYTES = 2e9
export const KOMPRESSION_ANTEIL = 0.25
export interface PlatzUrteil {
/** Leer, wenn der Platz reicht — sonst der Grund in Klartext. */
text: string
/** true: nicht einmal der verlustfreie Rip passt — der Start wird verweigert. */
hart: boolean
}
export function gbText(bytes: number): string {
return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
}
export function platzUrteil(freiBytes: number, rohBytes: number, komprimiert: boolean): PlatzUrteil {
const noetig = rohBytes + (komprimiert ? rohBytes * KOMPRESSION_ANTEIL : 0) + PLATZ_LUFT_BYTES
if (freiBytes >= noetig) return { text: '', hart: false }
if (freiBytes < rohBytes + PLATZ_LUFT_BYTES) {
return {
text: `Zu wenig Platz: ${gbText(freiBytes)} frei, der Rip braucht ${gbText(rohBytes)} verlustfrei. Das geht schief — erst Platz schaffen (Einstellungen → Roh-Dateien).`,
hart: true,
}
}
return {
text: `Knapp: ${gbText(freiBytes)} frei, gebraucht werden etwa ${gbText(noetig)} (Roh plus Kompression). Aufräumen unter Roh-Dateien hilft.`,
hart: false,
}
}
@@ -0,0 +1,44 @@
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
// (VCN) denselben Film in 2 statt 60+ Minuten.
//
// Regeln:
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
// Name (die „Invalid preset"-Falle).
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
// DVDs richtig; hochskaliert wird nichts).
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'],
['nvenc', 'NVENC'],
['qsv', 'QSV'],
]
/** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
export function presetEmpfehlung(
discTyp: string,
backends: readonly string[],
presets: readonly string[],
): string {
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
for (const [backend, kennung] of FAMILIEN) {
if (!backends.includes(backend)) continue
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
if (treffer !== undefined) return treffer
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner
}
return DEFAULT_HB_PRESET
}
+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(',')
}
@@ -0,0 +1,143 @@
// Die Vorgangs-Leiste (5.6.0, Commander: „Phase 14 plus Komprimierung —
// irgendwie zu einem machen, der Phasen-Balken gefällt mir") — EIN Weg für
// jede Disc, vier Stationen: Titel lesen → Rippen → Komprimieren → Ablegen,
// bei Bedarf das Rettungs-Abbild als Zwischenstation vor dem Rippen. Die
// Leiste zeigt immer den GANZEN Weg, aber nur an einer Stelle die Details:
// in der Laufwerks-Kachel, solange das Laufwerk arbeitet (Stationen 12),
// in der Kompressions-Karte danach (34), im Verlauf als Punkte.
//
// Hier liegt die REINE Logik (welche Station ist fertig, aktiv, offen,
// gescheitert) — testbar ohne React; gezeichnet wird in
// fenster/uebersicht/VorgangsLeiste.tsx.
import type { KompressionStand, RipStatus, VorgangEintrag } from './nachrichten'
export type Station = 'titel' | 'rettung' | 'rip' | 'kompression' | 'ablage'
export type LeisteZustand = 'laeuft' | 'wartet' | 'fertig' | 'fehler' | 'abgebrochen'
export type StationsZustand = 'fertig' | 'aktiv' | 'offen' | 'fehler'
export interface LeisteStand {
station: Station
zustand: LeisteZustand
/** 0100 in der aktiven Station, -1 = keine Angabe. */
prozent: number
/** Das Rettungs-Abbild als Zwischenstation zeigen. */
rettung: boolean
/** Kurztext in der aktiven Station („Platz 2", „läuft — Karte unten"). */
hinweis: string
}
export const STATIONS_NAMEN: Record<Station, string> = {
titel: 'Titel lesen',
rettung: 'Rettungs-Abbild',
rip: 'Rippen',
kompression: 'Komprimieren',
ablage: 'Ablegen',
}
export function stationen(rettung: boolean): Station[] {
return rettung ? ['titel', 'rettung', 'rip', 'kompression', 'ablage'] : ['titel', 'rip', 'kompression', 'ablage']
}
/** Zustand je Station: alles vor der aktiven ist fertig, die aktive läuft
* (oder ist gescheitert), alles danach ist offen. `fertig` = alle gold. */
export function stationsZustand(liste: readonly Station[], station: Station, zustand: LeisteZustand): StationsZustand[] {
const i = liste.indexOf(station)
return liste.map((_, k) => {
if (zustand === 'fertig') return 'fertig'
if (k < i) return 'fertig'
if (k === i) return zustand === 'fehler' || zustand === 'abgebrochen' ? 'fehler' : 'aktiv'
return 'offen'
})
}
function stand(station: Station, zustand: LeisteZustand, prozent = -1, rettung = false, hinweis = ''): LeisteStand {
return { station, zustand, prozent, rettung, hinweis }
}
/** Die Leiste in der Laufwerks-Kachel: aus dem Rip-Stand und nach dem
* Rip aus dem Vorgang (ohne Prozent: die Details stehen in der
* Kompressions-Karte). */
export function leisteAusRip(
rip: Pick<RipStatus, 'phase' | 'prozent' | 'rettung'>,
vorgang: Pick<VorgangEintrag, 'phase'> | undefined,
wartePlatz: number,
): LeisteStand {
const rettung = rip.phase === 'rettet' || rip.rettung !== undefined
switch (rip.phase) {
case 'liest-info':
return stand('titel', 'laeuft', -1, rettung)
case 'rettet':
return stand('rettung', 'laeuft', rip.prozent, true)
case 'rippt':
return stand('rip', 'laeuft', rip.prozent, rettung)
case 'komprimiert':
return stand('kompression', 'laeuft', rip.prozent, rettung)
case 'legt-ab':
return stand('ablage', 'laeuft', rip.prozent, rettung)
case 'fehler':
return stand('rip', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('rip', 'abgebrochen', -1, rettung)
case 'fertig':
break
}
// Der Rip ist durch — wo steht der Vorgang?
if (vorgang === undefined) return stand('kompression', 'wartet', -1, rettung, 'eingereiht')
switch (vorgang.phase) {
case 'rippt':
return stand('rip', 'laeuft', -1, rettung)
case 'wartet':
return stand('kompression', 'wartet', -1, rettung, wartePlatz > 0 ? `Platz ${wartePlatz}` : 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'legt-ab':
return stand('ablage', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'fertig':
return stand('ablage', 'fertig', 100, rettung)
case 'fehler':
return stand('kompression', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('kompression', 'abgebrochen', -1, rettung)
}
}
/** Die Leiste eines Vorgangs im Verlauf (kompakt). Bei Fehlern ohne
* Roh-Bytes scheiterte schon der Rip, sonst die Kompression. */
export function leisteAusVorgang(v: Pick<VorgangEintrag, 'phase' | 'rohBytes'>): LeisteStand {
switch (v.phase) {
case 'rippt':
return stand('rip', 'laeuft')
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft')
case 'legt-ab':
return stand('ablage', 'laeuft')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'fehler')
case 'abgebrochen':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'abgebrochen')
}
}
/** Die Leiste in der Kompressions-Karte: Stationen 12 sind fertig. */
export function leisteAusKompression(k: Pick<KompressionStand, 'phase' | 'prozent'>): LeisteStand {
switch (k.phase) {
case 'legt-ab':
return stand('ablage', 'laeuft', k.prozent)
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand('kompression', 'fehler')
case 'abgebrochen':
return stand('kompression', 'abgebrochen')
case 'rippt':
return stand('rip', 'laeuft', k.prozent)
case 'komprimiert':
return stand('kompression', 'laeuft', k.prozent)
}
}
+13
View File
@@ -0,0 +1,13 @@
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
// eine Administrator-Abfrage und könnte kein Tray zeigen.
import { app } from 'electron'
export function autostartSetzen(an: boolean): void {
app.setLoginItemSettings({ openAtLogin: an })
}
export function autostartAktiv(): boolean {
return app.getLoginItemSettings().openAtLogin
}
+92
View File
@@ -0,0 +1,92 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern.
//
// 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
// sofern sie noch auf einem Bildschirm liegt.
import { BrowserWindow, screen } from 'electron'
import { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(profilOrdner: string): BrowserWindow {
const lageDatei = join(profilOrdner, 'fenster-lage.json')
const lage = anfangsLage(
lageLesen(lageDatei),
screen.getPrimaryDisplay().workArea,
screen.getAllDisplays().map((d) => d.workArea),
)
const fenster = new BrowserWindow({
x: lage.x,
y: lage.y,
width: lage.width,
height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true,
autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
//
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
// Standard true) — die Kanten lassen sich also weiter ziehen, und
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
frame: false,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
backgroundColor: '#171412',
title: 'Rippy',
webPreferences: {
preload: join(__dirname, '../bruecke/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
if (lage.maximiert) fenster.maximize()
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
let zeiger: NodeJS.Timeout | null = null
const merken = (): void => {
if (zeiger !== null) clearTimeout(zeiger)
zeiger = setTimeout(() => {
if (fenster.isDestroyed()) return
const maximiert = fenster.isMaximized()
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
lageSchreiben(lageDatei, neu)
}, 400)
}
fenster.on('resize', merken)
fenster.on('move', merken)
fenster.on('maximize', merken)
fenster.on('unmaximize', merken)
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #einrichtung) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) {
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
} else {
void fenster.loadFile(
join(__dirname, '../fenster/index.html'),
startBereich.length > 0 ? { hash: startBereich } : undefined,
)
}
return fenster
}
+85
View File
@@ -0,0 +1,85 @@
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
// PUR, damit die Ampel die Rechnung prüft:
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
// Bildschirm.
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
// Fenster nicht ins Nirgendwo schieben).
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface Lage {
x: number
y: number
width: number
height: number
maximiert: boolean
}
export interface Bereich {
x: number
y: number
width: number
height: number
}
export const MINDEST_BREITE = 1100
export const MINDEST_HOEHE = 720
export const ANTEIL = 0.8
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
export function startLage(arbeitsbereich: Bereich): Lage {
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
return {
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
width: Math.min(width, arbeitsbereich.width),
height: Math.min(height, arbeitsbereich.height),
maximiert: false,
}
}
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
if (lage.width < 400 || lage.height < 300) return false
return bildschirme.some((b) => {
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
const links = Math.max(lage.x, b.x)
const unten = Math.min(lage.y + lage.height, b.y + b.height)
const oben = Math.max(lage.y, b.y)
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
return rechts - links >= 200 && unten - oben >= 100
})
}
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
return startLage(arbeitsbereich)
}
export function lageLesen(pfad: string): Lage | null {
try {
if (!existsSync(pfad)) return null
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
if (typeof roh !== 'object' || roh === null) return null
const l = roh as Record<string, unknown>
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
} catch {
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
return null
}
}
export function lageSchreiben(pfad: string, lage: Lage): void {
try {
writeFileSync(pfad, JSON.stringify(lage))
} catch {
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
}
}
+78
View File
@@ -0,0 +1,78 @@
// Den Windows-11-Fensterrand loswerden.
//
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
// Fensterinhalts.
//
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
// Windows 11 Build 22000):
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
//
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
// gibt es diesen Rand schlicht nicht.
//
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
// Haupt liegen, weil nur dort das Fensterhandle existiert.
import koffi from 'koffi'
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
const DWMWA_BORDER_COLOR = 34
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
const DWMWA_COLOR_NONE = 0xfffffffe
const DWMWCP_DONOTROUND = 1
export interface RandErgebnis {
ok: boolean
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
fehler: string
}
/**
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
* und Rippy sich nicht fremder anfühlen soll, als es muss.
*
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
*/
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
try {
const dwmapi = koffi.load('dwmapi.dll')
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
const DwmSetWindowAttribute = dwmapi.func(
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
)
const hwnd = handle.readBigUInt64LE(0)
const farbe = Buffer.alloc(4)
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
let eckenErgebnis = 0
if (eckenKantig) {
const ecken = Buffer.alloc(4)
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
}
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
return {
ok: false,
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
}
}
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
}
}
+451
View File
@@ -0,0 +1,451 @@
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
//
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { Protokoll } from '../gemeinsam/protokoll'
import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
const klickSmoke = process.argv.includes('--klick-smoke')
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
const ohneNebenwirkungen = smokeModus || klickSmoke
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
app.setPath('userData', profilArg.slice('--profil='.length))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
// Rippy nicht und wird von ihm nicht gestört.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
start()
}
function start(): void {
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
const protokollOrdner = join(app.getPath('userData'), 'logs')
const protokoll = new Protokoll(protokollOrdner, 'haupt')
protokoll.aufraeumen()
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen()
if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
}
let fenster: BrowserWindow | null = null
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
let beendenGewollt = smokeModus
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
// warten dann.
const aktiveVorgaenge = new Set<string>()
let updateGestartet = false
const smoke = {
fenster: false,
kern: false,
datenbank: false,
leine: leine.ok,
pong: false,
}
const kernVerwaltung = new KernVerwaltung(
join(app.getPath('userData'), 'rippy.db'),
(nachricht) => {
if (nachricht.art === 'kern-bereit') {
kernBereit = nachricht
smoke.kern = true
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
} else if (nachricht.art === 'rip-status') {
ripStatusVerarbeiten(nachricht.status)
} else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !ohneNebenwirkungen) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
() => statusSenden(),
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
// der close-Horcher das Fenster nicht bloß versteckt.
() => beenden(),
)
}
} else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'vorgaenge') {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
(nachricht.info.schonGerippt !== null
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
: ''),
)
}
},
(zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus,
)
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
else aktiveVorgaenge.add(KOMPRESSION)
anzeigeAktualisieren()
}
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
* die Kompression sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
fenster.setProgressBar(kompressionStand.prozent / 100)
} else {
fenster.setProgressBar(-1)
}
}
if (rip !== null) {
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
} else if (kompressionStand !== null) {
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
}
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id)
} else {
aktiveVorgaenge.delete(status.id)
}
letzterRip = status
anzeigeAktualisieren()
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
}
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
// Manifest Administratorrechte an — CreateProcess bricht dann mit
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
async function installerStarten(pfad: string): Promise<void> {
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
// Rippys eigenem Downloads-Ordner.
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
return
}
const fehler = await shell.openPath(pfad)
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
}
function toast(titel: string, text: string): void {
try {
if (!Notification.isSupported()) return
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
meldung.on('click', () => fensterZeigen())
meldung.show()
} catch (fehler) {
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
}
}
function statusSenden(): void {
if (fenster === null || fenster.isDestroyed()) return
const status: HauptStatus = {
version: app.getVersion(),
leine,
kern: kernVerwaltung.zustand(),
autostart: autostartAktiv(),
update: updateStand(),
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
}
fenster.webContents.send('haupt-status', status)
}
function fensterVerdrahten(): void {
if (fenster === null) return
fenster.webContents.on('did-finish-load', () => {
smoke.fenster = true
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
kernVerwaltung.fensterVerbinden(fenster!.webContents)
statusSenden()
smokePruefen()
})
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
// maximiert wird, nicht nur über unseren Knopf.
fenster.on('maximize', () => statusSenden())
fenster.on('unmaximize', () => statusSenden())
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => {
if (beendenGewollt) return
ereignis.preventDefault()
fenster?.hide()
})
}
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
return
}
if (fenster.isMinimized()) fenster.restore()
fenster.show()
fenster.focus()
}
function beenden(): void {
beendenGewollt = true
app.quit()
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt.
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
if (art === 'kern-pong') {
smoke.pong = true
smokePruefen()
}
})
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
if (fenster === null || fenster.isDestroyed()) return false
if (was === 'minimieren') {
fenster.minimize()
} else if (was === 'maximieren') {
if (fenster.isMaximized()) fenster.unmaximize()
else fenster.maximize()
} else if (was === 'schliessen') {
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
// ein laufender Rip darf davon nichts merken.
fenster.close()
} else {
return false
}
return fenster.isDestroyed() ? false : fenster.isMaximized()
})
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
ipcMain.handle('update-pruefen', () => {
updatePruefenJetzt()
return updateStand()
})
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
// statt still nichts zu tun (R4).
ipcMain.handle('update-installieren', () => {
const urteil = updateInstallierenJetzt()
if (!urteil.ok) statusSenden()
return urteil
})
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
autostartSetzen(an === true)
statusSenden()
return autostartAktiv()
})
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
// Programme, sonst nichts.
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
properties: ['openFile'],
filters: [
{ name: 'Programme', extensions: ['exe'] },
{ name: 'Alle Dateien', extensions: ['*'] },
],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
return await shell.openPath(pfad)
})
ipcMain.handle('protokoll-oeffnen', async () => {
return await shell.openPath(protokollOrdner)
})
app.on('second-instance', () => {
fensterZeigen()
})
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
app.on('window-all-closed', () => {
if (beendenGewollt) app.quit()
})
app.on('before-quit', () => {
beendenGewollt = true
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden()
})
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
})
let smokeAbgeschlossen = false
function smokePruefen(): void {
if (!smokeModus || smokeAbgeschlossen) return
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
if (fehlt.length > 0) return
smokeAbgeschlossen = true
void smokeAbschliessen()
}
async function smokeAbschliessen(): Promise<void> {
const bildPfad = process.env['RIPPY_SMOKE_BILD']
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
try {
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
// ein sofortiges capturePage lieferte hier »UnknownVizError«
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
// späte Kern-Antworten warten.
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
const bild = await fenster.webContents.capturePage()
await writeFile(bildPfad, bild.toPNG())
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
} catch (fehler) {
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
}
}
const kernInfo =
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
console.log(
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
)
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
kernVerwaltung.beenden()
setTimeout(() => app.exit(0), 100)
}
if (smokeModus) {
setTimeout(() => {
if (smokeAbgeschlossen) return
smokeAbgeschlossen = true
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
kernVerwaltung.beenden()
setTimeout(() => app.exit(2), 100)
}, 25_000)
}
}
+108
View File
@@ -0,0 +1,108 @@
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
import { app, MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
import { join } from 'node:path'
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
export interface KernZustand {
laeuft: boolean
pid?: number
neustarts: number
}
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
const MAX_NEUSTARTS_JE_MINUTE = 3
export class KernVerwaltung {
private kern: UtilityProcess | null = null
private beendenGewollt = false
private neustarts = 0
private neustartZeiten: number[] = []
constructor(
private readonly datenbankPfad: string,
private readonly beiNachricht: (nachricht: KernNachricht) => void,
private readonly beiZustand: (zustand: KernZustand) => void,
private readonly smokeModus = false,
) {}
starten(): void {
const kernDatei = join(__dirname, 'kern.js')
const args = [`--datenbank=${this.datenbankPfad}`]
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
// die Schlüsselkette dann aus.
if (this.smokeModus) args.push('--smoke-kern')
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
const umgebung: NodeJS.ProcessEnv = { ...process.env }
if (app.isPackaged) {
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
}
const kern = utilityProcess.fork(kernDatei, args, {
serviceName: 'Rippy-Kern',
stdio: 'inherit',
env: umgebung,
})
this.kern = kern
kern.on('message', (nachricht: unknown) => {
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
})
kern.on('spawn', () => {
this.beiZustand(this.zustand())
})
kern.on('exit', (code) => {
this.kern = null
if (this.beendenGewollt) return
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
const jetzt = Date.now()
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
this.beiZustand(this.zustand())
return
}
this.neustartZeiten.push(jetzt)
this.neustarts += 1
this.starten()
})
}
/**
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
* ins Fenster (§ 4.1).
*/
fensterVerbinden(webContents: WebContents): void {
if (this.kern === null) return
const { port1, port2 } = new MessageChannelMain()
const nachricht: HauptNachricht = { art: 'fenster-port' }
this.kern.postMessage(nachricht, [port1])
webContents.postMessage('kern-port', null, [port2])
}
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
senden(nachricht: HauptNachricht): void {
this.kern?.postMessage(nachricht)
}
zustand(): KernZustand {
return {
laeuft: this.kern !== null,
pid: this.kern?.pid,
neustarts: this.neustarts,
}
}
beenden(): void {
this.beendenGewollt = true
this.kern?.kill()
this.kern = null
}
}
+49
View File
@@ -0,0 +1,49 @@
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
// die Ampel das auf Linux prüfen kann (BAUEN.md).
//
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
export function argumentQuoten(arg: string): string {
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
let ergebnis = '"'
let rueckstriche = 0
for (const zeichen of arg) {
if (zeichen === '\\') {
rueckstriche += 1
continue
}
if (zeichen === '"') {
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
// Anführungszeichen selbst bekommt einen eigenen davor.
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
rueckstriche = 0
continue
}
ergebnis += '\\'.repeat(rueckstriche) + zeichen
rueckstriche = 0
}
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
// davor zählen deshalb ebenfalls doppelt.
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
}
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
return [programm, ...argumente].map(argumentQuoten).join(' ')
}
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
* danach wieder startet. Gemessen an der installierten Bibliothek
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
* `/S` für still", `--force-run` für „danach starten". Genau diese drei
* benutzt electron-updater selbst wir starten den Installer nur auf
* einem anderen Weg (außerhalb der Prozess-Leine). */
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
+186
View File
@@ -0,0 +1,186 @@
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
//
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
//
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
import koffi from 'koffi'
import { kommandozeileBauen } from './kommandozeile'
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
// denn, sie verlangen das Ausklinken beim Anlegen selbst
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
//
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
// electron-updater wird per `spawn(…, { detached: true })` gestartet
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
// beendet aber startet NICHT neu in der neuen version."
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
// zeigte deshalb erst ein falsches Negativ.
const CREATE_NO_WINDOW = 0x08000000
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
// bei Offset 16.
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
let gesetzt: LeineStatus | null = null
/**
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
* VOR dem Start des Kerns Kinder erben die Mitgliedschaft nur, wenn sie
* nach dem Anlegen gestartet werden.
*
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
*/
export function leineSetzen(): LeineStatus {
if (gesetzt !== null) return gesetzt
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func(
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
)
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
)
const AssignProcessToJobObject = kernel32.func(
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
)
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const job = CreateJobObjectW(null, null)
if (koffi.address(job) === 0n) {
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
if (
!SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
info,
STRUKTUR_GROESSE,
)
) {
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
// Prozess-Tod IST der Mechanismus.
gesetzt = { ok: true }
return gesetzt
} catch (fehler) {
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
return gesetzt
}
}
/**
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
*
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
* Kind, das Rippys eigenen Tod überleben MUSS er installiert ja gerade
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
*
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
* `update-info.json` steht `isAdminRightsRequired: false`) er liefe also
* wieder als Kind und stürbe wieder mit.
*/
export function ausserhalbDerLeineStarten(
programm: string,
argumente: readonly string[],
): { ok: boolean; fehler: string } {
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
)
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
const gestartet = CreateProcessW(
programm,
zeile,
null,
null,
false,
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
null,
null,
startupInfo,
prozessInfo,
)
if (!gestartet) {
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
}
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
// ohne Wirkung.
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
}
}
+52
View File
@@ -0,0 +1,52 @@
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
// beendet wird über das Kontextmenü oder den Menüpunkt.
import { Menu, nativeImage, Tray } from 'electron'
import { join } from 'node:path'
let tray: Tray | null = null
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
try {
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
// per extraResources dorthin).
const kandidaten = [
join(__dirname, '..', '..', 'bau', 'tray.png'),
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
]
let bild = nativeImage.createEmpty()
for (const kandidat of kandidaten) {
const versuch = nativeImage.createFromPath(kandidat)
if (!versuch.isEmpty()) {
bild = versuch
break
}
}
tray = new Tray(bild)
tray.setToolTip('Rippy — bereit')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Rippy öffnen', click: oeffnen },
{ type: 'separator' },
{ label: 'Beenden', click: beenden },
]),
)
tray.on('click', oeffnen)
return tray
} catch (fehler) {
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
return null
}
}
export function trayHinweis(text: string): void {
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
}
export function trayWeg(): void {
tray?.destroy()
tray = null
}
+61
View File
@@ -0,0 +1,61 @@
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
// Tests sie ohne Electron laden können.
export const STANDARD_UPDATE_URL =
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1)
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
* akzeptiert. */
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
const sauber = url.trim()
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
return {
ok: false,
grund:
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
}
}
/** So viel Änderungstext zeigt das Fenster höchstens der Rest wird
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
export const NEUERUNGEN_HOECHSTENS = 4000
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
*
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
* `string | Array<ReleaseNoteInfo> | null` bei uns ein String
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
* ankommen können, sonst steht im Fenster [object Object]".
*
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
* weitergereicht, was wie Auszeichnung aussieht. */
export function neuerungenText(roh: unknown): string {
const saubern = (t: string): string =>
t
.replace(/<[^>]*>/g, ' ')
.split('\n')
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
if (Array.isArray(roh)) {
const teile: string[] = []
for (const eintrag of roh) {
if (eintrag === null || typeof eintrag !== 'object') continue
const e = eintrag as { version?: unknown; note?: unknown }
const version = typeof e.version === 'string' ? e.version : ''
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
if (notiz.length === 0) continue
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
}
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
}
return ''
}
+246
View File
@@ -0,0 +1,246 @@
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
// 2. NIE während eines Rips — weder prüfen, laden noch installieren.
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
// letzten erfolgreichen Prüfung steht im Status.
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
//
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
// Knopf Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
import { app } from 'electron'
import electronUpdater from 'electron-updater'
import { INSTALLER_ARGUMENTE } from './kommandozeile'
import { ausserhalbDerLeineStarten } from './leine'
import { neuerungenText, updateUrlPruefen } from './update-regeln'
export {
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
neuerungenText,
updateUrlPruefen,
} from './update-regeln'
const { autoUpdater } = electronUpdater
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
export type { UpdateStand } from '../gemeinsam/nachrichten'
import type { UpdateStand } from '../gemeinsam/nachrichten'
const ANFANG: UpdateStand = {
letzteErfolgreichePruefung: '',
text: 'noch nicht geprüft',
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
}
let stand: UpdateStand = { ...ANFANG }
let aenderungMelden: () => void = () => {
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
}
let ripLaeuftJetzt: () => boolean = () => false
let scharf = false
/** Der geladene Installer — leer, solange nichts bereitliegt. */
let installerPfad = ''
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
let installerGestartet = false
let rippyBeenden: () => void = () => app.quit()
export function updateStand(): UpdateStand {
return { ...stand }
}
/** Stand ändern UND das Fenster benachrichtigen nie das eine ohne das
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
* (der 26.07.-Fund ein still scheiternder Hintergrund-Prozess"). */
function setzen(teil: Partial<UpdateStand>): void {
stand = { ...stand, ...teil }
aenderungMelden()
}
export function updateEinrichten(
eigeneUrl: string,
ripLaeuft: () => boolean,
melden: (text: string) => void,
beiAenderung: () => void = () => {
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
},
beenden: () => void = () => app.quit(),
): void {
aenderungMelden = beiAenderung
ripLaeuftJetzt = ripLaeuft
rippyBeenden = beenden
if (!app.isPackaged) {
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
return
}
const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) {
setzen({ text: pruefung.grund })
melden(pruefung.grund)
return
}
if (eigeneUrl.trim().length > 0) {
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
}
autoUpdater.autoDownload = false
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
// Objekt bezahlt): Sein eigener Weg startet den Installer per
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
autoUpdater.autoInstallOnAppQuit = false
scharf = true
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
// diese Zeile zurückkommt.
app.on('will-quit', () => {
if (!stand.bereit || ripLaeuftJetzt()) return
installerAnwerfen()
})
autoUpdater.on('update-available', (info) => {
const neuerungen = neuerungenText(info.releaseNotes)
if (ripLaeuft()) {
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
// tägliche Takt versucht es wieder.
setzen({
neueVersion: info.version,
neuerungen,
laeuft: false,
fortschritt: -1,
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
})
melden(stand.text)
return
}
setzen({
neueVersion: info.version,
neuerungen,
laeuft: true,
fortschritt: 0,
text: `Version ${info.version} wird geladen …`,
})
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
void autoUpdater
.downloadUpdate()
.then((pfade: string[]) => {
// downloadUpdate liefert die abgelegten Dateien zurück
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
// ebenfalls heraus, ohne erneut zu laden.
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
if (exe.length > 0) installerPfad = exe
})
.catch((fehler: unknown) => {
setzen({
laeuft: false,
fortschritt: -1,
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
})
})
})
autoUpdater.on('download-progress', (fortschritt) => {
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
})
autoUpdater.on('update-downloaded', (info) => {
setzen({
neueVersion: info.version,
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
bereit: true,
laeuft: false,
fortschritt: 100,
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
})
melden(stand.text)
})
autoUpdater.on('update-not-available', () => {
setzen({
letzteErfolgreichePruefung: new Date().toISOString(),
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
text: 'Rippy ist aktuell.',
})
})
autoUpdater.on('error', (fehler) => {
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
})
pruefen()
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
}
function pruefen(): void {
if (ripLaeuftJetzt()) return
setzen({ laeuft: true, text: 'Suche nach Updates …' })
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
/** Der Knopf Jetzt prüfen" im Fenster. Antwortet IMMER etwas ein
* Knopf, der nichts sagt, ist schlimmer als keiner. */
export function updatePruefenJetzt(): void {
if (!scharf) {
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
aenderungMelden()
return
}
if (ripLaeuftJetzt()) {
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
return
}
pruefen()
}
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
* er wirklich läuft nichts passiert" darf nicht als Erfolg durchgehen. */
function installerAnwerfen(): { ok: boolean; grund: string } {
if (installerGestartet) return { ok: true, grund: '' }
if (installerPfad.length === 0) {
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
}
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
if (!urteil.ok) {
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
return { ok: false, grund: urteil.fehler }
}
installerGestartet = true
return { ok: true, grund: '' }
}
/** Der Knopf Jetzt neu starten und installieren". Nur sinnvoll, wenn
* wirklich etwas bereitliegt und nie mitten in einem Rip. */
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
if (!scharf || !stand.bereit) {
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
}
if (ripLaeuftJetzt()) {
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
}
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
// weg ist, und startet es hinterher wieder (`--force-run`).
const urteil = installerAnwerfen()
if (!urteil.ok) return urteil
rippyBeenden()
return { ok: true, grund: '' }
}
@@ -0,0 +1,47 @@
// 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]
})
}
@@ -0,0 +1,46 @@
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
export async function bibliothekRefresh(
server: string,
url: string,
apiKey: string,
holen: typeof fetch = fetch,
): Promise<string> {
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
try {
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
method: 'POST',
headers: { 'X-Emby-Token': apiKey },
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) {
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
}
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
}
if (server === 'kodi') {
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey.includes(':')) {
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
}
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
method: 'POST',
headers: kopf,
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
}
} catch (fehler) {
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
}
return ''
}
+46
View File
@@ -0,0 +1,46 @@
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
export interface NfoMeta {
typ?: 'film' | 'serie'
beschreibung?: string
genres?: readonly string[]
laufzeitMinuten?: number
quelle?: string
}
function xml(wert: string): string {
return wert
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** Minimales NFO (pur, testbar). */
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
const zeilen = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
`<${wurzel}>`,
` <title>${xml(titel ?? '')}</title>`,
]
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
zeilen.push(` <year>${xml(String(jahr))}</year>`)
}
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
}
for (const genre of meta.genres ?? []) {
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
}
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
}
+30
View File
@@ -0,0 +1,30 @@
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
// Meldung sagt was passiert ist).
import { writeFileSync } from 'node:fs'
export function posterUrl(posterPfad: string): string {
if (posterPfad.length === 0) return ''
if (posterPfad.startsWith('http')) return posterPfad
return `https://image.tmdb.org/t/p/w500${posterPfad}`
}
export async function posterSpeichern(
posterPfad: string,
zielDatei: string,
holen: typeof fetch = fetch,
): Promise<string> {
const url = posterUrl(posterPfad)
if (url.length === 0) return ''
try {
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
const daten = Buffer.from(await antwort.arrayBuffer())
if (daten.length === 0) return 'Poster: leere Antwort'
writeFileSync(zielDatei, daten)
return 'poster.jpg gespeichert'
} catch (fehler) {
return `Poster fehlgeschlagen: ${String(fehler)}`
}
}

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