39 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
122 changed files with 17087 additions and 2895 deletions
+1595 -1
View File
File diff suppressed because it is too large Load Diff
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

+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()
@@ -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 )"
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

+1
View File
@@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` | | `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema | | `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). | | `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an. fasst die echten Einstellungen nicht an.
+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)
}
+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.
+5 -2
View File
@@ -25,8 +25,11 @@ function cspNurImBau(): Plugin {
return html.replace( return html.replace(
'<head>', '<head>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst // img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'. // bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`, // 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:" />`,
) )
}, },
} }
+116 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rippy", "name": "rippy",
"version": "5.0.0-w0", "version": "5.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rippy", "name": "rippy",
"version": "5.0.0-w0", "version": "5.6.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
@@ -24,6 +24,7 @@
"electron": "44.0.0", "electron": "44.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -2229,6 +2230,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -5546,6 +5613,53 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rippy", "name": "rippy",
"productName": "Rippy", "productName": "Rippy",
"version": "5.1.0", "version": "5.6.1",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js", "main": "out/haupt/index.js",
"author": "KrBrZ", "author": "KrBrZ",
@@ -13,6 +13,7 @@
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"test": "vitest run", "test": "vitest run",
"smoke": "electron-vite build && electron . --smoke", "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", "paket": "npm run build && electron-builder --win --config electron-builder.yml",
"werkzeug:electron": "node node_modules/electron/install.js" "werkzeug:electron": "node node_modules/electron/install.js"
}, },
@@ -32,6 +33,7 @@
"electron": "44.0.0", "electron": "44.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
+27
View File
@@ -5,6 +5,8 @@
// Kontextgrenze — contextBridge kann keine Ports übertragen) // Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status') // 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt // 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
// //
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach // Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert // did-finish-load — ob die React-Seite ihre Horcher da schon registriert
@@ -52,8 +54,33 @@ contextBridge.exposeInMainWorld('rippy', {
ordnerWaehlen(titel: string): Promise<string | null> { ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null> return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
}, },
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
dateiWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
},
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
ordnerOeffnen(pfad: string): Promise<string> {
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
},
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
protokollOeffnen(): Promise<string> {
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
},
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */ /** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> { autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean> return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
}, },
/** 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 }>
},
}) })
File diff suppressed because it is too large Load Diff
+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>
)
}
+6
View File
@@ -8,7 +8,13 @@ declare global {
aufStatus(horcher: (status: unknown) => void): void aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null> ordnerWaehlen(titel: string): Promise<string | null>
dateiWaehlen(titel: string): Promise<string | null>
ordnerOeffnen(pfad: string): Promise<string>
protokollOeffnen(): Promise<string>
autostartSetzen(an: boolean): Promise<boolean> autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
} }
} }
} }
+181 -59
View File
@@ -8,13 +8,20 @@
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { import {
istKernNachricht, istKernNachricht,
type AutomatikStand,
type BeschaffungStand, type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand, type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo, type GeraeteInfo,
type KernNachricht, type KernNachricht,
type LaufwerkBericht,
type MetaVorschlag, type MetaVorschlag,
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus, type RipStatus,
type SpeicherStand,
type TitelStand,
type VorgaengeStand,
type WerkzeugAuskunft, type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten' } from '../gemeinsam/nachrichten'
@@ -30,6 +37,8 @@ export interface Verbindung {
discInfos: Record<string, DiscInfoStand> discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]> vorschlaege: Record<string, MetaVorschlag[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number> ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
@@ -38,7 +47,6 @@ export interface Verbindung {
ripProtokoll: Record<string, string[]> ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string> einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: { schluessel: {
quelle: string quelle: string
angenommen: boolean | null angenommen: boolean | null
@@ -50,50 +58,72 @@ export interface Verbindung {
} | null } | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null 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. */ /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[] meldungen: string[]
} }
type Horcher = (stand: Verbindung) => void type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20 const MAX_MELDUNGEN = 30
const stand: Verbindung = { export const LEERE_VERBINDUNG: Verbindung = {
verbunden: false, verbunden: false,
kern: null, kern: null,
pongLatenzMs: null, pongLatenzMs: null,
laufwerke: [], laufwerke: [],
rips: {}, rips: {},
discInfos: {}, discInfos: {},
discTitel: {},
vorschlaege: {}, vorschlaege: {},
ripStart: {}, ripStart: {},
ripProtokoll: {}, ripProtokoll: {},
einstellungen: {}, einstellungen: {},
werkzeuge: null, werkzeuge: null,
bibliothek: [],
schluessel: null, schluessel: null,
beschaffung: null, beschaffung: null,
vorgaenge: null,
gesundheit: {},
speicher: null,
automatik: {},
poster: [],
meldungen: [], meldungen: [],
} }
const stand: Verbindung = { ...LEERE_VERBINDUNG }
const horcher = new Set<Horcher>() const horcher = new Set<Horcher>()
let gestartet = false let gestartet = false
let port: MessagePort | null = null let port: MessagePort | null = null
function melden(): void { function kopie(): Verbindung {
for (const h of horcher) { return {
h({ ...stand,
...stand, laufwerke: [...stand.laufwerke],
laufwerke: [...stand.laufwerke], rips: { ...stand.rips },
rips: { ...stand.rips }, discInfos: { ...stand.discInfos },
discInfos: { ...stand.discInfos }, vorschlaege: { ...stand.vorschlaege },
vorschlaege: { ...stand.vorschlaege }, einstellungen: { ...stand.einstellungen },
einstellungen: { ...stand.einstellungen }, gesundheit: { ...stand.gesundheit },
bibliothek: [...stand.bibliothek], automatik: { ...stand.automatik },
meldungen: [...stand.meldungen], poster: [...stand.poster],
}) meldungen: [...stand.meldungen],
} }
} }
function melden(): void {
for (const h of horcher) h(kopie())
}
function meldung(text: string): void { function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
} }
@@ -111,6 +141,9 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'kern-fehler': case 'kern-fehler':
meldung(`${nachricht.text}`) meldung(`${nachricht.text}`)
break break
case 'kern-hinweis':
meldung(nachricht.text)
break
case 'laufwerke': case 'laufwerke':
stand.laufwerke = nachricht.liste stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) { for (const ereignis of nachricht.ereignisse) {
@@ -118,6 +151,8 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`) meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') { } else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`) meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
stand.automatik = rest
} else if (ereignis.art === 'laufwerk-weg') { } else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
} }
@@ -132,14 +167,27 @@ function kernNachricht(nachricht: KernNachricht): void {
break break
} }
case 'disc-info': { case 'disc-info': {
const vorher = stand.discInfos[nachricht.info.id]
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) { if (nachricht.info.titel.length > 0) {
meldung( // Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, // 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 break
} }
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege': case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break break
@@ -149,9 +197,6 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'werkzeuge': case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft stand.werkzeuge = nachricht.auskunft
break break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status': case 'schluessel-status':
stand.schluessel = nachricht.stand stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
@@ -160,9 +205,30 @@ function kernNachricht(nachricht: KernNachricht): void {
stand.beschaffung = nachricht.stand stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break 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': { case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
@@ -192,6 +258,9 @@ function kernNachricht(nachricht: KernNachricht): void {
} }
break break
} }
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
} }
melden() melden()
} }
@@ -204,10 +273,11 @@ function portAnnehmen(neuerPort: MessagePort): void {
if (istKernNachricht(nachricht)) kernNachricht(nachricht) if (istKernNachricht(nachricht)) kernNachricht(nachricht)
} }
neuerPort.start() neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) senden({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' }) senden({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' }) senden({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' }) senden({ art: 'vorgaenge-laden' })
senden({ art: 'speicher-laden' })
melden() melden()
} }
@@ -232,20 +302,16 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void { export function aufVerbindung(h: Horcher): () => void {
horcher.add(h) horcher.add(h)
h({ h(kopie())
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
return () => horcher.delete(h) return () => horcher.delete(h)
} }
function anKern(nachricht: { art: string; id: string }, wasWar: string): void { 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) { if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`) meldung(`${wasWar}: keine Verbindung zum Kern`)
melden() melden()
@@ -258,43 +324,99 @@ export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
} }
export function ripStarten(id: string): void { /** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
anKern({ art: 'rip-start', id }, `Rip ${id}`) export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], 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 { export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
} }
export function rettungStarten(id: string): void {
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
}
export function metadatenSuchen(id: string, text: string): void { export function metadatenSuchen(id: string, text: string): void {
if (port === null) return senden({ art: 'metadaten-suchen', id, text })
port.postMessage({ art: 'metadaten-suchen', id, text })
} }
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return senden({ art: 'zuordnung-setzen', id, vorschlag })
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
} }
export function makemkvUpdate(): void { export function makemkvUpdate(): void {
if (port === null) { anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
} }
export function schluesselPruefen(): void { export function schluesselPruefen(): void {
if (port === null) return senden({ art: 'schluessel-pruefen' })
port.postMessage({ art: 'schluessel-pruefen' })
} }
export function einstellungSetzen(schluessel: string, wert: string): void { export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) { anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) }
melden()
return // ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
} export function vorgaengeLaden(): void {
port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) 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' })
} }
+62 -2
View File
@@ -31,6 +31,9 @@
--color-gruen: oklch(0.78 0.13 150); --color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85); --color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25); --color-rot: oklch(0.68 0.19 25);
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
--color-tinte: oklch(1 0 0);
} }
:root { :root {
@@ -59,8 +62,65 @@
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45); box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
} }
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
Windows-Rahmen mehr (frame:false) — gezogen wird an der Kopfzeile.
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
.ziehbar {
-webkit-app-region: drag;
}
.nicht-ziehbar {
-webkit-app-region: no-drag;
}
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */ /* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } ::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); } ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
nach oben; die Liste ist verdoppelt, bei -50 % beginnt sie nahtlos von
vorn. Dauer und Versatz setzt die Komponente je Spalte. */
@keyframes poster-lauf {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
.poster-spalte {
animation-name: poster-lauf;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
/* ── Hell-Modus (5.6.0, Commander: „White Mode") ─────────────────────────
Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als
dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet
über data-design auf <html> (App.tsx, Einstellung „design": dunkel /
hell / system). */
:root[data-design="hell"] {
color-scheme: light;
--color-buehne: oklch(0.965 0.008 80);
--color-karte: oklch(0.99 0.004 80);
--color-samt: oklch(0.94 0.01 80);
--color-gold-hell: oklch(0.6 0.12 72);
--color-gold: oklch(0.66 0.13 70);
--color-gold-tief: oklch(0.56 0.13 58);
--color-rec: oklch(0.6 0.19 40);
--color-schnee: oklch(0.2 0.012 60);
--color-dunst: oklch(0.4 0.015 60);
--color-nebel: oklch(0.55 0.015 60);
--color-gruen: oklch(0.52 0.15 150);
--color-warn: oklch(0.58 0.13 80);
--color-rot: oklch(0.55 0.2 25);
--color-tinte: oklch(0.15 0.01 60);
}
:root[data-design="hell"] .buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%);
}
:root[data-design="hell"] .filmkorn {
opacity: 0.03;
}
@@ -0,0 +1,81 @@
// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und
// Kompression sind entkoppelt — hier steht, was gerade rechnet und was
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
// wird fertig, der nächste wartet.
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste'
import { restText } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
const l = stand.laeuft
return (
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-tinte/25'}`} />
<span className="text-dunst">KOMPRESSION</span>
<span>·</span>
<span>
{l !== null ? 'läuft' : stand.pausiert ? 'angehalten' : 'wartet'}
{stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`}
</span>
<button
onClick={() => kompressionPause(!stand.pausiert)}
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-tinte/15 text-dunst hover:text-schnee'
}`}
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
>
{stand.pausiert ? '▶ Weiter' : '⏸ Pausieren'}
</button>
</div>
{l !== null && (
<div className="mt-3 rounded-xl border border-tinte/10 bg-samt/80 p-4">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
<div className="mt-2">
<VorgangsLeiste stand={leisteAusKompression(l)} restText={restText(l.restS)} />
</div>
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
&gt; {l.text}
</p>
{stand.pausiert && <p className="mt-1 font-mono text-[10.5px] text-warn">Pause: Dieser Film wird fertig, danach wartet die Schlange.</p>}
</div>
{l.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{l.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
<button
onClick={() => kompressionAbbrechen(l.vorgangId)}
className="mt-3 rounded-lg border border-rot/50 bg-rot/10 px-3 py-1 text-[11px] font-semibold text-rot hover:bg-rot/20"
title="Die Roh-Dateien bleiben liegen — unter Einstellungen → Roh-Dateien geht es später weiter"
>
Kompression abbrechen
</button>
</div>
)}
{stand.wartend.length > 0 && (
<div className="mt-3 space-y-1">
{stand.wartend.map((w, i) => (
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-1.5">
<VorgangsLeiste stand={leisteAusKompression(w)} kompakt />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-dunst">
Platz {i + 1} · {w.titel}
{stand.pausiert ? ' · wartet auf Weiter' : ''}
</span>
<button onClick={() => kompressionAbbrechen(w.vorgangId)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-rot">
aus der Schlange nehmen
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,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']
}
+456 -16
View File
@@ -7,6 +7,10 @@
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht) // Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus) // Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht) // 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. */ /** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string } export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
@@ -41,6 +45,8 @@ export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ───────────────────────────────────────────────── // ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase = export type RipPhase =
| 'liest-info' | 'liest-info'
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
| 'rettet'
| 'rippt' | 'rippt'
| 'komprimiert' | 'komprimiert'
| 'legt-ab' | 'legt-ab'
@@ -57,6 +63,112 @@ export interface RipStatus {
text: string text: string
dateien: string[] dateien: string[]
lesefehler: boolean lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
/** 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). */ /** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
@@ -75,9 +187,36 @@ export interface DiscInfoStand {
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am /** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */ * Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null schonGerippt: string | null
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
/** '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). */ /** Ein Eintrag der Bibliothek (§ 6.6) — seit 5.5.0 nur noch die
* Wiedererkennung („schon gerippt am …"), keine eigene Ansicht mehr. */
export interface BibliothekEintrag { export interface BibliothekEintrag {
fingerprint: string fingerprint: string
titel: string titel: string
@@ -87,7 +226,7 @@ export interface BibliothekEintrag {
ablageort: string ablageort: string
datum: string datum: string
dauerS: number dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */ /** TMDb-Posterpfad — '' wenn keiner bekannt. */
posterPfad: string posterPfad: string
} }
@@ -120,19 +259,140 @@ export interface BeschaffungStand {
fehler: boolean 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. */ /** Nachrichten, die der Kern verschickt. */
export type KernNachricht = export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus } | { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number } | { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string } | { 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: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus } | { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand } | { art: 'disc-info'; info: DiscInfoStand }
| { art: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> } | { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand } | { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt — /** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
* starten kann ihn nur das Haupt (shell/UAC, § 9). */ * starten kann ihn nur das Haupt (shell/UAC, § 9). */
@@ -151,23 +411,71 @@ export type KernNachricht =
version: string 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. */ /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = export type FensterNachricht =
| { art: 'ping'; zeit: number } | { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string } | { art: 'auswerfen'; id: string }
| { art: 'rip-start'; id: string } /** Titel-Lauf anstoßen (§ 6.2): dauert 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 } | { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string } | { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string } | { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag } | { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' } | { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' } | { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */ /** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' } | { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */ /** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' } | { art: 'schluessel-pruefen' }
// ── 5.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. */ /** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht = export type HauptNachricht =
@@ -182,14 +490,47 @@ export interface HauptStatus {
leine: { ok: boolean; fehler?: string } leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number } kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean autostart: boolean
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */ /** Selbst-Update (§ 6.8). */
update: { letzteErfolgreichePruefung: string; text: string } update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
/** 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> { function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null 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 { export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false if (!istObjekt(wert)) return false
switch (wert.art) { switch (wert.art) {
@@ -202,6 +543,7 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
case 'pong': case 'pong':
return typeof wert.zeit === 'number' return typeof wert.zeit === 'number'
case 'kern-fehler': case 'kern-fehler':
case 'kern-hinweis':
return typeof wert.text === 'string' return typeof wert.text === 'string'
case 'laufwerke': case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse) return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
@@ -220,12 +562,17 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
typeof (wert.info as Record<string, unknown>).titel === 'string' && typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number' typeof (wert.info as Record<string, unknown>).confidence === 'number'
) )
case 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen': case 'einstellungen':
return istObjekt(wert.werte) return istObjekt(wert.werte)
case 'werkzeuge': case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets) return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status': case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string' return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status': case 'beschaffung-status':
@@ -239,6 +586,46 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
return typeof wert.pfad === 'string' && wert.pfad.length > 0 return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege': case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.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: default:
return false return false
} }
@@ -274,27 +661,80 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
return false 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 { export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number' if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { if (wert.art === 'rip-start') {
return typeof wert.id === 'string' && wert.id.length > 0 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') { if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string' return istKennung(wert.schluessel) && typeof wert.wert === 'string'
} }
if (wert.art === 'metadaten-suchen') { if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string' return istKennung(wert.id) && typeof wert.text === 'string'
} }
if (wert.art === 'zuordnung-setzen') { if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag) 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 ( return (
wert.art === 'einstellungen-laden' || wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' || wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' || wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen' ||
wert.art === 'benachrichtigung-testen' ||
wert.art === 'poster-laden' ||
wert.art === 'speicher-laden'
) )
} }
+33
View File
@@ -0,0 +1,33 @@
// Die Platz-Prüfung vor dem Rip (5.5.0) — EINE Rechnung für Kern und
// Fenster: Roh plus geschätzt 25 % für die Kompression, plus 2 GB Luft.
// Der Kern urteilt beim Start (index.ts ripMitPlatz — auch für die
// Automatik), der Dialog zeigt dasselbe Urteil schon vor dem Klick
// (TitelWahlDialog).
export const PLATZ_LUFT_BYTES = 2e9
export const KOMPRESSION_ANTEIL = 0.25
export interface PlatzUrteil {
/** Leer, wenn der Platz reicht — sonst der Grund in Klartext. */
text: string
/** true: nicht einmal der verlustfreie Rip passt — der Start wird verweigert. */
hart: boolean
}
export function gbText(bytes: number): string {
return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
}
export function platzUrteil(freiBytes: number, rohBytes: number, komprimiert: boolean): PlatzUrteil {
const noetig = rohBytes + (komprimiert ? rohBytes * KOMPRESSION_ANTEIL : 0) + PLATZ_LUFT_BYTES
if (freiBytes >= noetig) return { text: '', hart: false }
if (freiBytes < rohBytes + PLATZ_LUFT_BYTES) {
return {
text: `Zu wenig Platz: ${gbText(freiBytes)} frei, der Rip braucht ${gbText(rohBytes)} verlustfrei. Das geht schief — erst Platz schaffen (Einstellungen → Roh-Dateien).`,
hart: true,
}
}
return {
text: `Knapp: ${gbText(freiBytes)} frei, gebraucht werden etwa ${gbText(noetig)} (Roh plus Kompression). Aufräumen unter Roh-Dateien hilft.`,
hart: false,
}
}
+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)
}
}
+56 -8
View File
@@ -1,18 +1,40 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts — // Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke // kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern. // (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5). //
import { BrowserWindow } from 'electron' // 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
// sofern sie noch auf einem Bildschirm liegt.
import { BrowserWindow, screen } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(): BrowserWindow { export function fensterErstellen(profilOrdner: string): BrowserWindow {
const lageDatei = join(profilOrdner, 'fenster-lage.json')
const lage = anfangsLage(
lageLesen(lageDatei),
screen.getPrimaryDisplay().workArea,
screen.getAllDisplays().map((d) => d.workArea),
)
const fenster = new BrowserWindow({ const fenster = new BrowserWindow({
width: 1000, x: lage.x,
height: 700, y: lage.y,
minWidth: 720, width: lage.width,
minHeight: 480, height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
//
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
// Standard true) — die Kanten lassen sich also weiter ziehen, und
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
frame: false,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck, // Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes // bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
// (oklch(0.13 0.012 60), siehe fenster/stil.css). // (oklch(0.13 0.012 60), siehe fenster/stil.css).
@@ -25,11 +47,37 @@ export function fensterErstellen(): BrowserWindow {
sandbox: true, sandbox: true,
}, },
}) })
if (lage.maximiert) fenster.maximize()
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
let zeiger: NodeJS.Timeout | null = null
const merken = (): void => {
if (zeiger !== null) clearTimeout(zeiger)
zeiger = setTimeout(() => {
if (fenster.isDestroyed()) return
const maximiert = fenster.isMaximized()
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
lageSchreiben(lageDatei, neu)
}, 400)
}
fenster.on('resize', merken)
fenster.on('move', merken)
fenster.on('maximize', merken)
fenster.on('unmaximize', merken)
// 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-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen. // (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen / // RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten. // #einrichtung) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL'] const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? '' const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) { if (devUrl !== undefined && devUrl.length > 0) {
+85
View File
@@ -0,0 +1,85 @@
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
// PUR, damit die Ampel die Rechnung prüft:
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
// Bildschirm.
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
// Fenster nicht ins Nirgendwo schieben).
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface Lage {
x: number
y: number
width: number
height: number
maximiert: boolean
}
export interface Bereich {
x: number
y: number
width: number
height: number
}
export const MINDEST_BREITE = 1100
export const MINDEST_HOEHE = 720
export const ANTEIL = 0.8
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
export function startLage(arbeitsbereich: Bereich): Lage {
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
return {
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
width: Math.min(width, arbeitsbereich.width),
height: Math.min(height, arbeitsbereich.height),
maximiert: false,
}
}
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
if (lage.width < 400 || lage.height < 300) return false
return bildschirme.some((b) => {
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
const links = Math.max(lage.x, b.x)
const unten = Math.min(lage.y + lage.height, b.y + b.height)
const oben = Math.max(lage.y, b.y)
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
return rechts - links >= 200 && unten - oben >= 100
})
}
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
return startLage(arbeitsbereich)
}
export function lageLesen(pfad: string): Lage | null {
try {
if (!existsSync(pfad)) return null
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
if (typeof roh !== 'object' || roh === null) return null
const l = roh as Record<string, unknown>
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
} catch {
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
return null
}
}
export function lageSchreiben(pfad: string, lage: Lage): void {
try {
writeFileSync(pfad, JSON.stringify(lage))
} catch {
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
}
}
+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)}` }
}
}
+161 -27
View File
@@ -13,22 +13,34 @@ import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path' import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten' import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog' import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { Protokoll } from '../gemeinsam/protokoll'
import { autostartAktiv, autostartSetzen } from './autostart' import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster' import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart' import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine' import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray' import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateStand } from './update' import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke') const smokeModus = process.argv.includes('--smoke')
// 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. // Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) { if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke')) 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 // Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. // 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()) { if (!app.requestSingleInstanceLock()) {
app.exit(0) app.exit(0)
} else { } else {
@@ -36,12 +48,19 @@ if (!app.requestSingleInstanceLock()) {
} }
function start(): void { function start(): void {
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
const protokollOrdner = join(app.getPath('userData'), 'logs')
const protokoll = new Protokoll(protokollOrdner, 'haupt')
protokoll.aufraeumen()
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der // Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3). // Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen() const leine: LeineStatus = leineSetzen()
if (!leine.ok) { if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status. // R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`) console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
} }
let fenster: BrowserWindow | null = null let fenster: BrowserWindow | null = null
@@ -69,6 +88,7 @@ function start(): void {
smoke.datenbank = nachricht.datenbank.ok smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) { if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`) console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
} }
statusSenden() statusSenden()
smokePruefen() smokePruefen()
@@ -77,17 +97,33 @@ function start(): void {
} else if (nachricht.art === 'einstellungen') { } else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus // Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8). // der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !smokeModus) { if (!updateGestartet && !ohneNebenwirkungen) {
updateGestartet = true updateGestartet = true
updateEinrichten( updateEinrichten(
nachricht.werte['updateUrl'] ?? '', nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0, () => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text), (text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
() => statusSenden(),
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
// der close-Horcher das Fenster nicht bloß versteckt.
() => beenden(),
) )
} }
} else if (nachricht.art === 'installer-starten') { } else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad) void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) { } 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. // Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast( toast(
'Disc erkannt', 'Disc erkannt',
@@ -98,10 +134,50 @@ function start(): void {
) )
} }
}, },
() => statusSenden(), (zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus, 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 // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen. // Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void { function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
@@ -110,25 +186,11 @@ function start(): void {
} else { } else {
aktiveVorgaenge.delete(status.id) aktiveVorgaenge.delete(status.id)
} }
if (fenster !== null && !fenster.isDestroyed()) { letzterRip = status
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) { anzeigeAktualisieren()
fenster.setProgressBar(status.prozent / 100) if (ohneNebenwirkungen) return
} else if (status.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else {
fenster.setProgressBar(-1)
}
}
if (status.phase === 'rippt') {
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else if (status.phase === 'komprimiert') {
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
if (smokeModus) return
if (status.phase === 'fertig') { if (status.phase === 'fertig') {
toast('Rippy ist fertig', status.text) toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') { } else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text) toast('Rippy: Fehler', status.text)
} }
@@ -172,6 +234,10 @@ function start(): void {
kern: kernVerwaltung.zustand(), kern: kernVerwaltung.zustand(),
autostart: autostartAktiv(), autostart: autostartAktiv(),
update: updateStand(), update: updateStand(),
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
} }
fenster.webContents.send('haupt-status', status) fenster.webContents.send('haupt-status', status)
} }
@@ -185,6 +251,11 @@ function start(): void {
statusSenden() statusSenden()
smokePruefen() smokePruefen()
}) })
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
// maximiert wird, nicht nur über unseren Knopf.
fenster.on('maximize', () => statusSenden())
fenster.on('unmaximize', () => statusSenden())
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray. // § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => { fenster.on('close', (ereignis) => {
if (beendenGewollt) return if (beendenGewollt) return
@@ -195,7 +266,7 @@ function start(): void {
function fensterZeigen(): void { function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) { if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen() fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten() fensterVerdrahten()
return return
} }
@@ -219,6 +290,41 @@ function start(): void {
} }
}) })
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
if (fenster === null || fenster.isDestroyed()) return false
if (was === 'minimieren') {
fenster.minimize()
} else if (was === 'maximieren') {
if (fenster.isMaximized()) fenster.unmaximize()
else fenster.maximize()
} else if (was === 'schliessen') {
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
// ein laufender Rip darf davon nichts merken.
fenster.close()
} else {
return false
}
return fenster.isDestroyed() ? false : fenster.isMaximized()
})
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
ipcMain.handle('update-pruefen', () => {
updatePruefenJetzt()
return updateStand()
})
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
// statt still nichts zu tun (R4).
ipcMain.handle('update-installieren', () => {
const urteil = updateInstallierenJetzt()
if (!urteil.ok) statusSenden()
return urteil
})
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API. // Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => { ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
autostartSetzen(an === true) autostartSetzen(an === true)
@@ -237,6 +343,33 @@ function start(): void {
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0] return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
}) })
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
// Programme, sonst nichts.
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
properties: ['openFile'],
filters: [
{ name: 'Programme', extensions: ['exe'] },
{ name: 'Alle Dateien', extensions: ['*'] },
],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
return await shell.openPath(pfad)
})
ipcMain.handle('protokoll-oeffnen', async () => {
return await shell.openPath(protokollOrdner)
})
app.on('second-instance', () => { app.on('second-instance', () => {
fensterZeigen() fensterZeigen()
}) })
@@ -249,15 +382,16 @@ function start(): void {
app.on('before-quit', () => { app.on('before-quit', () => {
beendenGewollt = true beendenGewollt = true
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden() kernVerwaltung.beenden()
}) })
void app.whenReady().then(() => { void app.whenReady().then(() => {
kernVerwaltung.starten() kernVerwaltung.starten()
if (!smokeModus) { if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden) trayErstellen(fensterZeigen, beenden)
} }
fenster = fensterErstellen() fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten() fensterVerdrahten()
}) })
+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']
+95 -1
View File
@@ -17,11 +17,44 @@
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der // rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei. // ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
import koffi from 'koffi' import koffi from 'koffi'
import { kommandozeileBauen } from './kommandozeile'
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js: // Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
// denn, sie verlangen das Ausklinken beim Anlegen selbst
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
//
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
// electron-updater wird per `spawn(…, { detached: true })` gestartet
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
// beendet aber startet NICHT neu in der neuen version."
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
// zeigte deshalb erst ein falsches Negativ.
const CREATE_NO_WINDOW = 0x08000000
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist // JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION // x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
// bei Offset 16. // bei Offset 16.
@@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus {
} }
const info = Buffer.alloc(STRUKTUR_GROESSE) const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET) info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
if ( if (
!SetInformationJobObject( !SetInformationJobObject(
job, job,
@@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus {
return gesetzt return gesetzt
} }
} }
/**
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
*
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
* Kind, das Rippys eigenen Tod überleben MUSS — er installiert ja gerade
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
*
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
* `update-info.json` steht `isAdminRightsRequired: false`) — er liefe also
* wieder als Kind und stürbe wieder mit.
*/
export function ausserhalbDerLeineStarten(
programm: string,
argumente: readonly string[],
): { ok: boolean; fehler: string } {
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
)
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
const gestartet = CreateProcessW(
programm,
zeile,
null,
null,
false,
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
null,
null,
startupInfo,
prozessInfo,
)
if (!gestartet) {
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
}
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
// ohne Wirkung.
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
}
}
+41
View File
@@ -18,3 +18,44 @@ export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).', 'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
} }
} }
/** So viel Änderungstext zeigt das Fenster höchstens — der Rest wird
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
export const NEUERUNGEN_HOECHSTENS = 4000
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
*
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
* `string | Array<ReleaseNoteInfo> | null` — bei uns ein String
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
* ankommen können, sonst steht im Fenster „[object Object]".
*
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
* weitergereicht, was wie Auszeichnung aussieht. */
export function neuerungenText(roh: unknown): string {
const saubern = (t: string): string =>
t
.replace(/<[^>]*>/g, ' ')
.split('\n')
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
if (Array.isArray(roh)) {
const teile: string[] = []
for (const eintrag of roh) {
if (eintrag === null || typeof eintrag !== 'object') continue
const e = eintrag as { version?: unknown; note?: unknown }
const version = typeof e.version === 'string' ? e.version : ''
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
if (notiz.length === 0) continue
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
}
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
}
return ''
}
+189 -26
View File
@@ -2,42 +2,90 @@
// öffentlichen Gitea (Entscheid 6). Die vier Regeln: // öffentlichen Gitea (Entscheid 6). Die vier Regeln:
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg // 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT. // die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
// 2. NIE während eines Rips — weder laden noch installieren. // 2. NIE während eines Rips — weder prüfen, laden noch installieren.
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der // 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
// letzten erfolgreichen Prüfung steht im Status. // letzten erfolgreichen Prüfung steht im Status.
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf). // 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
//
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
// Knopf Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
import { app } from 'electron' import { app } from 'electron'
import electronUpdater from 'electron-updater' import electronUpdater from 'electron-updater'
import { updateUrlPruefen } from './update-regeln' import { INSTALLER_ARGUMENTE } from './kommandozeile'
import { ausserhalbDerLeineStarten } from './leine'
import { neuerungenText, updateUrlPruefen } from './update-regeln'
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln' export {
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
neuerungenText,
updateUrlPruefen,
} from './update-regeln'
const { autoUpdater } = electronUpdater const { autoUpdater } = electronUpdater
export interface UpdateStand { // EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */ export type { UpdateStand } from '../gemeinsam/nachrichten'
letzteErfolgreichePruefung: string import type { UpdateStand } from '../gemeinsam/nachrichten'
text: string
const ANFANG: UpdateStand = {
letzteErfolgreichePruefung: '',
text: 'noch nicht geprüft',
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
} }
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' } let stand: UpdateStand = { ...ANFANG }
let aenderungMelden: () => void = () => {
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
}
let ripLaeuftJetzt: () => boolean = () => false
let scharf = false
/** Der geladene Installer — leer, solange nichts bereitliegt. */
let installerPfad = ''
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
let installerGestartet = false
let rippyBeenden: () => void = () => app.quit()
export function updateStand(): UpdateStand { export function updateStand(): UpdateStand {
return { ...stand } return { ...stand }
} }
/** Stand ändern UND das Fenster benachrichtigen — nie das eine ohne das
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
* (der 26.07.-Fund „ein still scheiternder Hintergrund-Prozess"). */
function setzen(teil: Partial<UpdateStand>): void {
stand = { ...stand, ...teil }
aenderungMelden()
}
export function updateEinrichten( export function updateEinrichten(
eigeneUrl: string, eigeneUrl: string,
ripLaeuft: () => boolean, ripLaeuft: () => boolean,
melden: (text: string) => void, melden: (text: string) => void,
beiAenderung: () => void = () => {
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
},
beenden: () => void = () => app.quit(),
): void { ): void {
aenderungMelden = beiAenderung
ripLaeuftJetzt = ripLaeuft
rippyBeenden = beenden
if (!app.isPackaged) { if (!app.isPackaged) {
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
return return
} }
const pruefung = updateUrlPruefen(eigeneUrl) const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) { if (!pruefung.ok) {
stand.text = pruefung.grund setzen({ text: pruefung.grund })
melden(pruefung.grund) melden(pruefung.grund)
return return
} }
@@ -45,39 +93,154 @@ export function updateEinrichten(
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() }) autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
} }
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip. // WIR installieren, nicht electron-updater (01.09.2026, am lebenden
autoUpdater.autoInstallOnAppQuit = true // Objekt bezahlt): Sein eigener Weg startet den Installer per
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
autoUpdater.autoInstallOnAppQuit = false
scharf = true
autoUpdater.on('update-available', () => { // Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
// diese Zeile zurückkommt.
app.on('will-quit', () => {
if (!stand.bereit || ripLaeuftJetzt()) return
installerAnwerfen()
})
autoUpdater.on('update-available', (info) => {
const neuerungen = neuerungenText(info.releaseNotes)
if (ripLaeuft()) { if (ripLaeuft()) {
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der // Regel 2: nicht einmal herunterladen, solange gerippt wird — der
// tägliche Takt versucht es wieder. // tägliche Takt versucht es wieder.
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.' setzen({
neueVersion: info.version,
neuerungen,
laeuft: false,
fortschritt: -1,
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
})
melden(stand.text)
return return
} }
void autoUpdater.downloadUpdate().catch((fehler: unknown) => { setzen({
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}` neueVersion: info.version,
neuerungen,
laeuft: true,
fortschritt: 0,
text: `Version ${info.version} wird geladen …`,
}) })
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
void autoUpdater
.downloadUpdate()
.then((pfade: string[]) => {
// downloadUpdate liefert die abgelegten Dateien zurück
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
// ebenfalls heraus, ohne erneut zu laden.
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
if (exe.length > 0) installerPfad = exe
})
.catch((fehler: unknown) => {
setzen({
laeuft: false,
fortschritt: -1,
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
})
})
})
autoUpdater.on('download-progress', (fortschritt) => {
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
}) })
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.` setzen({
neueVersion: info.version,
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
bereit: true,
laeuft: false,
fortschritt: 100,
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
})
melden(stand.text) melden(stand.text)
}) })
autoUpdater.on('update-not-available', () => { autoUpdater.on('update-not-available', () => {
stand.letzteErfolgreichePruefung = new Date().toISOString() setzen({
stand.text = 'Rippy ist aktuell.' letzteErfolgreichePruefung: new Date().toISOString(),
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
text: 'Rippy ist aktuell.',
})
}) })
autoUpdater.on('error', (fehler) => { autoUpdater.on('error', (fehler) => {
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar. // Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
}) })
const pruefen = (): void => {
if (ripLaeuft()) return
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
pruefen() pruefen()
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.() setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
} }
function pruefen(): void {
if (ripLaeuftJetzt()) return
setzen({ laeuft: true, text: 'Suche nach Updates …' })
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
/** Der Knopf „Jetzt prüfen" im Fenster. Antwortet IMMER etwas — ein
* Knopf, der nichts sagt, ist schlimmer als keiner. */
export function updatePruefenJetzt(): void {
if (!scharf) {
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
aenderungMelden()
return
}
if (ripLaeuftJetzt()) {
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
return
}
pruefen()
}
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
* er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */
function installerAnwerfen(): { ok: boolean; grund: string } {
if (installerGestartet) return { ok: true, grund: '' }
if (installerPfad.length === 0) {
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
}
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
if (!urteil.ok) {
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
return { ok: false, grund: urteil.fehler }
}
installerGestartet = true
return { ok: true, grund: '' }
}
/** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn
* wirklich etwas bereitliegt — und nie mitten in einem Rip. */
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
if (!scharf || !stand.bereit) {
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
}
if (ripLaeuftJetzt()) {
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
}
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
// weg ist, und startet es hinterher wieder (`--force-run`).
const urteil = installerAnwerfen()
if (!urteil.ok) return urteil
rippyBeenden()
return { ok: true, grund: '' }
}
@@ -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,198 @@
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
//
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
//
// Drei Sicherungen, alle bewusst:
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
// Weg, wie Rippy Daten verlieren könnte.
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
import { join } from 'node:path'
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
export const STANDARD_AUFBEWAHRUNG = '7'
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
const text = (wert ?? '').trim().toLowerCase()
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
if (text === 'sofort' || text === '0') return { art: 'sofort' }
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
}
export function regelText(regel: AufbewahrungsRegel): string {
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
}
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
const beendet = new Date(beendetIso)
if (Number.isNaN(beendet.getTime())) return ''
if (regel.art === 'sofort') return beendet.toISOString()
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
}
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
export interface AufraeumKandidat {
id: string
phase: string
komprimiert: boolean
rohOrdner: string
rohGeloeschtAm: string | null
beendet: string | null
fertigeDateien: readonly string[]
}
export interface AufraeumZugriff {
existiert: (pfad: string) => boolean
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
groesse: (pfad: string) => number
ordnerLoeschen: (pfad: string) => void
}
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
existiert: existsSync,
groesse: (pfad) => {
try {
return statSync(pfad).size
} catch {
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
return 0
}
},
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
}
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
export function loeschHindernis(
v: AufraeumKandidat,
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): string {
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
for (const datei of v.fertigeDateien) {
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
}
if (regel.art === 'behalten') return 'Regel: behalten'
const frist = faelligAm(v.beendet, regel)
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
return ''
}
export interface AufraeumErgebnis {
geloescht: string[]
uebersprungen: Array<{ id: string; grund: string }>
fehler: Array<{ id: string; grund: string }>
}
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
export function aufraeumen(
vorgaenge: readonly AufraeumKandidat[],
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): AufraeumErgebnis {
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
for (const v of vorgaenge) {
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
if (hindernis.length > 0) {
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
continue
}
try {
zugriff.ordnerLoeschen(v.rohOrdner)
ergebnis.geloescht.push(v.id)
} catch (fehler) {
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
// gesagt — beim nächsten Takt kommt er wieder dran.
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
}
}
return ergebnis
}
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
export function ordnerGroesse(pfad: string): number {
let summe = 0
const gehen = (ordner: string): void => {
let eintraege: import('node:fs').Dirent[]
try {
eintraege = readdirSync(ordner, { withFileTypes: true })
} catch {
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
// erfunden.
return
}
for (const e of eintraege) {
const voll = join(ordner, e.name)
if (e.isDirectory()) gehen(voll)
else {
try {
summe += statSync(voll).size
} catch {
// siehe oben
}
}
}
}
if (existsSync(pfad)) gehen(pfad)
return summe
}
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
if (!existsSync(rohWurzel)) return []
const liste: FremderOrdner[] = []
let namen: string[]
try {
namen = readdirSync(rohWurzel, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
} catch (fehler) {
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
// bleibt dann leer, weil wirklich nichts gesehen wurde.
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
return liste
}
for (const name of namen) {
const voll = join(rohWurzel, name)
if (bekannte.has(voll) || bekannte.has(name)) continue
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
}
return liste.sort((a, b) => b.bytes - a.bytes)
}
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
* darf „fremden Ordner löschen" anfassen — nie etwas anderes. */
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
const kandidat = pfad.replace(/[\\/]+$/, '')
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
const rest = kandidat.slice(wurzel.length)
return /^[\\/][^\\/]+$/.test(rest)
}
+5 -17
View File
@@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs'
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog, // müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
// %ProgramFiles%) bleiben dagegen bei path.win32. // %ProgramFiles%) bleiben dagegen bei path.win32.
import { join } from 'node:path' import { join } from 'node:path'
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
// Auswahl-Dialog rechnet mit derselben Funktion).
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen. export { sichererName }
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt /** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */ * gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
@@ -48,7 +36,7 @@ export function zielordner(
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */ /** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string { export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie' const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`) return join(basis, name, staffelOrdnerName(staffel))
} }
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend. /** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
+472
View File
@@ -0,0 +1,472 @@
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
// noch komprimiert"),
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
// Neulesen mehr.
//
// Was hier NEU ist gegenüber 5.3.x:
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
// * 5.5.0 Arbeitsordner: Liegt die Ablage auf dem Server, rechnet HandBrake
// LOKAL (Arbeitsordner\fertig\<Vorgang>) und erst das Ergebnis wandert
// über das Netz — kein Encode gegen eine SMB-Freigabe.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from './auftrag'
export interface AblageUmgebung {
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
handbrake: () => string
/** Wurzel der Ablage. */
ablage: () => string
/** 5.5.0: eigener lokaler Arbeitsordner — '' wenn keiner (dann wird
* direkt in die Ablage gerechnet, wie bisher). */
arbeitsordner?: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date
/** Datei verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
}
export interface AblageErgebnis {
status: 'fertig' | 'fehler' | 'abgebrochen'
zielOrdner: string
dateien: string[]
text: string
komprimiert: boolean
preset: string
fertigBytes: number
hinweise: string[]
/** 5.5.0: Welche Folgen-Nummern vergeben wurden (leer bei Filmen oder ohne
* Zuordnung) — der Serien-Merker zählt daran weiter. */
folgenVergeben: number[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
export function dateiVerschieben(von: string, nach: string): void {
try {
renameSync(von, nach)
} catch (fehler) {
const code = (fehler as NodeJS.ErrnoException).code
if (code !== 'EXDEV') throw fehler
copyFileSync(von, nach)
unlinkSync(von)
}
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
// Ergebnis selbst.
return 0
}
}
export async function komprimierenUndAblegen(
auftrag: KompressionsAuftrag,
umgebung: AblageUmgebung,
melden: AblageMeldung,
abbruch: AbortSignal,
): Promise<AblageErgebnis> {
const einstellungen = umgebung.einstellungen()
const handbrake = umgebung.handbrake()
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
const komprimiert = soll && handbrake.length > 0
const preset =
auftrag.preset.length > 0
? auftrag.preset
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
const fehlschlag = (text: string, statusPreset: string, ist: boolean): AblageErgebnis => ({
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: [],
text,
komprimiert: ist,
preset: statusPreset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
})
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
const alsSerie = dateien.some((d) => d.rolle === 'folge')
const staffel = auftrag.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
const fertigOrdner =
auftrag.zielOrdner.length > 0
? auftrag.zielOrdner
: alsSerie
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
try {
mkdirSync(fertigOrdner, { recursive: true })
} catch (fehler) {
return fehlschlag(`Zielordner nicht anlegbar (${fertigOrdner}): ${String(fehler)} — ist die Ablage erreichbar?`, '', komprimiert)
}
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
// 5.5.0: Eigener Arbeitsordner → HandBrake schreibt LOKAL, danach wird
// verschoben. Ohne ihn wie bisher direkt ins Ziel.
const arbeit = (umgebung.arbeitsordner?.() ?? '').trim()
const zwischen = komprimiert && arbeit.length > 0 ? join(arbeit, 'fertig', auftrag.vorgangId) : ''
if (zwischen.length > 0) mkdirSync(zwischen, { recursive: true })
const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
}
const gewichte = dateien.map((d) => d.groesseBytes)
const scannen = umgebung.spurenScannen ?? spurenScannen
const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = []
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
const nachzuziehen: Array<[string, string]> = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
// ihren Namen bis zur Episoden-Zuordnung.
const nummern = rollenNummern(dateien.map((d) => d.rolle))
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const [nummer, anzahl] = nummern[i]
const zielDatei = join(
zielFuer(datei.rolle),
zielDateiname({
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
alsSerie,
rolle: datei.rolle,
nummer,
anzahl,
original: basename(datei.pfad),
}),
)
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
phase: 'legt-ab',
prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
})
try {
verschieben(datei.pfad, zielDatei)
} catch (fehler) {
return fehlschlag(
`Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
'',
false,
)
}
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue
}
const wahl = sprachwahl(datei, global)
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
)
}
if (fehlt.untertitel.length > 0) {
hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
}
} else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
}
}
const ausgabe = zwischen.length > 0 ? join(zwischen, basename(datei.pfad)) : zielDatei
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''}`,
})
const encode = await komprimieren({
programm: handbrake,
eingabe: datei.pfad,
ausgabe,
preset,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
abbruch,
aufFortschritt: (prozent, restS) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
restS,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
})
if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (encode.status === 'error') {
return fehlschlag(
`Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
preset,
true,
)
}
if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
}
// 5.5.0: Lokal Gerechnetes auf die Ablage bringen — erst jetzt, da alle
// Encodes gelungen sind. Ein Netzfehler hier kostet keinen Encode: Die
// Dateien liegen im Arbeitsordner und der Vorgang sagt, wo.
for (let i = 0; i < nachzuziehen.length; i++) {
const [von, nach] = nachzuziehen[i]
if (abbruch.aborted) return abgebrochen(fertigOrdner, [], preset, komprimiert, hinweise)
melden({ phase: 'legt-ab', prozent: 100, text: `Verschiebe auf die Ablage ${i + 1}/${nachzuziehen.length}: ${basename(nach)}` })
try {
verschieben(von, nach)
} catch (fehler) {
return fehlschlag(
`Verschieben auf die Ablage fehlgeschlagen: ${String(fehler)} — die fertigen Dateien liegen unter ${zwischen}, die Roh-Dateien unter ${auftrag.rohOrdner}.`,
preset,
true,
)
}
}
if (zwischen.length > 0) {
try {
rmSync(zwischen, { recursive: true, force: true })
} catch {
// Ein leerer Zwischenordner, der stehen bleibt, stört nicht.
}
}
// ── Wiederholen: die alte Fassung weicht der neuen — JETZT erst, da alle
// neuen Dateien da sind. Vorher hätte ein gescheiterter Lauf die
// erste Fassung gekostet; danach stünde sie bei Serien der
// Umbenennung im Weg („existiert schon"). ────────────────────────
for (const alt of auftrag.ersetzt ?? []) {
if (fertigeDateien.includes(alt) || !existsSync(alt)) continue
try {
unlinkSync(alt)
hinweise.push(`Alte Fassung ersetzt: ${basename(alt)}`)
} catch (fehler) {
hinweise.push(`Alte Fassung nicht gelöscht (${basename(alt)}): ${String(fehler)}`)
}
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
let folgenVergeben: number[] = []
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
} else {
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
folgenVergeben = episoden
} else {
hinweise.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
try {
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle:
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
anzeigeTitel,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
// unter dem alten Namen nicht mehr gibt.
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
let fertigBytes = 0
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const jetzt = umgebung.jetzt?.() ?? new Date()
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
umgebung.bibliothekEintragen({
fingerprint: auftrag.fingerprint,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
typ: auftrag.discTyp,
groesseBytes: fertigBytes,
ablageort: fertigOrdner,
datum: begonnen.toISOString(),
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
}
const text =
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
(refresh.length > 0 ? ` ${refresh}` : '') +
(auftrag.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '')
return {
status: 'fertig',
zielOrdner: fertigOrdner,
dateien: endgueltig,
text,
komprimiert,
preset: komprimiert ? preset : '',
fertigBytes,
hinweise,
folgenVergeben,
}
}
function abgebrochen(
zielOrdner: string,
dateien: string[],
preset: string,
komprimiert: boolean,
hinweise: string[],
): AblageErgebnis {
return {
status: 'abgebrochen',
zielOrdner,
dateien,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
komprimiert,
preset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
}
}
/** Alle MKVs im Zielordner (und in extras\) — nach dem Umbenennen der Folgen
* ist das die einzige verlässliche Liste. */
function vorhandeneMkv(ordner: string, extras: string): string[] {
const liste: string[] = []
for (const o of [ordner, extras]) {
if (!existsSync(o)) continue
try {
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
} catch {
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
}
}
return liste.sort()
}
+210
View File
@@ -0,0 +1,210 @@
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
//
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
pfad: string
titelNr: number
rolle: TitelRolle
dauerS: number
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
groesseBytes: number
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
export interface KompressionsAuftrag {
vorgangId: string
laufwerk: string
discTyp: string
rohOrdner: string
dateien: RohDatei[]
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
zuordnung: Zuordnung | null
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
staffel: number | null
ersteFolge: number | null
discNr: number | null
fingerprint: string | null
lesefehler: boolean
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
hinweise: string[]
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
begonnen: string
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
preset: string
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs — die neue
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
* '' = Zielordner wird frisch bestimmt. */
zielOrdner: string
/** Beim Wiederholen: die fertigen Dateien des ERSTEN Durchlaufs. Sie
* werden gelöscht, sobald die neuen fertig sind — vorher nicht, damit
* ein gescheiterter zweiter Lauf die erste Fassung nicht kostet. Bei
* Serien stünden sie sonst der Umbenennung im Weg („existiert schon"). */
ersetzt?: string[]
}
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
return JSON.stringify(auftrag)
}
/** Liest einen gemerkten Auftrag zurück — null, wenn er nicht die Form hat
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
export function auftragAusJson(text: string): KompressionsAuftrag | null {
let roh: unknown
try {
roh = JSON.parse(text)
} catch {
return null
}
if (typeof roh !== 'object' || roh === null) return null
const a = roh as Record<string, unknown>
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
const dateien: RohDatei[] = []
for (const d of a.dateien) {
if (typeof d !== 'object' || d === null) return null
const e = d as Record<string, unknown>
if (typeof e.pfad !== 'string') return null
dateien.push({
pfad: e.pfad,
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
return {
vorgangId: a.vorgangId,
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
rohOrdner: a.rohOrdner,
dateien,
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
staffel: typeof a.staffel === 'number' ? a.staffel : null,
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
discNr: typeof a.discNr === 'number' ? a.discNr : null,
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
lesefehler: a.lesefehler === true,
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
preset: typeof a.preset === 'string' ? a.preset : '',
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
ersetzt: Array.isArray(a.ersetzt) ? a.ersetzt.map(String) : [],
}
}
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
export function filmAus(wert: unknown): MetaVorschlag | null {
if (typeof wert !== 'object' || wert === null) return null
const f = wert as Record<string, unknown>
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
return {
typ: f.typ === 'tv' ? 'tv' : 'movie',
tmdbId: f.tmdbId,
titel: f.titel,
jahr: typeof f.jahr === 'number' ? f.jahr : null,
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
untertitel: string[]
/** Die erste Untertitelspur vorwählen — weil der Ton nicht in der
* Wunschsprache ist. */
untertitelStandard: boolean
}
/**
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
* „japanischer Ton und braucht dringend die deutschen untertitel" —
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
global: { audio: readonly string[]; untertitel: readonly string[] },
): SprachWahl {
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
// Regel — null heißt ausdrücklich „keine Vorwahl".
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
const v = datei.untertitelVorwahl
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
}
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
return { audio, untertitel, untertitelStandard }
}
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
}
/**
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
* Vorher sprang der Balken je Titel auf null zurück — bei 2 × 16 GB stand
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
* Teile gleich. `prozent` < 0 heißt „vom laufenden Teil noch keine Angabe".
*/
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
if (gewichte.length === 0) return -1
const gleich = gewichte.some((g) => !(g > 0))
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
const gesamt = g.reduce((s, w) => s + w, 0)
if (gesamt <= 0) return -1
const i = Math.min(Math.max(0, index), g.length - 1)
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
}
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
* Warnung — der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
export function folgenPlausibel(
dateiSekunden: readonly number[],
nummern: readonly number[],
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
toleranz = 0.15,
): string[] {
const warnungen: string[] = []
for (let i = 0; i < nummern.length; i++) {
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
const ist = dateiSekunden[i] ?? 0
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
warnungen.push(
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
)
}
}
return warnungen
}
+89
View File
@@ -0,0 +1,89 @@
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
// Ampel die Regeln prüft:
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
// bleibt der Zeitstempel-Name des Vorgangs.
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { sichererName } from '../../gemeinsam/ablagepfad'
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
import type { RohDatei } from './auftrag'
export interface DiscAngaben {
staffel: number | null
discNr: number | null
}
export function rohOrdnerName(
zuordnung: Zuordnung | null,
angaben: DiscAngaben | null,
vorgangsName: string,
existiert: (name: string) => boolean,
): string {
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
let basis: string
if (zuordnung.meta.typ === 'tv') {
basis = sichererName(zuordnung.titel)
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
} else {
basis = sichererName(zuordnung.titel, zuordnung.jahr)
}
if (basis.length === 0) return vorgangsName
if (!existiert(basis)) return basis
for (let n = 2; n < 100; n++) {
const kandidat = `${basis} (${n})`
if (!existiert(kandidat)) return kandidat
}
return vorgangsName
}
export interface FilmGruppe {
film: MetaVorschlag
dateien: RohDatei[]
}
export interface FilmGruppen {
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
primaer: RohDatei[]
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
* übernimmt diesen Film. */
primaerFilm: MetaVorschlag | null
weitere: FilmGruppe[]
}
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
const primaer: RohDatei[] = []
const weitere: FilmGruppe[] = []
for (const d of dateien) {
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
primaer.push(d)
continue
}
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
if (gruppe !== undefined) gruppe.dateien.push(d)
else weitere.push({ film: d.film, dateien: [d] })
}
if (primaer.length === 0 && weitere.length > 0) {
const erste = weitere.shift()!
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
}
return { primaer, primaerFilm: null, weitere }
}
/** Eine Zuordnung aus dem Vorschlag allein — wenn TMDb-Details nicht zu
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
return {
titel: v.titel,
jahr: v.jahr,
confidence: 1,
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
}
}
+510 -227
View File
@@ -3,34 +3,64 @@
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und // AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
// hängen damit an der Prozess-Leine (R6). // hängen damit an der Prozess-Leine (R6).
// //
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein // Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in // RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions-
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der // Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts →
// Einstellung im UI (W-5) eine Stimme. // ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' // liegen — die nächste Disc kann rein, während die vorige noch rechnet
// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
// nach einem Abbruch nachsehen.
//
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
// aus dem Abbild (iso:), das nicht mehr hängen kann.
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path' import { basename, join } from 'node:path'
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' import type {
import { baueNfo } from '../ablage/nfo' BibliothekEintrag,
import { bibliothekRefresh } from '../ablage/medienserver' GeraeteInfo,
import { posterSpeichern } from '../ablage/poster' RipAuftragOptionen,
import { zielordner } from '../ablage/struktur' RipAuftragTitel,
import { komprimieren } from '../komprimieren/handbrake' RipStatus,
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' TitelRolle,
} from '../../gemeinsam/nachrichten'
import type { Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung' import type { Zuordnung } from '../metadaten/zuordnung'
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso' import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv' import { rippen } from '../rip/makemkv'
import { spawn } from 'node:child_process' import { rettungsAbbildDatei } from '../rip/rettung'
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
interface LaufenderVorgang { interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */
abbruch: AbortController abbruch: AbortController
/** Beendet nur den gerade laufenden Titel (überspringen). */
titelAbbruch?: AbortController
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
uebersprungen?: boolean
status: RipStatus status: RipStatus
} }
/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */
export interface VorgangBeginn {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
rohOrdner: string
begonnen: string
}
export interface PipelineUmgebung { export interface PipelineUmgebung {
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
makemkvcon: () => string makemkvcon: () => string
@@ -50,25 +80,52 @@ export interface PipelineUmgebung {
) => number ) => number
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ /** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
ablage: () => string ablage: () => string
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
rohWurzel?: () => string
einstellungen: () => Einstellungen einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ
* greift nur, wenn der Nutzer nichts eingestellt hat. */
presetEmpfehlung?: (discTyp: string) => string
melden: (status: RipStatus) => void melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null fingerprint?: (id: string) => string | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein6.6). */ /** Was die Disc über sich selbst sagt — für die Staffel-Nummer4.2). */
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) für
* Audio-CD und ISO, die keine Kompression durchlaufen. */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */ /** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date jetzt?: () => Date
/** 5.4.0: Der Rip ist durch der Kern merkt sich den Vorgang und reiht
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
* Disc) null, wenn nichts zu holen ist. */
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste
* Disc soll rein, während die Kompression läuft. */
nachRipAuswerfen?: (id: string) => void
/** 5.5.0: Das Rettungs-Abbild lesen — injizierbar für Tests. */
rettungsAbbild?: typeof rettungsAbbildDatei
}
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
interface LetzterAuftrag {
auftrag: RipAuftragTitel[]
optionen: RipAuftragOptionen
} }
export class Pipeline { export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>() private laufend = new Map<string, LaufenderVorgang>()
/** 5.5.0: Rippy hat an diesem Laufwerk Lesefehler GESEHEN nur dann
* wird das Rettungs-Abbild angeboten (Commander: nicht bei heilen Discs). */
private fehlerGesehen = new Map<string, string>()
private letzterAuftrag = new Map<string, LetzterAuftrag>()
constructor(private readonly umgebung: PipelineUmgebung) {} constructor(private readonly umgebung: PipelineUmgebung) {}
@@ -76,18 +133,60 @@ export class Pipeline {
return this.laufend.has(id) return this.laufend.has(id)
} }
/** Warum eine Rettung angeboten wird — '' wenn keine Fehler gesehen wurden. */
rettungGrund(id: string): string {
return this.fehlerGesehen.get(id) ?? ''
}
/** Beim Entnehmen der Disc: Was gesehen wurde, gilt nicht mehr. */
discEntnommen(id: string): void {
this.fehlerGesehen.delete(id)
this.letzterAuftrag.delete(id)
}
private melden(status: RipStatus): void { private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id) const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
this.umgebung.melden(status) if (eintrag !== undefined) eintrag.status = mitRettung
this.umgebung.melden(mitRettung)
} }
private fehler(id: string, text: string): RipStatus { private fehler(id: string, text: string): RipStatus {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
} }
/** Startet die Kette für die ganze Disc ('all'). */ /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
async starten(id: string): Promise<void> { * weiter der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
* während Folge 2 heil daneben lag). */
ueberspringen(id: string): void {
const vorgang = this.laufend.get(id)
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
vorgang.uebersprungen = true
vorgang.titelAbbruch.abort()
}
/** 5.5.0: Rettungs-Abbild + Rip daraus mit dem letzten Auftrag dieses
* Laufwerks. Nur, wenn Rippy vorher Lesefehler gesehen hat. */
async rettungStarten(id: string): Promise<void> {
const grund = this.fehlerGesehen.get(id)
if (grund === undefined) {
this.umgebung.melden(
this.fehler(id, 'Rettungs-Abbild nur bei GESEHENEN Lesefehlern — an dieser Disc hat Rippy keine bemerkt. Erst normal rippen.'),
)
return
}
const letzter = this.letzterAuftrag.get(id)
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
}
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
async starten(
id: string,
auftrag: readonly RipAuftragTitel[] = [],
optionen: RipAuftragOptionen = {},
rettung = false,
): Promise<void> {
if (this.laufend.has(id)) { if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
return return
@@ -125,8 +224,7 @@ export class Pipeline {
const abbruch = new AbortController() const abbruch = new AbortController()
const startZeit = this.umgebung.jetzt?.() ?? new Date() const startZeit = this.umgebung.jetzt?.() ?? new Date()
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) const vorgangsName = vorgangsNameFuer(geraet, startZeit)
const vorgangsName = `${geraet.typ}-${zeit}`
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
if (geraet.typ === 'cd') { if (geraet.typ === 'cd') {
@@ -142,208 +240,367 @@ export class Pipeline {
return return
} }
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
const start: RipStatus = { // behält seinen Zeitstempel-Namen als Kennung.
id, const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
phase: 'rippt', const zuordnung = this.umgebung.zuordnung?.(id) ?? null
prozent: -1, const startAngaben = this.umgebung.discAngaben?.(id) ?? null
text: `Rippe verlustfrei nach ${rohOrdner}`, const rohOrdner = join(
dateien: [], rohWurzel,
lesefehler: false, rohOrdnerName(
} zuordnung,
this.laufend.set(id, { abbruch, status: start }) { staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
this.melden(start) vorgangsName,
(name) => existsSync(join(rohWurzel, name)),
const rip = await rippen({ ),
programm, )
geraetepfad: geraet.pfad, mkdirSync(rohOrdner, { recursive: true })
zielOrdner: rohOrdner, const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
titel: 'all', this.umgebung.vorgangBeginnt?.({
abbruch: abbruch.signal, id: vorgangsName,
aufFortschritt: (prozent) => { laufwerk: id,
const alt = this.laufend.get(id)?.status typ: geraet.typ,
if (alt !== undefined) this.melden({ ...alt, prozent }) titel: anzeigeTitel,
}, jahr: zuordnung?.jahr ?? null,
aufMeldung: (_code, text) => { rohOrdner,
const alt = this.laufend.get(id)?.status begonnen: startZeit.toISOString(),
if (alt !== undefined) this.melden({ ...alt, text })
},
}) })
if (rip.status === 'cancelled') { const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const hinweise: string[] = []
this.laufend.set(id, {
abbruch,
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName },
})
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
this.laufend.delete(id) this.laufend.delete(id)
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) // Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
return // Punkt „Aufräumen nach Abbruch").
} if (alleDateien.size === 0) {
if (rip.status === 'error') {
this.laufend.delete(id)
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
return
}
const lesefehlerHinweis = rip.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: ''
// ── Kompression (W-3) ────────────────────────────────────────────────
const einstellungen = this.umgebung.einstellungen()
const handbrake = this.umgebung.handbrake()
const soll = komprimierenFuer(geraet.typ, einstellungen)
if (!soll || handbrake.length === 0) {
const grund = !soll
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
this.laufend.delete(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text: `Fertig. ${grund}${lesefehlerHinweis}`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const fertigOrdner =
zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
mkdirSync(fertigOrdner, { recursive: true })
const fertigeDateien: string[] = []
for (let i = 0; i < rip.dateien.length; i++) {
const eingabe = rip.dateien[i]
const ausgabe = join(fertigOrdner, basename(eingabe))
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'komprimiert',
prozent: -1,
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
})
}
const encode = await komprimieren({
programm: handbrake,
eingabe,
ausgabe,
preset,
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const stand = this.laufend.get(id)?.status
if (stand !== undefined) {
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
}
},
})
if (encode.status === 'cancelled') {
this.laufend.delete(id)
this.melden({
id,
phase: 'abgebrochen',
prozent: -1,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
if (encode.status === 'error') {
this.laufend.delete(id)
this.melden({
id,
phase: 'fehler',
prozent: -1,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
fertigeDateien.push(encode.ausgabe)
}
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
}
try {
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of fertigeDateien) {
try { try {
groesse += statSync(datei).size rmSync(rohOrdner, { recursive: true, force: true })
} catch { } catch (fehler) {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
// am Eintrag selbst.
} }
} }
this.umgebung.bibliothekEintragen({ this.umgebung.vorgangEndet?.(vorgangsName, phase, text)
fingerprint, this.melden({
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, id,
jahr: zuordnung?.jahr ?? null, phase,
typ: geraet.typ, prozent: -1,
groesseBytes: groesse, text,
ablageort: fertigOrdner, dateien: [...alleDateien],
datum: startZeit.toISOString(), lesefehler: lesefehlerGesamt,
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), vorgangId: vorgangsName,
posterPfad: zuordnung?.meta.posterPfad ?? '',
}) })
} }
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
let quellePfad = geraet.pfad
if (rettung) {
const isoPfad = join(rohOrdner, 'rettung.iso')
this.melden({
id,
phase: 'rettet',
prozent: 0,
text: `Rettungs-Abbild: lese die Disc Sektor für Sektor nach ${isoPfad} — Unlesbares wird mit Nullen gefüllt …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: 0, stellen: 0 },
})
const lesen = this.umgebung.rettungsAbbild ?? rettungsAbbildDatei
const ergebnis = await lesen(
geraet.pfad,
isoPfad,
geraet.groesseBytes,
(prozent, unlesbar, stellen) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'rettet',
prozent,
text: `Rettungs-Abbild: ${prozent} % — ${unlesbar} unlesbare Sektoren an ${stellen} Stellen (mit Nullen gefüllt)`,
rettung: { unlesbareSektoren: unlesbar, stellen },
})
}
},
abbruch.signal,
)
if (ergebnis.status === 'cancelled') {
beenden('abgebrochen', 'Rettungs-Abbild abgebrochen.')
return
}
if (ergebnis.status === 'error') {
beenden('fehler', `Rettungs-Abbild gescheitert: ${ergebnis.fehler}`)
return
}
hinweise.push(
`Rettungs-Abbild: ${ergebnis.unlesbareSektoren} unlesbare Sektoren an ${ergebnis.stellen.length} Stellen mit Nullen gefüllt (Bericht: rettung.iso.rettung.txt).`,
)
quellePfad = isoPfad
this.melden({
id,
phase: 'rippt',
prozent: -1,
text: `Rettungs-Abbild fertig (${ergebnis.unlesbareSektoren} Sektoren genullt) — rippe jetzt aus dem Abbild …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: ergebnis.unlesbareSektoren, stellen: ergebnis.stellen.length },
})
}
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
// Trailer, Werbung und Alterskennzeichen.
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
// vorher sprang er je Titel zurück auf null.
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
const gescheitert: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
const titelAbbruch = new AbortController()
const weiterreichen = (): void => titelAbbruch.abort()
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
const vorgang = this.laufend.get(id)
if (vorgang !== undefined) {
vorgang.titelAbbruch = titelAbbruch
vorgang.uebersprungen = false
}
const beschriftung =
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
this.melden({
id,
phase: 'rippt',
prozent: gewichteterFortschritt(gewichte, i, -1),
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
titelGesamt: laeufe.length,
haengt: null,
vorgangId: vorgangsName,
})
// Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch
// oder Fehler ist eine halbe Datei und wird entfernt (5.4.0).
const vorher = dateienIn(rohOrdner)
const rip = await rippen({
programm,
geraetepfad: quellePfad,
zielOrdner: rohOrdner,
titel: welcher,
abbruch: titelAbbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, text })
},
aufHaengt: (klemmt) => {
// Der Defekt-Wächter hat angeschlagen: Ab jetzt ist die Rettung
// eine Option (5.5.0).
if (klemmt !== null && !rettung) this.fehlerGesehen.set(id, `Defekt-Wächter: hängt an Byte ${klemmt.offset}`)
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
},
})
abbruch.signal.removeEventListener('abort', weiterreichen)
if (rip.status === 'cancelled') {
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
// Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) {
beenden(
'abgebrochen',
'Abgebrochen.' +
(aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') +
(alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''),
)
return
}
uebersprungen.push(beschriftung)
continue
}
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
continue
}
// rippen() liefert den ganzen Zielordner zurück, also auch die
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
}
}
const nachLauf = this.laufend.get(id)
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
if (alleDateien.size === 0) {
beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.')
return
}
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
// ── Übergabe an die Warteschlange (5.4.0) ──────────────────────────
const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => {
const a = auftragJeDatei.get(pfad)
const rolle: TitelRolle = a?.rolle ?? 'hauptfilm'
return {
pfad,
titelNr: a?.nr ?? -1,
rolle,
dauerS: a?.dauerS ?? 0,
groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
const angaben = this.umgebung.discAngaben?.(id) ?? null
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
let hauptDateien = gruppen.primaer
let hauptZuordnung = zuordnung
if (gruppen.primaerFilm !== null) {
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
}
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
for (let n = 0; n < gruppen.weitere.length; n++) {
const gruppe = gruppen.weitere[n]
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
const gruppenId = `${vorgangsName}-${n + 2}`
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
try {
mkdirSync(gruppenOrdner, { recursive: true })
for (const d of gruppe.dateien) {
const neu = join(gruppenOrdner, basename(d.pfad))
renameSync(d.pfad, neu)
d.pfad = neu
}
} catch (fehler) {
hinweise.push(`${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
hauptDateien = [...hauptDateien, ...gruppe.dateien]
continue
}
this.umgebung.vorgangBeginnt?.({
id: gruppenId,
laufwerk: id,
typ: geraet.typ,
titel: gruppenZuordnung.titel,
jahr: gruppenZuordnung.jahr,
rohOrdner: gruppenOrdner,
begonnen: startZeit.toISOString(),
})
weitereAuftraege.push({
titel: gruppenZuordnung.titel,
auftrag: {
vorgangId: gruppenId,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner: gruppenOrdner,
dateien: gruppe.dateien,
zuordnung: gruppenZuordnung,
staffel: null,
ersteFolge: null,
discNr: angaben?.discNr ?? null,
fingerprint: null,
lesefehler: lesefehlerGesamt,
hinweise: [],
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
},
})
}
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner,
dateien: hauptDateien,
zuordnung: hauptZuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null,
fingerprint: this.umgebung.fingerprint?.(id) ?? null,
lesefehler: lesefehlerGesamt,
hinweise,
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
}
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
const weitereTitel: string[] = []
for (const w of weitereAuftraege) {
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
weitereTitel.push(w.titel)
}
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
this.laufend.delete(id) this.laufend.delete(id)
this.umgebung.nachRipAuswerfen?.(id)
this.melden({ this.melden({
id, id,
phase: 'fertig', phase: 'fertig',
prozent: 100, prozent: 100,
text: text:
`Fertig — komprimiert nach ${fertigOrdner}.` + `Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
(refresh.length > 0 ? ` ${refresh}` : '') + (platz > 0
lesefehlerHinweis, ? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
dateien: fertigeDateien, : 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
lesefehler: rip.lesefehler, (weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
(lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
(hinweise.length > 0 ? `${hinweise.join(' ')}` : ''),
dateien: dateien.map((d) => d.pfad),
lesefehler: lesefehlerGesamt,
vorgangId: vorgangsName,
}) })
} }
@@ -435,13 +692,7 @@ export class Pipeline {
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0 let groesse = 0
for (const datei of dateien) { for (const datei of dateien) groesse += groesseVon(datei)
try {
groesse += statSync(datei).size
} catch {
// Anzeige-Komfort — siehe oben.
}
}
this.umgebung.bibliothekEintragen({ this.umgebung.bibliothekEintragen({
fingerprint, fingerprint,
titel: ordnerName, titel: ordnerName,
@@ -504,22 +755,54 @@ export class Pipeline {
} }
} }
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
} }
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an
// der Liste selbst.
return 0
}
}
function dateienIn(ordner: string): Set<string> {
try {
return new Set(readdirSync(ordner))
} catch {
// Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und
// im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist.
return new Set()
}
}
/** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines
* abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
* (das Rettungs-Abbild). Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
const entfernt: string[] = []
for (const name of dateienIn(ordner)) {
if (vorher.has(name) || schonen.includes(name)) continue
try {
unlinkSync(join(ordner, name))
entfernt.push(name)
} catch (fehler) {
// Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da.
entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`)
}
}
return entfernt
}
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
return new Promise((fertig) => { return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
const stuecke: Buffer[] = [] const stuecke: Buffer[] = []
kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
}) })
} }
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
copyFileSync(eingabe, ausgabe)
}
@@ -0,0 +1,176 @@
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
// werden zusammen nicht schneller.
//
// 5.5.0: Pause (Commander: „zum Beispiel beim Spielen") — der laufende
// Film wird fertig gerechnet, der nächste wartet, bis es weitergeht.
//
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
import type { KompressionStand } from '../../gemeinsam/nachrichten'
import type { KompressionsAuftrag } from './auftrag'
import type { AblageErgebnis, AblageMeldung } from './ablegen'
export type Ausfuehren = (
auftrag: KompressionsAuftrag,
melden: AblageMeldung,
abbruch: AbortSignal,
) => Promise<AblageErgebnis>
interface Eintrag {
auftrag: KompressionsAuftrag
titel: string
abbruch: AbortController
}
export interface WarteschlangenStand {
laeuft: KompressionStand | null
wartend: KompressionStand[]
}
export class KompressionsWarteschlange {
private wartend: Eintrag[] = []
private laufend: Eintrag | null = null
private laufendStand: KompressionStand | null = null
private arbeitet = false
private pausiert = false
constructor(
private readonly ausfuehren: Ausfuehren,
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
private readonly beiStand: (stand: WarteschlangenStand) => void,
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
) {}
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
this.beiStand(this.stand())
void this.schleife()
return platz
}
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
abbrechen(vorgangId: string): boolean {
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
if (i >= 0) {
const [weg] = this.wartend.splice(i, 1)
this.beiStand(this.stand())
void this.beiErgebnis(weg.auftrag, {
status: 'abgebrochen',
zielOrdner: '',
dateien: [],
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
folgenVergeben: [],
})
return true
}
if (this.laufend?.auftrag.vorgangId === vorgangId) {
this.laufend.abbruch.abort()
return true
}
return false
}
/** Pause: Der laufende Auftrag wird fertig, der nächste startet erst nach
* `pausieren(false)`. */
pausieren(an: boolean): void {
this.pausiert = an
if (!an) void this.schleife()
}
istPausiert(): boolean {
return this.pausiert
}
stand(): WarteschlangenStand {
return {
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
wartend: this.wartend.map((e) => ({
vorgangId: e.auftrag.vorgangId,
titel: e.titel,
prozent: -1,
text: this.pausiert ? 'wartet (Pause)' : 'wartet',
phase: 'wartet',
})),
}
}
laeuftEtwas(): boolean {
return this.laufend !== null || this.wartend.length > 0
}
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
kennt(vorgangId: string): boolean {
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
}
private async schleife(): Promise<void> {
if (this.arbeitet) return
this.arbeitet = true
try {
while (this.wartend.length > 0 && !this.pausiert) {
const eintrag = this.wartend.shift()!
this.laufend = eintrag
this.laufendStand = {
vorgangId: eintrag.auftrag.vorgangId,
titel: eintrag.titel,
prozent: -1,
text: 'Beginne …',
phase: 'komprimiert',
}
this.beiStand(this.stand())
let ergebnis: AblageErgebnis
try {
ergebnis = await this.ausfuehren(
eintrag.auftrag,
(stand) => {
if (this.laufendStand === null) return
this.laufendStand = { ...this.laufendStand, ...stand }
this.beiStand(this.stand())
},
eintrag.abbruch.signal,
)
} catch (fehler) {
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
// kein stilles Ende der Schlange.
ergebnis = {
status: 'fehler',
zielOrdner: '',
dateien: [],
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
folgenVergeben: [],
}
}
this.laufend = null
this.laufendStand = null
try {
await this.beiErgebnis(eintrag.auftrag, ergebnis)
} catch (fehler) {
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
}
this.beiStand(this.stand())
}
} finally {
this.arbeitet = false
}
}
}
+186
View File
@@ -0,0 +1,186 @@
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
//
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
// ist, passiert NICHTS von selbst — es sagt nur, warum.
//
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
// prüft den Ablauf mit einer Fake-Uhr.
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { ABGELOEST } from '../rip/makemkv'
import type { Zuordnung } from '../metadaten/zuordnung'
export const AUTOMATIK_SICHER = 0.9
export interface AutomatikAbhaengigkeiten {
an(): boolean
sekunden(): number
ripLaeuft(id: string): boolean
titelLesen(id: string): Promise<TitelStand>
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
benachrichtigen(titel: string, text: string): void
/** Uhr — injizierbar. */
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
taktStoppen?: (zeiger: NodeJS.Timeout) => void
}
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
return stand.titel
.filter((t) => t.gewaehlt)
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
}
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
* Merker; ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
export function optionenAus(
angaben: { staffel: number | null; discNr: number | null } | null,
merker: { staffel: number; naechsteFolge: number } | null,
): RipAuftragOptionen {
const staffel = angaben?.staffel ?? merker?.staffel ?? null
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
return { staffel, ersteFolge }
}
export class AutomatikDienst {
private zeiger = new Map<string, NodeJS.Timeout>()
private staende = new Map<string, AutomatikStand>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: AutomatikAbhaengigkeiten,
) {}
stand(id: string): AutomatikStand | null {
return this.staende.get(id) ?? null
}
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
const stand = { id, aktiv, sekunden, text }
this.staende.set(id, stand)
this.kontext.anFenster({ art: 'automatik', stand })
}
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
const id = geraet.id
if (!this.abh.an()) return
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
if (this.abh.ripLaeuft(id)) return
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
return
}
if (schonGerippt !== null) {
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
return
}
this.melden(id, false, 0, 'Automatik: liest die Titel …')
const titel = await this.abh.titelLesen(id)
if (titel.fehler === ABGELOEST) return
if (this.abh.ripLaeuft(id)) return
if (titel.fehler.length > 0) {
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
return
}
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
return
}
this.countdown(id, titel)
}
private countdown(id: string, titel: TitelStand): void {
this.stopp(id, '')
let rest = this.abh.sekunden()
const n = titel.titel.filter((t) => t.gewaehlt).length
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
this.melden(id, true, rest, text(rest))
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
const zeiger = takt(() => {
rest -= 1
if (rest > 0) {
this.melden(id, true, rest, text(rest))
return
}
this.stopp(id, '')
this.starten(id, titel)
}, 1000)
this.zeiger.set(id, zeiger)
}
private starten(id: string, titel: TitelStand): void {
if (this.abh.ripLaeuft(id)) return
const auftrag = auftragAusVorschlag(titel)
if (auftrag.length === 0) return
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
this.abh.ripStarten(id, auftrag, optionen)
}
private merkerZuordnung = new Map<string, Zuordnung>()
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
if (zuordnung === null) this.merkerZuordnung.delete(id)
else this.merkerZuordnung.set(id, zuordnung)
}
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
const z = this.merkerZuordnung.get(id)
return z === undefined ? null : this.abh.serienMerker(z)
}
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
stopp(id: string, grund: string): void {
const zeiger = this.zeiger.get(id)
if (zeiger !== undefined) {
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
this.zeiger.delete(id)
}
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** Der Kasten in der Kachel verschwindet ein Rip hat begonnen (5.6.0):
* Countdown weg, Text weg. Ohne das stand Automatik gestoppt du
* entscheidest." noch unter dem fertigen Rip. */
leeren(id: string): void {
this.stopp(id, '')
if (this.staende.has(id)) this.melden(id, false, 0, '')
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
if (stand === undefined || !stand.aktiv) return
this.stopp(id, '')
const titel = this.letzteTitel.get(id)
if (titel !== undefined) this.starten(id, titel)
}
private letzteTitel = new Map<string, TitelStand>()
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
titelMerken(id: string, stand: TitelStand): void {
this.letzteTitel.set(id, stand)
}
discEntnommen(id: string): void {
this.stopp(id, '')
this.staende.delete(id)
this.letzteTitel.delete(id)
this.merkerZuordnung.delete(id)
}
}
@@ -0,0 +1,94 @@
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
// discord.com/developers/docs/resources/webhook („Execute Webhook":
// content ist eine Zeichenkette bis 2000 Zeichen).
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
// der ihn ausgelöst hat.
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export interface Kanaele {
discordWebhook: string
telegramToken: string
telegramChatId: string
}
/** Was an Discord geht (pur, testbar). */
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
}
/** Was an Telegram geht (pur, testbar). */
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
return {
url: `https://api.telegram.org/bot${token}/sendMessage`,
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
}
}
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
return {
discordWebhook: lesen('discordWebhook').trim(),
telegramToken: lesen('telegramToken').trim(),
telegramChatId: lesen('telegramChatId').trim(),
}
}
export function kanaeleEingerichtet(k: Kanaele): boolean {
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
}
export class BenachrichtigungsDienst {
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly holen: typeof fetch = fetch,
) {}
eingeschaltet(): boolean {
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
}
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
if (!erzwingen && !this.eingeschaltet()) return []
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
const fehler: string[] = []
const anfragen: Array<{ name: string; url: string; body: string }> = []
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
}
for (const a of anfragen) {
try {
const antwort = await this.holen(a.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: a.body,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
} catch (e) {
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
}
}
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
return fehler
}
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
async testen(): Promise<void> {
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
if (!kanaeleEingerichtet(k)) {
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
return
}
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
}
}
@@ -0,0 +1,138 @@
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
// also wie bisher).
import { join } from 'node:path'
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
import { einstellung, type KernKontext } from '../kontext'
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'ablage',
'arbeitsordner',
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'makemkvAppKey',
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
// 5.5.0
'automatik',
'automatikSekunden',
'benachrichtigungen',
'discordWebhook',
'telegramToken',
'telegramChatId',
'design',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
* frühere Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
* verwirrt nur. */
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
export class EinstellungsDienst {
constructor(private readonly kontext: KernKontext) {}
lesen(schluessel: string, vorgabe = ''): string {
return einstellung(this.kontext, schluessel, vorgabe)
}
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
ablageOrdner(): string {
const eingestellt = this.lesen('ablage')
if (eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
arbeitsordnerEigen(): string {
return this.lesen('arbeitsordner').trim()
}
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
arbeitsordner(): string {
const eigen = this.arbeitsordnerEigen()
return eigen.length > 0 ? eigen : this.ablageOrdner()
}
/** Wo die Roh-Ordner liegen. */
rohWurzel(): string {
return join(this.arbeitsordner(), 'roh')
}
/** Was Pipeline und Ablage brauchen. */
fuerPipeline(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
automatikAn(): boolean {
return this.lesen('automatik', 'an') !== 'aus'
}
automatikSekunden(): number {
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
}
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
senden(): void {
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
}
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
setzen(schluessel: string, wert: string): boolean {
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
return false
}
if (this.kontext.db === null) {
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
return false
}
this.kontext.db.einstellungSchreiben(schluessel, wert)
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
this.senden()
return true
}
}
function istGeheim(schluessel: string): boolean {
return /key|token|webhook|passwort/i.test(schluessel)
}
+419
View File
@@ -0,0 +1,419 @@
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
// Hintergrund der Erst-Einrichtung.
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { einstellung, type KernKontext } from '../kontext'
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
ripLaeuft(id: string): boolean
geraetInfoVon(id: string): GeraeteInfo | null
makemkvcon(): string
}
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
export interface SerienMerker {
titel: string
staffel: number
naechsteFolge: number
wann: string
}
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
if (text === null || text.length === 0) return {}
try {
const roh: unknown = JSON.parse(text)
if (typeof roh !== 'object' || roh === null) return {}
const ergebnis: Record<string, SerienMerker> = {}
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
if (typeof wert !== 'object' || wert === null) continue
const m = wert as Record<string, unknown>
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
ergebnis[id] = {
titel: typeof m.titel === 'string' ? m.titel : '',
staffel: m.staffel,
naechsteFolge: m.naechsteFolge,
wann: typeof m.wann === 'string' ? m.wann : '',
}
}
return ergebnis
} catch {
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
return {}
}
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
export class ErkennungDienst {
private zuordnungen = new Map<string, Zuordnung>()
private fingerprints = new Map<string, string>()
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl)
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
private merkmale = new Map<string, DiscTitel>()
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
readonly letzteTitel = new Map<string, TitelStand>()
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
private titelAbbrueche = new Map<string, AbortController>()
private titelLaufZaehler = 0
private titelLaufNummern = new Map<string, number>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: ErkennungAbhaengigkeiten,
) {}
private tmdb(): TmdbClient {
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
}
private omdb(): OmdbClient {
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
}
zuordnung(id: string): Zuordnung | null {
return this.zuordnungen.get(id) ?? null
}
fingerprint(id: string): string | null {
return this.fingerprints.get(id) ?? null
}
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
const m = this.merkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
}
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
private alleMerker(): Record<string, SerienMerker> {
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
}
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
if (tmdbId === null) return null
return this.alleMerker()[String(tmdbId)] ?? null
}
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
if (this.kontext.db === null) return
const alle = this.alleMerker()
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
}
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) vorher stand dieselbe Struktur dreimal im Code. */
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = this.merkmale.get(id)
const meta = zuordnung?.meta
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt:
zuordnung === null ? null : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
}
}
schonGerippt(id: string): string | null {
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
}
/** § 5 Schritt 3: Disc rein was ist das? Liefert die Zuordnung (für die
* Automatik) null, wenn die Erkennung scheiterte. */
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
try {
const gefunden = discTitel(geraet.pfad)
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
this.merkmale.set(geraet.id, gefunden)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, this.tmdb(), this.omdb(), {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
this.zuordnungen.set(geraet.id, zuordnung)
this.kontext.protokoll.schreiben(
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
return zuordnung
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
return null
}
}
discVergessen(id: string): void {
this.zuordnungen.delete(id)
this.fingerprints.delete(id)
this.merkmale.delete(id)
this.letzteTitel.delete(id)
this.titelAbbrueche.get(id)?.abort()
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
async metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) für
* Ändern " und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
const tmdb = this.tmdb()
let details = null
try {
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
} catch (fehler) {
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
}
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
return {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const zuordnung = await this.zuordnungAus(vorschlag)
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
}
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
async titelLesen(id: string): Promise<TitelStand> {
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
const senden = (stand: TitelStand): TitelStand => {
if (!stand.laeuft) this.letzteTitel.set(id, stand)
this.kontext.anFenster({ art: 'disc-titel', stand })
return stand
}
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
const geraet = this.abh.geraetInfoVon(id)
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
const programm = this.abh.makemkvcon()
if (programm.length === 0) {
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
}
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
this.titelAbbrueche.get(id)?.abort()
const abbruch = new AbortController()
this.titelAbbrueche.set(id, abbruch)
const meineNummer = ++this.titelLaufZaehler
this.titelLaufNummern.set(id, meineNummer)
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
senden({ ...leer, laeuft: true })
const start = Date.now()
try {
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
const merkmale = this.merkmale.get(id)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0,
})
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
this.kontext.protokoll.schreiben(
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
)
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
laeuft: false,
titel,
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await this.nachpruefen(id, titel, info.titel)
return stand
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
const grund = String(fehler).includes('keine Antwort nach')
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
: `Titel lesen: ${String(fehler).slice(0, 200)}`
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
return senden({ ...leer, fehler: grund })
} finally {
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
if (nochAktuell()) {
this.titelAbbrueche.delete(id)
this.titelLaufNummern.delete(id)
}
}
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus wahrscheinlich" wird
* bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
const bisher = this.zuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = vorschlag.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = this.merkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.omdb(), {
istSerie: true,
alternativen: merkmale.alternativen,
})
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
this.zuordnungen.set(id, neu)
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')}${Math.round(neu.confidence * 100)} %`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
}
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
async posterLaden(): Promise<void> {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
return
}
const pfade = await tmdb.beliebtePoster(40)
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
}
}
+202
View File
@@ -0,0 +1,202 @@
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
// Klick-Beweises.
//
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { LaufwerkApi } from '../laufwerk/codes'
import {
audioSpurLesen,
audioToc,
auswerfenMitGrund,
geraeteInfo,
kennung,
listOptischeLaufwerke,
} from '../laufwerk/disc'
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
import { DiscWache } from '../laufwerk/wache'
import type { Spur } from '../rip/audiocd'
const GESUNDHEIT_STUNDEN = 24
export interface LaufwerkHorcher {
discEingelegt(geraet: GeraeteInfo): void
discEntnommen(geraet: GeraeteInfo): void
}
export class LaufwerkDienst {
private api: LaufwerkApi | null = null
private wache: DiscWache | null = null
fehler: string | null = null
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
* Buchstabe, den es nicht gibt (Q) die Disc-Merkmale finden dann
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
readonly fake: string
readonly bereit: Promise<void>
constructor(
private readonly kontext: KernKontext,
private readonly horcher: LaufwerkHorcher,
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
private readonly ripLaeuft: (id: string) => boolean,
umgebung: NodeJS.ProcessEnv = process.env,
) {
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
this.bereit = this.starten()
}
private fakeGeraet(): GeraeteInfo {
return {
id: this.fake,
name: `Laufwerk ${this.fake}:`,
typ: 'bluray',
pfad: `\\\\.\\${this.fake}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
this.horcher.discEingelegt(ereignis.geraet)
} else if (ereignis.art === 'disc-entnommen') {
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
this.horcher.discEntnommen(ereignis.geraet)
} else if (ereignis.art === 'laufwerk-weg') {
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
}
}
}
private async starten(): Promise<void> {
const wacheFehler = (fehler: unknown): void => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
}
try {
if (this.fake.length > 0) {
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
this.wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('../laufwerk/win32')
this.api = win32Api()
this.wache = new DiscWache(
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
(m) => this.wacheMeldung(m),
wacheFehler,
)
this.wache.starten()
} catch (fehler) {
this.fehler = String(fehler)
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
console.error(`[kern] Laufwerke: ${this.fehler}`)
}
}
aktuellerStand(): GeraeteInfo[] {
return this.wache?.aktuellerStand() ?? []
}
/** Der Geräte-Name zu einer Kennung — oder null. */
geraetVon(id: string): string | null {
if (this.api === null) return null
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
}
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
geraetInfoVon(id: string): GeraeteInfo | null {
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
const geraet = this.geraetVon(id)
if (geraet === null || this.api === null) return null
return geraeteInfo(this.api, geraet)
}
audioToc(id: string): Spur[] {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(this.api, geraet)
}
audioSpurLesen(
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
}
async auswerfen(id: string): Promise<void> {
await this.bereit
if (this.ripLaeuft(id)) {
this.kontext.anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (this.api === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
return
}
const geraet = this.geraetVon(id)
if (geraet === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
this.wache?.runde()
}
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
async pruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
this.kontext.anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil}${bericht.text}`)
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
}
}
@@ -0,0 +1,87 @@
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
// Laufwerk gibt es trotzdem.
import { existsSync, statfsSync } from 'node:fs'
import { dirname } from 'node:path'
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
const echtMessen: Messen = (pfad) => {
const s = statfsSync(pfad)
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
}
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
let p = pfad
for (let i = 0; i < 40; i++) {
if (existiert(p)) return p
const eltern = dirname(p)
if (eltern === p) return p
p = eltern
}
return p
}
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
try {
const s = messen(vorhandenerPfad(pfad, existiert))
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
} catch (fehler) {
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
// Fenster zeigt den Grund, nie eine erfundene Null.
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
}
}
export { platzUrteil } from '../../gemeinsam/platz'
export class SpeicherDienst {
private zeiger: NodeJS.Timeout | null = null
private letzter: SpeicherStand | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly messen: Messen = echtMessen,
) {}
stand(): SpeicherStand {
const arbeit = this.einstellungen.arbeitsordner()
const ablage = this.einstellungen.ablageOrdner()
const s: SpeicherStand = {
arbeit: platzMessen(arbeit, this.messen),
ablage: platzMessen(ablage, this.messen),
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
}
this.letzter = s
return s
}
letzterStand(): SpeicherStand | null {
return this.letzter
}
senden(): void {
try {
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
}
}
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
taktStarten(ms = 60_000): void {
if (this.zeiger !== null) return
this.zeiger = setInterval(() => this.senden(), ms)
this.zeiger.unref?.()
}
}
+420
View File
@@ -0,0 +1,420 @@
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
// Benachrichtigung, wenn eine Kompression zu Ende ist.
import { existsSync, rmSync } from 'node:fs'
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
import type { VorgangBeginn } from '../ablauf/pipeline'
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from '../ablage/rohaufraeumen'
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import type { KernKontext } from '../kontext'
import { TmdbClient } from '../metadaten/tmdb'
import type { Vorgang } from '../speicher/db'
import type { BenachrichtigungsDienst } from './benachrichtigung'
import type { EinstellungsDienst } from './einstellungen'
import type { ErkennungDienst } from './erkennung'
import type { WerkzeugDienst } from './werkzeuge'
/** Statistik aus den Vorgängen (pur). */
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
let fertige = 0
let rohBytes = 0
let fertigBytes = 0
let dauerS = 0
for (const v of liste) {
rohBytes += v.rohBytes
if (v.phase !== 'fertig') continue
fertige += 1
fertigBytes += v.fertigBytes
if (v.beendet !== null) {
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
if (Number.isFinite(d) && d > 0) dauerS += d
}
}
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
}
export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
export class VorgangDienst {
readonly warteschlange: KompressionsWarteschlange
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
private letztePhase = new Map<string, string>()
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly werkzeuge: WerkzeugDienst,
private readonly erkennung: ErkennungDienst,
private readonly benachrichtigung: BenachrichtigungsDienst,
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
private readonly speicherSenden: () => void,
) {
this.warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
(stand) => this.standGeaendert(stand.laeuft),
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
)
}
private get db() {
return this.kontext.db
}
ablageUmgebung(): AblageUmgebung {
return {
handbrake: () => this.werkzeuge.handbrake(),
ablage: () => this.einstellungen.ablageOrdner(),
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
einstellungen: () => this.einstellungen.fuerPipeline(),
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey'))
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag),
}
}
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
try {
this.db?.bibliothekEintragen(eintrag)
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
}
}
private standGeaendert(laeuft: KompressionStand | null): void {
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
this.senden()
}
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
this.letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = this.db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status}${text}`)
this.kontext.anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
const letzte = Math.max(...ergebnis.folgenVergeben)
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
}
if (ergebnis.status !== 'abgebrochen') {
void this.benachrichtigung.senden(
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
`${titel}: ${ergebnis.text.slice(0, 300)}`,
)
}
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
else this.senden()
}
// ── Pipeline-Haken ──────────────────────────────────────────────────────
vorgangBeginnt(v: VorgangBeginn): void {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
this.db?.vorgangAnlegen(vorgang)
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk}${v.rohOrdner}`)
this.senden()
}
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = this.warteschlange.einreihen(auftrag, titel)
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
this.senden()
return platz
}
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
this.senden()
}
// ── Stand fürs Fenster ──────────────────────────────────────────────────
stand(): VorgaengeStand {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const liste = this.db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return {
vorgaenge,
rohGesamtBytes,
fremde,
regel: regelText(regel),
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
statistik: statistikAus(liste),
}
}
senden(): void {
try {
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
}
this.speicherSenden()
}
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
pause(an: boolean): void {
this.warteschlange.pausieren(an)
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
this.senden()
}
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
rohAufraeumenLauf(): void {
if (this.db === null) return
try {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const kandidaten = this.db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
} catch (fehler) {
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
}
this.senden()
}
rohLoeschen(vorgangId: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
return
}
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
// direkt unter irgendeinem roh\ ist.
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
this.kontext.fehler(`${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
return
}
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
this.kontext.fehler(`${ordner}" gehört zu einem Vorgang — dort löschen.`)
return
}
try {
rmSync(ordner, { recursive: true, force: true })
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`${ordner}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
neuKomprimieren(vorgangId: string, preset: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" ist schon in der Warteschlange.`)
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
this.kontext.fehler(`${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`)
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
this.kontext.fehler(`${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
return
}
if (vorhandene.length < auftrag.dateien.length) {
this.kontext.hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
const neu: KompressionsAuftrag = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
zielOrdner: alteFassung ? v.zielOrdner : '',
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
}
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = this.warteschlange.einreihen(neu, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
this.senden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
wiederaufnehmen(): void {
if (this.db === null) return
const jetzt = new Date().toISOString()
for (const v of this.db.vorgangListe()) {
if (v.phase === 'rippt') {
this.db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
this.db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
import type { KernNachricht } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { finden } from '../werkzeuge/katalog'
import type { EinstellungsDienst } from './einstellungen'
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
export class WerkzeugDienst {
/** Die gemessene Auskunft bleibt liegen die Pipeline braucht sie für die
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
schluesselStand: SchluesselStand | null = null
private beschaffungLaeuft = false
private installerAntwort: ((fehler: string | null) => void) | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
) {}
makemkvcon(): string {
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
}
handbrake(): string {
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
}
flac(): string {
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
}
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
async senden(): Promise<void> {
const makemkvcon = this.makemkvcon()
const handbrake = this.handbrake()
const flac = this.flac()
const { encoderMessen } = await import('../komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
this.kontext.anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
this.kontext.protokoll.schreiben(
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
)
}
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
async schluesselLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { schluesselKette } = await import('../werkzeuge/schluessel')
this.schluesselStand = await schluesselKette(
this.makemkvcon(),
this.einstellungen.lesen('makemkvAppKey'),
new Date().toISOString(),
)
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
}
}
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('../werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
} else if (stand.stand === 'gesetzt') {
this.kontext.hinweis(stand.text)
} else {
this.kontext.protokoll.schreiben(stand.text)
}
} catch (fehler) {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
}
}
/** Antwort des Haupts auf 'installer-starten'. */
installerErgebnis(fehler: string | null): void {
this.installerAntwort?.(fehler ?? '')
this.installerAntwort = null
}
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
// Prozess-Leine und überlebt ein geschlossenes Rippy).
async makemkvUpdateLauf(): Promise<void> {
if (this.beschaffungLaeuft) {
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return
}
this.beschaffungLaeuft = true
try {
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
'../werkzeuge/beschaffen'
)
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
this.beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
this.beschaffungLaeuft = false
return
}
const installiert = this.schluesselStand?.version ?? ''
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
this.beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
this.beschaffungLaeuft = false
return
}
// Merken, woran eine gelungene Installation zu erkennen ist: Die
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
// erstmals eine).
const alteExe = this.makemkvcon()
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const startFehler = await new Promise<string | null>((fertig) => {
this.installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
this.installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
this.beschaffungLaeuft = false
return
}
this.beschaffungMelden(
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
null,
true,
)
// Warten, bis die Installation durch ist: Die Versions-Ressource der
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
let runden = 0
const zeiger = setInterval(() => {
runden += 1
const exe = this.makemkvcon()
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
void this.schluesselLauf().then(() => this.senden())
} else if (runden >= 80) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
null,
false,
)
}
}, 15_000)
zeiger.unref()
} catch (fehler) {
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
this.beschaffungLaeuft = false
}
}
}
+283 -542
View File
@@ -1,55 +1,47 @@
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1). // Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1: // Hier passiert die ganze Arbeit; das Fenster zeigt nur an.
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5), //
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort. // Seit 5.5.0 ist diese Datei nur noch der EINSTIEG (Separation of Concerns,
// Commander-Wunsch): Datenbank öffnen, Protokoll anlegen, die Dienste
// verdrahten und Nachrichten verteilen. Die Arbeit liegt in kern/dienste/:
// einstellungen — die EINE Quelle (§ 6.7), Ablage/Arbeitsordner
// laufwerke — Wache, Auswurf, Gesundheit, nachgebautes Laufwerk
// erkennung — Disc-Merkmale, TMDb/OMDb, Titel-Lauf, Serien-Merker
// vorgaenge — Datensätze, Warteschlange, Roh-Aufräumen, Statistik
// werkzeuge — makemkvcon/HandBrake/flac, Schlüssel, MakeMKV-Update
// speicher — freier Platz
// benachrichtigung— Discord/Telegram
// automatik — Zero-Click mit Countdown
// und die Kette selbst in kern/ablauf/ (Pipeline → Warteschlange → Ablage).
// //
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess« // parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen // und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js. // Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron' import type { MessagePortMain } from 'electron'
import { join } from 'node:path' import { dirname, join } from 'node:path'
import { import { istFensterNachricht, istHauptNachricht, type DbStatus, type KernNachricht, type RipAuftragOptionen, type RipAuftragTitel } from '../gemeinsam/nachrichten'
istFensterNachricht, import { Protokoll } from '../gemeinsam/protokoll'
istHauptNachricht,
type DbStatus,
type KernNachricht,
type MetaVorschlag,
} from '../gemeinsam/nachrichten'
import { Pipeline } from './ablauf/pipeline' import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes' import { AUFBEWAHRUNG_SCHLUESSEL } from './ablage/rohaufraeumen'
import { import { AutomatikDienst } from './dienste/automatik'
audioSpurLesen, import { BenachrichtigungsDienst } from './dienste/benachrichtigung'
audioToc, import { EinstellungsDienst } from './dienste/einstellungen'
auswerfenMitGrund, import { ErkennungDienst } from './dienste/erkennung'
geraeteInfo, import { LaufwerkDienst } from './dienste/laufwerke'
kennung, import { SpeicherDienst, platzUrteil } from './dienste/speicher'
listOptischeLaufwerke, import { VorgangDienst } from './dienste/vorgaenge'
} from './laufwerk/disc' import { WerkzeugDienst } from './dienste/werkzeuge'
import { DiscWache } from './laufwerk/wache' import { datenbankPfadAusArgv, type KernKontext } from './kontext'
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db' import { Datenbank } from './speicher/db'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
// nicht — er ist ein reiner Node-Prozess).
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
// ── Datenbank und Protokoll ─────────────────────────────────────────────
let db: Datenbank | null = null let db: Datenbank | null = null
let dbStatus: DbStatus let dbStatus: DbStatus
const pfad = datenbankPfadAusArgv(process.argv) const pfad = datenbankPfadAusArgv(process.argv)
const protokoll = new Protokoll(join(pfad !== null ? dirname(pfad) : join(process.env['TEMP'] ?? '.', 'rippy'), 'logs'), 'kern')
protokoll.aufraeumen()
protokoll.schreiben(`Kern startet (PID ${process.pid}, Node ${process.versions.node}) — Datenbank ${pfad ?? '?'}`)
if (pfad === null) { if (pfad === null) {
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' } dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
} else { } else {
@@ -69,486 +61,257 @@ if (pfad === null) {
dbStatus = { ok: false, fehler: String(fehler) } dbStatus = { ok: false, fehler: String(fehler) }
} }
} }
protokoll.schreiben(dbStatus.ok ? `Datenbank geöffnet: ${dbStatus.pfad} (Schema ${db?.schemaVersion() ?? '?'})` : `DATENBANK-FEHLER: ${dbStatus.fehler}`)
const smokeKern = process.argv.includes('--smoke-kern')
let fensterPort: MessagePortMain | null = null let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void { const kontext: KernKontext = {
fensterPort?.postMessage(nachricht) db,
} dbStatus,
smoke: smokeKern,
function anHauptUndFenster(nachricht: KernNachricht): void { protokoll,
process.parentPort.postMessage(nachricht) anFenster: (nachricht) => fensterPort?.postMessage(nachricht),
anFenster(nachricht) anHauptUndFenster: (nachricht) => {
} process.parentPort.postMessage(nachricht)
fensterPort?.postMessage(nachricht)
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
let laufwerkApi: LaufwerkApi | null = null
let laufwerkFehler: string | null = null
let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
const discFingerprints = new Map<string, string>()
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
},
})
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
}
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
async function metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) {
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const zuordnung: Zuordnung = {
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta: {
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
backdropPfad: details?.backdrop_path ?? '',
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
},
})
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('./laufwerk/win32')
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
},
})
}
}
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
)
wache.starten()
} catch (fehler) {
laufwerkFehler = String(fehler)
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePreset',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
function einstellungenLaden(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
function geraetVon(id: string): string | null {
if (laufwerkApi === null) return null
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
}
const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
audioToc: (id) => {
const geraet = geraetVon(id)
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(laufwerkApi, geraet)
}, },
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => { hinweis: (text) => {
const geraet = geraetVon(id) protokoll.schreiben(text)
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) fensterPort?.postMessage({ art: 'kern-hinweis', text })
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
}, },
geraet: (id) => { fehler: (text) => {
if (laufwerkApi === null) return null protokoll.schreiben(`FEHLER: ${text}`)
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) console.error(`[kern] ${text}`)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet) fensterPort?.postMessage({ art: 'kern-fehler', text })
}, },
ablage: ablageOrdner, }
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets), // ── Dienste verdrahten ──────────────────────────────────────────────────
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }), const einstellungen = new EinstellungsDienst(kontext)
zuordnung: (id) => discZuordnungen.get(id) ?? null, const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
fingerprint: (id) => discFingerprints.get(id) ?? null, const speicher = new SpeicherDienst(kontext, einstellungen)
bibliothekEintragen: (eintrag) => { const benachrichtigung = new BenachrichtigungsDienst(kontext, einstellungen)
try {
db?.bibliothekEintragen(eintrag) // Die Pipeline und die Laufwerke brauchen einander: Auswurf nur, wenn kein
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) // Rip läuft; Rip nur mit Gerätestand. Deshalb erst die Funktionen, dann
} catch (fehler) { // beide Objekte.
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber let ripVerwaltung: Pipeline
// gesagt. const laufwerke = new LaufwerkDienst(
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` }) kontext,
{
discEingelegt: (geraet) => {
void erkennung.discErmitteln(geraet).then((zuordnung) => {
automatik.zuordnungMerken(geraet.id, zuordnung)
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
})
},
discEntnommen: (geraet) => {
erkennung.discVergessen(geraet.id)
automatik.discEntnommen(geraet.id)
ripVerwaltung.discEntnommen(geraet.id)
},
},
(id) => ripVerwaltung.laeuft(id),
)
const erkennung = new ErkennungDienst(kontext, {
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
makemkvcon: () => werkzeuge.makemkvcon(),
})
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
ripVerwaltung = new Pipeline({
makemkvcon: () => werkzeuge.makemkvcon(),
handbrake: () => werkzeuge.handbrake(),
flac: () => werkzeuge.flac(),
audioToc: (id) => laufwerke.audioToc(id),
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt),
geraet: (id) => laufwerke.geraetInfoVon(id),
ablage: () => einstellungen.ablageOrdner(),
rohWurzel: () => einstellungen.rohWurzel(),
einstellungen: () => einstellungen.fuerPipeline(),
melden: (status) => {
kontext.anHauptUndFenster({ art: 'rip-status', status })
if (status.phase === 'fertig' || status.phase === 'fehler' || status.phase === 'abgebrochen') {
protokoll.schreiben(`Rip ${status.id} ${status.phase}: ${status.text.slice(0, 300)}`)
} }
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerke.pruefen(status.id)
if (status.phase === 'fehler') void benachrichtigung.senden('Rippy: Rip fehlgeschlagen', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`)
if (status.phase === 'fertig') void benachrichtigung.senden('Rippy: Rip fertig', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`)
},
zuordnung: (id) => erkennung.zuordnung(id),
discAngaben: (id) => erkennung.discAngaben(id),
fingerprint: (id) => erkennung.fingerprint(id),
zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag),
bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag),
vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v),
vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel),
vorgangEndet: (id, phase, text) => vorgaenge.vorgangEndet(id, phase, text),
nachRipAuswerfen: (id) => {
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
if (einstellungen.lesen('autoAuswurf') === 'false') return
void laufwerke.auswerfen(id)
}, },
}) })
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den const automatik = new AutomatikDienst(kontext, {
// Kern; erlaubt sind genau diese Schlüssel ─────────────────────────── an: () => einstellungen.automatikAn(),
const ERLAUBTE_EINSTELLUNGEN = new Set([ sekunden: () => einstellungen.automatikSekunden(),
'ablage', ripLaeuft: (id) => ripVerwaltung.laeuft(id),
'transcodeEnabled', titelLesen: async (id) => {
'transcodePreset', const stand = await erkennung.titelLesen(id)
'transcodePresetDvd', automatik.titelMerken(id, stand)
'transcodePresetBluray', return stand
'transcodePresetUhd', },
'audioSprachen', ripStarten: (id, auftrag, optionen) => ripMitPlatz(id, auftrag, optionen),
'untertitelSprachen', discAngaben: (id) => erkennung.discAngaben(id),
'mediaServer', serienMerker: (zuordnung) => {
'mediaServerUrl', const m = zuordnung.meta.typ === 'tv' ? erkennung.serienMerkerFuer(zuordnung.meta.id) : null
'mediaServerApiKey', return m === null ? null : { staffel: m.staffel, naechsteFolge: m.naechsteFolge }
'tmdbApiKey', },
'omdbApiKey', benachrichtigen: (titel, text) => void benachrichtigung.senden(titel, text),
'makemkvAppKey', })
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
])
function einstellungenSenden(): void { /** Der Rip-Start mit Platz-Prüfung (5.5.0): Der Dialog zeigt das Urteil
const werte: Record<string, string> = { ablage: ablageOrdner() } * vorher, der Kern urteilt beim Start auch für die Automatik. Zu wenig
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) { * für den verlustfreien Rip: kein Start, der Grund steht in den
const wert = db?.einstellungLesen(schluessel) * Ereignissen. Knapp: Start mit Hinweis. Nicht messbar: Start ohne Urteil
if (wert !== null && wert !== undefined) werte[schluessel] = wert * (die Messung ist Komfort, nicht Pflicht). */
function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void {
const rohBytes = auftrag.reduce((s, t) => s + (t.groesseBytes ?? 0), 0)
const arbeit = speicher.stand().arbeit
if (arbeit.ok && rohBytes > 0) {
const urteil = platzUrteil(arbeit.freiBytes, rohBytes, einstellungen.lesen('transcodeEnabled') !== 'false')
if (urteil.hart) {
kontext.fehler(`Rip auf ${id} nicht gestartet — ${urteil.text}`)
return
}
if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`)
} }
// AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine // Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient.
// Einstellungs-Quelle bleibt trotzdem der Kern. automatik.leeren(id)
anHauptUndFenster({ art: 'einstellungen', werte }) void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen))
} }
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ────────────── // ── Nachrichten vom Fenster ─────────────────────────────────────────────
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null function fensterNachricht(frage: unknown): void {
if (!istFensterNachricht(frage)) {
async function schluesselLauf(): Promise<void> { // R4: Eine verworfene Nachricht verschwindet NICHT still. Genau das
if (process.platform !== 'win32') return // kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im Prüfer,
try { // der Kern warf sie weg, und im Fenster stand ewig „Rippy liest die
const { schluesselKette } = await import('./werkzeuge/schluessel') // Titel …", ohne dass irgendwo etwas stand.
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') const art = typeof frage === 'object' && frage !== null ? String((frage as Record<string, unknown>).art) : typeof frage
schluesselStand = await schluesselKette( kontext.fehler(`Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.`)
makemkvcon,
db?.einstellungLesen('makemkvAppKey') ?? '',
new Date().toISOString(),
)
anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand })
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
console.error(`[kern] Schlüsselkette: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` })
}
}
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
// Prozess-Leine und überlebt ein geschlossenes Rippy).
let beschaffungLaeuft = false
let installerAntwort: ((fehler: string | null) => void) | null = null
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
}
async function makemkvUpdateLauf(): Promise<void> {
if (beschaffungLaeuft) {
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return return
} }
beschaffungLaeuft = true switch (frage.art) {
try { case 'ping':
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import( kontext.anFenster({ art: 'pong', zeit: frage.zeit })
'./werkzeuge/beschaffen'
)
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
beschaffungLaeuft = false
return return
} case 'auswerfen':
const installiert = schluesselStand?.version ?? '' void laufwerke.auswerfen(frage.id)
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
beschaffungLaeuft = false
return return
} case 'titel-lesen':
// Merken, woran eine gelungene Installation zu erkennen ist: Die // Der Nutzer will selbst wählen — ein laufender Countdown endet.
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt automatik.stopp(frage.id, '')
// erstmals eine). void erkennung.titelLesen(frage.id)
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
beschaffungMelden(text, anteil, true),
)
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const startFehler = await new Promise<string | null>((fertig) => {
installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
beschaffungLaeuft = false
return return
} case 'rip-start':
beschaffungMelden( automatik.stopp(frage.id, '')
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` + ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.', return
null, case 'rip-abbruch':
true, ripVerwaltung.abbrechen(frage.id)
) return
// Warten, bis die Installation durch ist: Die Versions-Ressource der case 'rip-ueberspringen':
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten. ripVerwaltung.ueberspringen(frage.id)
let runden = 0 return
const zeiger = setInterval(() => { case 'rettung-start':
runden += 1 automatik.leeren(frage.id)
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id))
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : '' return
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) { case 'metadaten-suchen':
clearInterval(zeiger) void erkennung.metadatenSuchen(frage.id, frage.text)
beschaffungLaeuft = false return
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false) case 'zuordnung-setzen':
void schluesselLauf().then(() => werkzeugeSenden()) automatik.stopp(frage.id, '')
} else if (runden >= 80) { void erkennung.zuordnungSetzen(frage.id, frage.vorschlag)
clearInterval(zeiger) return
beschaffungLaeuft = false case 'einstellung-setzen':
beschaffungMelden( if (einstellungen.setzen(frage.schluessel, frage.wert)) {
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".', // Neue Aufbewahrungs-Regel, neuer Arbeitsordner: sofort sichtbar.
null, if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaenge.senden()
false, if (frage.schluessel === 'ablage' || frage.schluessel === 'arbeitsordner') {
) vorgaenge.senden()
speicher.senden()
}
} }
}, 15_000) return
zeiger.unref() case 'einstellungen-laden':
} catch (fehler) { einstellungen.senden()
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true) return
beschaffungLaeuft = false case 'werkzeuge-laden':
void werkzeuge.senden()
return
case 'makemkv-update':
void werkzeuge.makemkvUpdateLauf()
return
case 'schluessel-pruefen':
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
return
case 'vorgaenge-laden':
vorgaenge.senden()
return
case 'vorgang-roh-loeschen':
vorgaenge.rohLoeschen(frage.vorgangId)
return
case 'vorgang-neu-komprimieren':
vorgaenge.neuKomprimieren(frage.vorgangId, frage.preset ?? '')
return
case 'kompression-abbrechen':
if (!vorgaenge.warteschlange.abbrechen(frage.vorgangId)) {
kontext.fehler(`Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.`)
}
return
case 'kompression-pause':
vorgaenge.pause(frage.an)
return
case 'roh-aufraeumen':
vorgaenge.rohAufraeumenLauf()
return
case 'fremden-ordner-loeschen':
vorgaenge.fremdenOrdnerLoeschen(frage.ordner)
return
case 'laufwerk-pruefen':
void laufwerke.pruefen(frage.id)
return
case 'automatik-stopp':
automatik.stopp(frage.id, 'Automatik gestoppt — du entscheidest.')
return
case 'automatik-jetzt':
automatik.jetzt(frage.id)
return
case 'benachrichtigung-testen':
void benachrichtigung.testen()
return
case 'poster-laden':
void erkennung.posterLaden()
return
case 'speicher-laden':
speicher.senden()
return
} }
} }
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
async function werkzeugeSenden(): Promise<void> {
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
const { encoderMessen } = await import('./komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
}
async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit
if (ripVerwaltung.laeuft(id)) {
anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (laufwerkApi === null) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
return
}
const geraete = listOptischeLaufwerke(laufwerkApi)
const geraet = geraete.find((g) => kennung(g) === id)
if (geraet === undefined) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
wache?.runde()
}
process.parentPort.on('message', (ereignis) => { process.parentPort.on('message', (ereignis) => {
const nachricht: unknown = ereignis.data const nachricht: unknown = ereignis.data
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') { if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
installerAntwort?.(nachricht.fehler ?? '') werkzeuge.installerErgebnis(nachricht.fehler)
installerAntwort = null
return return
} }
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') { if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
@@ -557,54 +320,19 @@ process.parentPort.on('message', (ereignis) => {
fensterPort?.close() fensterPort?.close()
fensterPort = ereignis.ports[0] ?? null fensterPort = ereignis.ports[0] ?? null
if (fensterPort === null) return if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => { fensterPort.on('message', (portEreignis) => fensterNachricht(portEreignis.data))
const frage: unknown = portEreignis.data
if (!istFensterNachricht(frage)) return
if (frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') {
void auswerfen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'metadaten-suchen') {
void metadatenSuchen(frage.id, frage.text)
} else if (frage.art === 'zuordnung-setzen') {
void zuordnungSetzen(frage.id, frage.vorschlag)
} else if (frage.art === 'einstellung-setzen') {
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
} else if (db === null) {
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
} else if (frage.art === 'werkzeuge-laden') {
void werkzeugeSenden()
} else if (frage.art === 'bibliothek-laden') {
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} else if (frage.art === 'makemkv-update') {
void makemkvUpdateLauf()
} else if (frage.art === 'schluessel-pruefen') {
void schluesselLauf().then(() => werkzeugeSenden())
}
})
fensterPort.start() fensterPort.start()
// Damit das Fenster nach dem Verbinden sofort den Stand kennt: // Damit das Fenster nach dem Verbinden sofort den Stand kennt:
anFenster(bereitNachricht()) kontext.anFenster(bereitNachricht())
void laufwerkeBereit.then(() => { void laufwerke.bereit.then(() => {
if (laufwerkFehler !== null) { if (laufwerke.fehler !== null) kontext.fehler(`Laufwerke: ${laufwerke.fehler}`)
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` }) kontext.anFenster({ art: 'laufwerke', liste: laufwerke.aktuellerStand(), ereignisse: [] })
} if (werkzeuge.schluesselStand !== null) {
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] }) kontext.anFenster({ art: 'schluessel-status', stand: werkzeuge.schluesselStand })
if (schluesselStand !== null) {
anFenster({ art: 'schluessel-status', stand: schluesselStand })
} }
}) })
vorgaenge.senden()
speicher.senden()
} }
}) })
@@ -619,12 +347,25 @@ function bereitNachricht(): KernNachricht {
process.parentPort.postMessage(bereitNachricht()) process.parentPort.postMessage(bereitNachricht())
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster. // Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
einstellungenSenden() einstellungen.senden()
// Was nach dem letzten Beenden noch offen war (5.4.0).
vorgaenge.wiederaufnehmen()
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an — // Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein. // der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf() void werkzeuge.schluesselLauf()
void werkzeuge.spurauswahlLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der // Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster. // Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden() void werkzeuge.senden()
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void werkzeuge.schluesselLauf(), 24 * 60 * 60 * 1000).unref()
}
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
// nach jeder gelungenen Kompression (siehe Warteschlange). Speicher (5.5.0):
// jede Minute.
if (!smokeKern) {
setTimeout(() => vorgaenge.rohAufraeumenLauf(), 60_000).unref()
setInterval(() => vorgaenge.rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
speicher.taktStarten()
} }
@@ -7,7 +7,7 @@
// Einrückung — Namen stehen mit genau vier Leerzeichen). // Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst // - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT). // läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { spawn } from 'node:child_process' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser' import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/ const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
@@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] {
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> { function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => { return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = [] const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s)) kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
@@ -15,7 +15,7 @@
// im Fehlerfall die einzige Auskunft. // im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung — // - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus. // sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { spawn } from 'node:child_process' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux. // win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path' import { win32 as pfad } from 'node:path'
@@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen {
preset: string preset: string
audioSprachen?: readonly string[] audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[] untertitelSprachen?: readonly string[]
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
} }
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
// ffprobe geprüft — Regel D):
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] { export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset] const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()] const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
@@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0) const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(',')) if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles') args.push('--all-subtitles')
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
return args return args
} }
@@ -54,6 +72,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode // Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/ const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
// grep -a, 02.09.2026):
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
export function etaAusZeile(zeile: string): number {
if (!HB_ENCODE.test(zeile.trim())) return -1
const t = HB_ETA.exec(zeile)
if (t === null) return -1
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
}
/** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss /** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss
* von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M) * von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */ * werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
@@ -104,11 +136,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */ /** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung { export class HbAuswertung {
prozent = -1 prozent = -1
restS = -1
falschesPreset = '' falschesPreset = ''
falscherSchalter = '' falscherSchalter = ''
letzteZeilen: string[] = [] letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } { verarbeite(zeile: string): { prozent?: number; restS?: number } {
if (zeile.trim().length > 0) { if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile) this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift() if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
@@ -116,9 +149,13 @@ export class HbAuswertung {
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile) if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile) if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile) const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) { if (p >= 0) {
this.prozent = p const rest = etaAusZeile(zeile)
return { prozent: p } if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
this.prozent = p
this.restS = rest
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
}
} }
return {} return {}
} }
@@ -224,9 +261,11 @@ export interface HbLaufOptionen {
preset: string preset: string
audioSprachen?: readonly string[] audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[] untertitelSprachen?: readonly string[]
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */ /** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[] extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void /** Fortschritt 0100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
aufFortschritt?: (prozent: number, restS: number) => void
abbruch?: AbortSignal abbruch?: AbortSignal
} }
@@ -234,7 +273,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => { return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen) const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? [])) args.push(...(optionen.extraArgs ?? []))
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung() const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser() const leser = new HbZeilenLeser()
@@ -248,7 +287,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
const zeile = (z: string): void => { const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z) const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
} }
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
+118
View File
@@ -0,0 +1,118 @@
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
// WIRKLICH in der Roh-Datei? (5.4.0)
//
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
// Wunsch still durchrutscht.
//
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
// Objekt endet also NICHT am Textende — es wird über die Klammern
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
export interface HbSpuren {
audio: Array<{ nr: number; lang: string; beschreibung: string }>
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
dauerS: number
}
export function buildScanArgs(datei: string): string[] {
return ['--scan', '--json', '--input', datei, '--title', '1']
}
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
export function spurenAusScan(ausgabe: string): HbSpuren | null {
const marke = ausgabe.indexOf('JSON Title Set:')
if (marke < 0) return null
const start = ausgabe.indexOf('{', marke)
if (start < 0) return null
let tiefe = 0
let ende = -1
for (let i = start; i < ausgabe.length; i++) {
const zeichen = ausgabe[i]
if (zeichen === '{') tiefe += 1
else if (zeichen === '}') {
tiefe -= 1
if (tiefe === 0) {
ende = i
break
}
}
}
if (ende < 0) return null
let daten: unknown
try {
daten = JSON.parse(ausgabe.slice(start, ende + 1))
} catch {
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
// erfundener leerer Befund (R2).
return null
}
if (typeof daten !== 'object' || daten === null) return null
const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record<string, unknown>
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
if (!Array.isArray(feld)) return []
return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
return {
nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
}
})
}
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
return {
audio: spuren(titel.AudioList),
untertitel: spuren(titel.SubtitleList),
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
}
}
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig(null)
return
}
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig(null)
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
})
})
}
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
export function fehlendeSprachen(
spuren: HbSpuren,
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
): { audio: string[]; untertitel: string[] } {
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
const audioDa = da(spuren.audio)
const untertitelDa = da(spuren.untertitel)
return {
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
}
}
+42
View File
@@ -0,0 +1,42 @@
// Der gemeinsame Kontext der Kern-Dienste (5.5.0, Separation of Concerns):
// Jeder Dienst bekommt genau das hier — die Datenbank, die Wege nach
// draußen und das Protokoll — statt auf Modul-Globale zu greifen. So ist
// jeder Dienst für sich lesbar und mit einem Fake-Kontext testbar.
import type { DbStatus, KernNachricht } from '../gemeinsam/nachrichten'
import type { Protokoll } from '../gemeinsam/protokoll'
import type { Datenbank } from './speicher/db'
export interface KernKontext {
/** null, wenn die Datenbank nicht zu öffnen war — der Kern läuft trotzdem (R4). */
readonly db: Datenbank | null
readonly dbStatus: DbStatus
/** Nur ans Fenster (wenn eins verbunden ist). */
anFenster(nachricht: KernNachricht): void
/** Ans Haupt UND ans Fenster — für alles, was Tray/Taskleiste/Updater brauchen. */
anHauptUndFenster(nachricht: KernNachricht): void
/** Eine Zeile ohne Warnzeichen fürs Ereignis-Protokoll — und in die Datei. */
hinweis(text: string): void
/** Eine Zeile MIT Warnzeichen (kern-fehler) — und in die Datei. */
fehler(text: string): void
readonly protokoll: Protokoll
/** Der Gerüst-Beweis (--smoke-kern): ohne Netz, ohne MakeMKV, ohne Takte. */
readonly smoke: boolean
}
/** --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
* nicht er ist ein reiner Node-Prozess). */
export function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
/** Einstellung lesen mit Vorgabe — die eine Quelle (§ 6.7). */
export function einstellung(kontext: Pick<KernKontext, 'db'>, schluessel: string, vorgabe = ''): string {
const wert = kontext.db?.einstellungLesen(schluessel)
return wert === null || wert === undefined ? vorgabe : wert
}
@@ -0,0 +1,172 @@
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
//
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
// sie selbst.
//
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
// Pfad-Konverter, über spawn nicht).
import { spawn } from 'node:child_process'
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
import { textVon } from '../rip/parser'
export interface LaufwerkEreignis {
/** ISO-Zeit (UTC). */
zeit: string
provider: string
id: number
geraet: string
}
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
export const EREIGNIS_TEXTE: Record<number, string> = {
7: 'fehlerhafter Block',
11: 'Controllerfehler',
15: 'Gerät nicht bereit',
51: 'E/A-Fehler beim Lesen',
153: 'E/A-Vorgang wiederholt',
}
export function xpathFuer(stunden: number): string {
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
}
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
}
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
const liste: LaufwerkEreignis[] = []
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
const block = treffer[0]
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
if (!Number.isInteger(id) || provider.length === 0) continue
liste.push({ zeit, provider, id, geraet })
}
return liste
}
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
let fehlerhafteBloecke = 0
let controllerfehler = 0
let sonstige = 0
for (const e of relevant) {
if (e.id === 7) fehlerhafteBloecke += 1
else if (e.id === 11 || e.id === 15) controllerfehler += 1
else sonstige += 1
}
const letzte = relevant.slice(0, 8).map((e) => {
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
const zeit =
lokal !== null && !Number.isNaN(lokal.getTime())
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
: '?'
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
})
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
if (relevant.length === 0) {
return {
urteil: 'ruhig',
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (controllerfehler > 0) {
return {
urteil: 'hardware',
text:
`${controllerfehler} Controllerfehler ${zeitraum}` +
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (fehlerhafteBloecke > 0) {
return {
urteil: 'disc',
text:
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
return {
urteil: 'unklar',
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
export function ereignisseLesen(
stunden: number,
timeoutMs = 30_000,
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
return new Promise((fertig) => {
let kind: ReturnType<typeof spawn>
try {
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
} catch (fehler) {
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
return
}
const stuecke: Buffer[] = []
const fehlerStuecke: Buffer[] = []
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
})
kind.on('close', (code) => {
clearTimeout(zeiger)
const xml = textVon(Buffer.concat(stuecke))
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
// steht auf stderr mit Code ≠ 0.
if (code !== 0 && xml.trim().length === 0) {
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
return
}
fertig({ ereignisse: ereignisseAusXml(xml) })
})
})
}
+200 -32
View File
@@ -105,29 +105,149 @@ export function udfVolumeLabel(geraetepfad: string): string {
} }
} }
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml bdmt_eng bevorzugt, /** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */ * dieser Datei NUR den Titel das Inhaltsverzeichnis daneben ist der
export function titelAusBdmt(wurzel: string): string { * stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
*
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
* <di:titleName titleNumber="1">EP 1</di:titleName>
* <di:titleName titleNumber="2">EP 2</di:titleName>
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> usw.
*
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
* Das ist ein Beweis, keine Vermutung und schlägt jede Titel-Raterei. */
export interface BdmtInhalt {
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
titel: string
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
eintraege: string[]
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
* (bdmt_deu neben bdmt_eng, 5.4.0) ohne den Haupttitel, ohne Doppel. */
alternativen: string[]
}
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
* Authoring-Werkzeug). */
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
const eintraege: string[] = []
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
const text = t[1].trim()
if (text.length > 0) eintraege.push(text)
}
return { titel: name === null ? '' : name[1].trim(), eintraege }
}
/** Liest die bdmt-Dateien bdmt_eng zuerst, die anderen Sprachen liefern
* Titel-Varianten (5.4.0). */
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL') const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
if (!existsSync(metaOrdner)) return '' if (!existsSync(metaOrdner)) return leer
let kandidaten: string[] let kandidaten: string[]
try { try {
kandidaten = readdirSync(metaOrdner).sort() kandidaten = readdirSync(metaOrdner).sort()
} catch { } catch {
return '' return leer
} }
const bdmt = const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
kandidaten.find((k) => k === 'bdmt_eng.xml') ?? if (bdmts.length === 0) return leer
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml')) // Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
if (bdmt === undefined) return '' // Deutsch, dann der Rest in Dateireihenfolge.
let inhalt: string const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
try { bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8') let titel = ''
} catch { let eintraege: string[] = []
return '' const alternativen: string[] = []
for (const bdmt of bdmts) {
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
continue
}
const zerlegt = bdmtZerlegen(inhalt)
if (titel.length === 0 && zerlegt.titel.length > 0) {
titel = zerlegt.titel
eintraege = zerlegt.eintraege
continue
}
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
if (
zerlegt.titel.length > 0 &&
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
) {
alternativen.push(zerlegt.titel)
}
} }
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt) return { titel, eintraege, alternativen }
return treffer === null ? '' : treffer[1].trim() }
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
export function titelAusBdmt(wurzel: string): string {
return inhaltAusBdmt(wurzel).titel
}
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
* `101 LOGO` Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
export function istFolgenName(name: string): boolean {
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
}
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
export function folgenZahl(eintraege: readonly string[]): number {
return eintraege.filter(istFolgenName).length
}
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
// steht mit Absicht nicht hier:
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
const TRENNER = String.raw`[\s\-–—_:,.]`
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
export interface DiscZusatz {
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
titel: string
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer — null, wenn keine dasteht. */
staffel: number | null
}
/** `Spartacus: Gods Of The Arena - Disc 2` Titel + Disc 2 (pur).
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
export function discZusatzAbtrennen(titel: string): DiscZusatz {
let rest = titel.trim()
let discNr: number | null = null
let staffel: number | null = null
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
for (let runde = 0; runde < 4; runde++) {
const disc = DISC_MUSTER.exec(rest)
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
discNr = Number(disc[1])
rest = rest.slice(0, disc.index)
continue
}
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
staffel = Number(staffelTreffer[1])
rest = rest.slice(0, staffelTreffer.index)
continue
}
break
}
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
} }
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */ /** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
@@ -138,22 +258,53 @@ export function discWurzel(geraetepfad: string): string {
} }
export interface DiscTitel { export interface DiscTitel {
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
titel: string titel: string
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */ /** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
quelle: 'bdmt' | 'label' | '' quelle: 'bdmt' | 'label' | ''
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
discNr: number | null
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
staffel: number | null
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
folgen: number
/** Die Disc bezeugt selbst, dass sie Folgen trägt ab zwei ist es
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
istSerie: boolean
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
alternativen: string[]
} }
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der /** Mindestens so viele Folgen im Inhaltsverzeichnis, damit Serie" als
* BDMT-Titel wird NICHT normalisiert (2.22" würde zu „2 22"). */ * bewiesen gilt eine einzelne könnte auch eine Zugabe sein. */
export const FOLGEN_FUER_SERIE = 2
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
const zusatz = discZusatzAbtrennen(titel)
return {
titel,
quelle,
discNr: zusatz.discNr,
staffel: zusatz.staffel,
folgen,
istSerie: folgen >= FOLGEN_FUER_SERIE,
alternativen,
}
}
/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label.
* Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */
export function discTitel(geraetepfad: string): DiscTitel { export function discTitel(geraetepfad: string): DiscTitel {
const wurzel = discWurzel(geraetepfad) const wurzel = discWurzel(geraetepfad)
let folgen = 0
if (wurzel.length > 0) { if (wurzel.length > 0) {
const bdmt = titelAusBdmt(wurzel) const bdmt = inhaltAusBdmt(wurzel)
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' } folgen = folgenZahl(bdmt.eintraege)
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
} }
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' } if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
return { titel: '', quelle: '' } return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
} }
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der /** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der
@@ -163,25 +314,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
return `${label}|${groesseBytes}` return `${label}|${groesseBytes}`
} }
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
export const HOECHSTENS_KANDIDATEN = 7
/** Suchvarianten für die Metadaten-APIs (progressive Degradation, /** Suchvarianten für die Metadaten-APIs (progressive Degradation,
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne * ARM-Lehre) von SPEZIFISCH nach vage:
* Klammern, dann hintere Worte strippen. */ *
* voller Titel ohne Disc-/Staffel-Zusatz ohne Doppelpunkt
* ohne Klammern hintere Worte strippen Doppelpunkt-Vorspann
*
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
* ('Spartacus: Gods Of The Arena' 'Spartacus'). TMDb hat einen Film,
* der wörtlich so heißt die richtige Serie wurde nie gefragt. Er
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
* (MINDEST_ABDECKUNG in zuordnung.ts). */
export function titelKandidaten(titel: string): string[] { export function titelKandidaten(titel: string): string[] {
const kandidaten: string[] = [] const kandidaten: string[] = []
const nachlaufend = new RegExp(`${TRENNER}+$`)
const merke = (t: string): void => { const merke = (t: string): void => {
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '') const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber) if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
} }
merke(titel) merke(titel)
if (titel.includes(':')) { // Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
merke(titel.split(':', 1)[0]) // der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
merke(titel.replaceAll(':', '')) const ohneZusatz = discZusatzAbtrennen(titel).titel
} merke(ohneZusatz)
merke(titel.replace(/\([^)]*\)/g, '')) // Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
let worte = titel.split(/\s+/).filter(Boolean) // beide Schreibweisen fragen.
while (worte.length > 1 && kandidaten.length < 6) { if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
worte = worte.slice(0, -1) worte = worte.slice(0, -1)
merke(worte.join(' ')) merke(worte.join(' '))
} }
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
return kandidaten return kandidaten
} }
+115 -5
View File
@@ -2,14 +2,50 @@
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…', // ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur // Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE. // eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
//
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
// movie/149?append_to_response=images,credits,release_dates
// &include_image_language=de,en,null
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
// file_path:'/….png'|'.svg', width, height, vote_average}],
// credits.cast[{name, character, profile_path, order}],
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
// tv/46296?append_to_response=images,credits,content_ratings
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
// seasons[{season_number, name, episode_count}], number_of_episodes
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
export interface FilmTreffer { export interface FilmTreffer {
id: number id: number
title?: string title?: string
name?: string name?: string
poster_path?: string poster_path?: string | null
backdrop_path?: string | null
release_date?: string release_date?: string
first_air_date?: string first_air_date?: string
overview?: string
popularity?: number
}
export interface TmdbLogo {
iso_639_1: string | null
file_path: string
width?: number
height?: number
vote_average?: number
}
export interface TmdbCast {
name: string
character?: string
profile_path?: string | null
order?: number
} }
export interface FilmDetails { export interface FilmDetails {
@@ -19,10 +55,22 @@ export interface FilmDetails {
release_date?: string release_date?: string
first_air_date?: string first_air_date?: string
overview?: string overview?: string
poster_path?: string poster_path?: string | null
backdrop_path?: string backdrop_path?: string | null
runtime?: number runtime?: number
genres?: Array<{ name: string }> genres?: Array<{ name: string }>
tagline?: string
vote_average?: number
vote_count?: number
popularity?: number
episode_run_time?: number[]
number_of_episodes?: number
number_of_seasons?: number
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
images?: { logos?: TmdbLogo[] }
credits?: { cast?: TmdbCast[] }
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
} }
export function istV4Token(key: string): boolean { export function istV4Token(key: string): boolean {
@@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean {
const BASIS = 'https://api.themoviedb.org/3' const BASIS = 'https://api.themoviedb.org/3'
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
* bestbewertete. '' wenn keins. */
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
if (logos === undefined || logos.length === 0) return ''
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
const sortiert = [...logos]
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
return sortiert[0]?.file_path ?? ''
}
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
if (typ === 'tv') {
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
return (de?.rating ?? '').trim()
}
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
if (eintraege.length === 0) return ''
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
}
/** Die ersten Darsteller mit Bild (pur). */
export function besetzungAus(
cast: readonly TmdbCast[] | undefined,
hoechstens = 6,
): Array<{ name: string; rolle: string; bildPfad: string }> {
if (cast === undefined) return []
return [...cast]
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.slice(0, hoechstens)
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
}
export class TmdbClient { export class TmdbClient {
constructor( constructor(
private readonly apiKey: string, private readonly apiKey: string,
@@ -84,11 +170,35 @@ export class TmdbClient {
} }
async movieDetails(id: number): Promise<FilmDetails | null> { async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null return (await this.anfrage(`movie/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,release_dates',
include_image_language: 'de,en,null',
})) as FilmDetails | null
} }
async tvDetails(id: number): Promise<FilmDetails | null> { async tvDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null return (await this.anfrage(`tv/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,content_ratings',
include_image_language: 'de,en,null',
})) as FilmDetails | null
}
/** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung):
* /movie/popular liefert results[].poster_path dieselbe Form wie die
* Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */
async beliebtePoster(hoechstens = 40): Promise<string[]> {
const pfade: string[] = []
for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) {
const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as {
results?: FilmTreffer[]
} | null
for (const f of daten?.results ?? []) {
if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path)
}
}
return pfade.slice(0, hoechstens)
} }
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */ /** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
+318 -65
View File
@@ -12,9 +12,24 @@
// //
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira // Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5). // (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
import { titelKandidaten } from './discmerkmale' //
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb' // 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
// nicht mehr als Treffer.
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
// auf einen Vorschlag zurück.
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
// gemessen, nicht an der englischen.
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb' import type { OmdbClient, OmdbTreffer } from './omdb'
import type { Darsteller } from '../../gemeinsam/nachrichten'
export const WENIGE_TREFFER = 3 export const WENIGE_TREFFER = 3
@@ -30,6 +45,14 @@ export interface MetaTreffer {
laufzeitMinuten: number laufzeitMinuten: number
genres: string[] genres: string[]
quelle: 'tmdb' | 'omdb' | '' quelle: 'tmdb' | 'omdb' | ''
// ── Kino-Banner v2 (5.4.0) ──
logoPfad: string
tagline: string
bewertung: number
fsk: string
besetzung: Darsteller[]
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
folgenGesamt: number
} }
export interface Zuordnung { export interface Zuordnung {
@@ -37,6 +60,8 @@ export interface Zuordnung {
jahr: number | null jahr: number | null
confidence: number confidence: number
meta: MetaTreffer meta: MetaTreffer
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
hinweise?: string[]
} }
function jahrAus(datum: string | undefined): number | null { function jahrAus(datum: string | undefined): number | null {
@@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null {
return Number.isFinite(jahr) && jahr > 0 ? jahr : null return Number.isFinite(jahr) && jahr > 0 ? jahr : null
} }
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer { export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
const laufzeit =
typ === 'movie'
? (details.runtime ?? 0)
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
return { return {
typ, typ,
id, id,
@@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
beschreibung: details.overview ?? '', beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '', posterPfad: details.poster_path ?? '',
backdropPfad: details.backdrop_path ?? '', backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: details.runtime ?? 0, laufzeitMinuten: laufzeit,
genres: (details.genres ?? []).map((g) => g.name), genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb', quelle: 'tmdb',
logoPfad: logoAus(details.images?.logos),
tagline: (details.tagline ?? '').trim(),
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
fsk: fskAus(details, typ),
besetzung: besetzungAus(details.credits?.cast),
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
} }
} }
@@ -73,38 +108,66 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
laufzeitMinuten: treffer.runtime, laufzeitMinuten: treffer.runtime,
genres: treffer.genres, genres: treffer.genres,
quelle: 'omdb', quelle: 'omdb',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
} }
} }
export const LEERER_TREFFER: MetaTreffer = {
typ: 'unknown',
id: null,
titel: '',
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
const UNBEKANNT = (titel: string): Zuordnung => ({ const UNBEKANNT = (titel: string): Zuordnung => ({
titel, titel,
jahr: null, jahr: null,
confidence: 0.3, confidence: 0.3,
meta: { meta: { ...LEERER_TREFFER, titel },
typ: 'unknown',
id: null,
titel,
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
},
}) })
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
* Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
* keine Bewertung TMDb kennt dort nur den Namen. */
export function istStub(details: FilmDetails): boolean {
const beschreibung = (details.overview ?? '').trim().length > 0
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
const bewertet = (details.vote_count ?? 0) > 0
return !beschreibung && !bild && !bewertet
}
/** Titel Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
* Abdeckung und Titelvergleich alle drei sollen dasselbe Wort" meinen. */
function worte(t: string): Set<string> {
return new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
}
/** Wort-Überlappung zweier Titel (Jaccard, 01) das Gate gegen /** Wort-Überlappung zweier Titel (Jaccard, 01) das Gate gegen
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb * selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
* aus dem Label Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur BD"). */ * aus dem Label Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur BD"). */
export function wortUeberlappung(a: string, b: string): number { export function wortUeberlappung(a: string, b: string): number {
const worte = (t: string): Set<string> =>
new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
const wa = worte(a) const wa = worte(a)
const wb = worte(b) const wb = worte(b)
if (wa.size === 0 || wb.size === 0) return 0 if (wa.size === 0 || wb.size === 0) return 0
@@ -113,71 +176,173 @@ export function wortUeberlappung(a: string, b: string): number {
return gemeinsam / (wa.size + wb.size - gemeinsam) return gemeinsam / (wa.size + wb.size - gemeinsam)
} }
/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung
* weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb
* schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of
* the Arena' war zeichengenau verglichen kein Treffer der exakt
* richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */
export function gleichBedeutend(a: string, b: string): boolean {
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
const nackt = (t: string): string =>
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0)
.join(' ')
const na = nackt(a)
return na.length > 0 && na === nackt(b)
}
/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (01)? */
export function abdeckung(discTitel: string, kandidat: string): number {
const ganz = worte(discTitel)
if (ganz.size === 0) return 0
let drin = 0
for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1
return drin / ganz.size
}
/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */
export const VORSCHLAG = 0.6
/** Darunter ist ein Treffer GERATEN, nicht gefunden und wird zum
* Vorschlag herabgestuft, egal wie wörtlich er aussieht.
*
* Der Grund, an der echten Disc gemessen (01.09.2026): Aus
* 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die
* Frage 'Spartacus' übrig sechs von sieben Wörtern weggeworfen. TMDb
* antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit
* 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART
* des Treffers, nie die QUALITÄT der Frage. */
export const MINDEST_ABDECKUNG = 0.5
export interface DiscHinweis {
/** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
istSerie?: boolean
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
alternativen?: readonly string[]
}
interface Kandidat {
text: string
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
massstab: string
}
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
* gestutzten je Variante an ihrer eigenen Vorlage gemessen (pur). */
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
const gesehen = new Set<string>()
const voll: Kandidat[] = []
const rest: Kandidat[] = []
for (const quelle of quellen) {
const massstab = discZusatzAbtrennen(quelle).titel || quelle
const varianten = titelKandidaten(quelle)
if (varianten.length === 0) varianten.push(quelle)
varianten.forEach((text, i) => {
const schluessel = text.toLowerCase()
if (gesehen.has(schluessel)) return
gesehen.add(schluessel)
;(i === 0 ? voll : rest).push({ text, massstab })
})
}
return [...voll, ...rest]
}
/** Die Kaskade Clients injiziert, damit sie ohne Netz prüfbar ist. /** Die Kaskade Clients injiziert, damit sie ohne Netz prüfbar ist.
* *
* Reihenfolge JE KANDIDAT: wörtlich Film wörtlich Serie (nur beim * Reihenfolge JE KANDIDAT: wörtlich Film wörtlich Serie spezifisch
* vollen Titel) spezifisch Film spezifisch Serie. Erst dann der nächste, * Film spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026: *
* Beim Disc-Titel Spartacus Gods Of The Arena" erschlug der stark * Drei Sicherungen, alle an echten Discs bezahlt:
* gekürzte Kandidat Spartacus" mit einem wörtlichen Treffer (Film von *
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */ * * `hinweis.istSerie` schaltet die Film-Zweige ganz ab.
* * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG):
* Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten
* das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026:
* 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage
* 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet.
* * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN
* Kandidaten er muss noch jedes Wort des Titels tragen (Zusätze wie
* '- Disc 2' zählen nicht mit). Vorher hing das an Kandidat Nummer
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
* Nummer 1 und fiel durch dieses Raster.
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer der
* nächste wörtliche Treffer bekommt die Chance.
*/
export async function zuordnen( export async function zuordnen(
discTitel: string, discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>, tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>, omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
hinweis: DiscHinweis = {},
): Promise<Zuordnung> { ): Promise<Zuordnung> {
const titel = discTitel.trim() const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('') if (titel.length === 0) return UNBEKANNT('')
const kandidaten = titelKandidaten(titel) const nurSerie = hinweis.istSerie === true
if (kandidaten.length === 0) kandidaten.push(titel) const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
let ersteTreffer: FilmTreffer[] = [] let ersteFilme: FilmTreffer[] = []
let ersteSerien: FilmTreffer[] = []
for (let i = 0; i < kandidaten.length; i++) { for (const kandidat of kandidaten) {
const kandidat = kandidaten[i] const deckung = abdeckung(kandidat.massstab, kandidat.text)
const filme = (await tmdb.searchMovie(kandidat)) ?? [] const vollstaendig = deckung >= 1
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme /** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
// Wörtlicher Filmtreffer (0,95) // Wörtlicher Filmtreffer (0,95)
for (const film of filme) { for (const film of filme) {
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) { if (gleichBedeutend(film.title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id) const details = await tmdb.movieDetails(film.id)
if (details !== null) { if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details) const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta } return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
} }
} }
} }
const serien = (await tmdb.searchTv(kandidat)) ?? [] const serien = (await tmdb.searchTv(kandidat.text)) ?? []
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
// Wörtlicher Serientreffer (0,90) // Wörtlicher Serientreffer (0,90)
for (const serie of serien) { for (const serie of serien) {
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) { if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id) const details = await tmdb.tvDetails(serie.id)
if (details !== null) { if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details) const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta } return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
} }
} }
} }
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80): // Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie // Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
// jemand, der weiß, was er sucht (der Evangelion-Fund). // (der Evangelion-Fund). Stubs zählen dabei nicht mit.
if (i === 0) { if (vollstaendig) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) { if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id) for (const film of filme) {
if (details !== null) { const details = await tmdb.movieDetails(film.id)
const meta = ausDetails('movie', filme[0].id, details) if (details !== null && !istStub(details)) {
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
} }
} }
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) { if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
const details = await tmdb.tvDetails(serien[0].id) for (const serie of serien) {
if (details !== null) { const details = await tmdb.tvDetails(serie.id)
const meta = ausDetails('tv', serien[0].id, details) if (details !== null && !istStub(details)) {
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
} }
} }
} }
@@ -187,12 +352,15 @@ export async function zuordnen(
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen. // Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) { if (omdb.verfuegbar) {
for (const kandidat of kandidaten) { for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat) const treffer = await omdb.lookup(kandidat.text)
if (treffer !== null) { if (treffer !== null) {
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
// was sie ist.
if (nurSerie && treffer.type !== 'tv') continue
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische // Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream // Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
// 5". Ohne echte Wort-Überlappung ist das kein Treffer. // 5". Ohne echte Wort-Überlappung ist das kein Treffer.
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer) const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) { if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id) const deutsch = await tmdb.findByImdb(meta.id)
@@ -208,19 +376,36 @@ export async function zuordnen(
if (deutsch['type'] === 'tv') meta.typ = 'tv' if (deutsch['type'] === 'tv') meta.typ = 'tv'
} }
} }
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } // Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
// ist ein Vorschlag, egal von welchem Dienst er kommt.
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
} }
} }
} }
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. // 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
if (ersteTreffer.length > 0) { // Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
const details = await tmdb.movieDetails(ersteTreffer[0].id) // Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
if (details !== null) { // trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
const meta = ausDetails('movie', ersteTreffer[0].id, details) let notnagel: Zuordnung | null = null
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta } for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
} const details = await tmdb.movieDetails(film.id)
if (details === null) continue
const meta = ausDetails('movie', film.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
} }
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.tvDetails(serie.id)
if (details === null) continue
const meta = ausDetails('tv', serie.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
if (notnagel !== null) return notnagel
return UNBEKANNT(titel) return UNBEKANNT(titel)
} }
@@ -229,3 +414,71 @@ export async function zuordnen(
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean { export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle return zuordnung.confidence >= schwelle
} }
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
export const LAUFZEIT_PASST = 0.06
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
export const LAUFZEIT_FALSCH = 0.25
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
export interface Messung {
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
hauptS: number
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
folgenS: readonly number[]
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
struktur: 'film' | 'serie' | 'unklar'
}
/**
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
* haben ist die Laufzeit. Aus wahrscheinlich" wird „bestätigt", oder
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
* (confidence 1) wird nie herabgestuft, nur kommentiert.
*/
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
const hinweise: string[] = []
let confidence = zuordnung.confidence
const meta = zuordnung.meta
const vomNutzer = zuordnung.confidence >= 1
const soll = meta.laufzeitMinuten * 60
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
const abweichung = Math.abs(messung.hauptS - soll) / soll
const ist = Math.round(messung.hauptS / 60)
if (abweichung <= LAUFZEIT_PASST) {
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
} else if (abweichung >= LAUFZEIT_FALSCH) {
hinweise.push(
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else {
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
}
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
const abweichung = Math.abs(mittel - soll) / soll
const ist = Math.round(mittel / 60)
if (abweichung <= 0.2) {
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
} else if (abweichung >= 0.5) {
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
}
if (messung.struktur === 'serie' && meta.typ === 'movie') {
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
}
+303 -225
View File
@@ -1,225 +1,303 @@
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2). // makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar); // Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen. // hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
// //
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen): // Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan // - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
// hing bzw. stürzte — Befund 23.07.) // hing bzw. stürzte — Befund 23.07.)
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt // - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
// Kodierungen; „Das Öffnen" war die Folge von text=True. // Kodierungen; „Das Öffnen" war die Folge von text=True.
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und // - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2). // sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer // - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor. // nur „Failed to open disc", die Ursache steht Zeilen davor.
import { spawn } from 'node:child_process' import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs' import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import {
KRITISCHE_CODES, KRITISCHE_CODES,
KRITISCHE_TEXTE, KRITISCHE_TEXTE,
LESEFEHLER_MARKE, LESEFEHLER_MARKE,
parseMsg, offsetAusMeldung,
parseStreamInfo, parseMsg,
parseTitelInfo, parseStreamInfo,
progressAusPrgv, parseTitelInfo,
quelle, progressAusPrgv,
sprachenZusammenfassen, quelle,
textVon, sprachenZusammenfassen,
type SprachEintrag, textVon,
type TitelInfo, type SprachEintrag,
type TitelStreams, type TitelInfo,
} from './parser' type TitelStreams,
} from './parser'
export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)] export function buildInfoArgs(geraetepfad: string): string[] {
} return ['-r', '--noscan', 'info', quelle(geraetepfad)]
}
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner] export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
} return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
}
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung { /** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
prozent = -1 * Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
letzteMeldung = '' * zwölf sind also rund sechs Versuche genug, um einen kurzen Aussetzer
lesefehler = false * von einem echten Defekt zu unterscheiden. */
kritischeMeldungen: string[] = [] export const HAENGT_AB = 12
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */ /** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } { * Aufrufer schweigt dazu abgelöst ist kein Fehler, sondern Absicht. */
const p = progressAusPrgv(zeile) export const ABGELOEST = 'titel-lauf-abgeloest'
if (p >= 0) {
if (p === this.prozent) return {} /** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
this.prozent = p export class RipAuswertung {
return { prozent: p } prozent = -1
} letzteMeldung = ''
const meldung = parseMsg(zeile) lesefehler = false
if (meldung === null) return {} kritischeMeldungen: string[] = []
this.letzteMeldung = meldung.text /** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true haengtBei = ''
if (meldung.code in KRITISCHE_CODES) { /** Wie oft dieselbe Stelle schon gemeldet wurde. */
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`) haengtWiederholungen = 0
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
this.kritischeMeldungen.push(meldung.text) /** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
} * der Disc und kein Fortschrittsproblem Weiterversuchen hilft nicht. */
return { meldung } haengt(): boolean {
} return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
}
fehlerText(returncode: number | null, dateienDa: boolean): string {
return ( /** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
`makemkvcon endete mit Code ${returncode ?? '?'}` + verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') + const p = progressAusPrgv(zeile)
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') + if (p >= 0) {
(dateienDa ? '' : ' — keine MKV-Datei entstanden') if (p === this.prozent) return {}
) // Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
} this.haengtBei = ''
} this.haengtWiederholungen = 0
this.prozent = p
/** Buffer-Ströme in Zeilen zerlegen binär, die letzte halbe Zeile bleibt return { prozent: p }
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */ }
export class ZeilenLeser { const meldung = parseMsg(zeile)
private rest: Buffer = Buffer.alloc(0) if (meldung === null) return {}
this.letzteMeldung = meldung.text
hinzu(stueck: Buffer): string[] { if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck]) // Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
const zeilen: string[] = [] const stelle = offsetAusMeldung(meldung.text)
for (;;) { if (stelle.length > 0) {
const umbruch = this.rest.indexOf(0x0a) if (stelle === this.haengtBei) {
if (umbruch < 0) break this.haengtWiederholungen += 1
let ende = umbruch } else {
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1 this.haengtBei = stelle
zeilen.push(textVon(this.rest.subarray(0, ende))) this.haengtWiederholungen = 1
this.rest = this.rest.subarray(umbruch + 1) }
} }
return zeilen if (meldung.code in KRITISCHE_CODES) {
} this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
schluss(): string[] { this.kritischeMeldungen.push(meldung.text)
if (this.rest.length === 0) return [] }
const letzte = textVon(this.rest) return { meldung }
this.rest = Buffer.alloc(0) }
return letzte.trim().length > 0 ? [letzte] : []
} fehlerText(returncode: number | null, dateienDa: boolean): string {
} return (
`makemkvcon endete mit Code ${returncode ?? '?'}` +
export interface DiscInfo { (this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
titel: TitelInfo[] (this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
streams: Map<number, TitelStreams> (dateienDa ? '' : ' — keine MKV-Datei entstanden')
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } )
} }
}
/** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s,
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */ /** Buffer-Ströme in Zeilen zerlegen binär, die letzte halbe Zeile bleibt
export function titelInfoLesen( * liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
programm: string, export class ZeilenLeser {
geraetepfad: string, private rest: Buffer = Buffer.alloc(0)
timeoutMs = 300_000,
): Promise<DiscInfo> { hinzu(stueck: Buffer): string[] {
return new Promise((fertig, scheitert) => { this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const kind = spawn(programm, buildInfoArgs(geraetepfad), { const zeilen: string[] = []
windowsHide: true, for (;;) {
stdio: ['ignore', 'pipe', 'pipe'], const umbruch = this.rest.indexOf(0x0a)
}) if (umbruch < 0) break
const stuecke: Buffer[] = [] let ende = umbruch
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck)) if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck)) zeilen.push(textVon(this.rest.subarray(0, ende)))
const zeiger = setTimeout(() => { this.rest = this.rest.subarray(umbruch + 1)
kind.kill() }
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) return zeilen
}, timeoutMs) }
kind.on('error', (fehler) => {
clearTimeout(zeiger) schluss(): string[] {
scheitert(fehler) if (this.rest.length === 0) return []
}) const letzte = textVon(this.rest)
kind.on('close', () => { this.rest = Buffer.alloc(0)
clearTimeout(zeiger) return letzte.trim().length > 0 ? [letzte] : []
const ausgabe = textVon(Buffer.concat(stuecke)) }
const titel = parseTitelInfo(ausgabe) }
const streams = parseStreamInfo(ausgabe)
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) }) export interface DiscInfo {
}) /** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam die
}) * kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
} *
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 (Das
export type RipErgebnis = * Programm konnte keine verwendbaren optischen Laufwerke finden")
| { status: 'success'; dateien: string[]; lesefehler: boolean } * antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
| { status: 'cancelled' } * stünde im Fenster nur keine Titel gefunden" und niemand wüsste,
| { status: 'error'; fehler: string; lesefehler: boolean } * dass das Laufwerk gemeint ist und nicht die Disc. */
ursachen: string[]
export interface RipLaufOptionen { titel: TitelInfo[]
programm: string streams: Map<number, TitelStreams>
geraetepfad: string sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
zielOrdner: string }
/** 'all' oder eine Titel-Nummer als Text. */
titel: string /** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s,
aufFortschritt?: (prozent: number) => void * zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
aufMeldung?: (code: number, text: string) => void export function titelInfoLesen(
abbruch?: AbortSignal programm: string,
} geraetepfad: string,
timeoutMs = 300_000,
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf das Ergebnis trägt abbruch?: AbortSignal,
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */ ): Promise<DiscInfo> {
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> { return new Promise((fertig, scheitert) => {
return new Promise((fertig) => { const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
mkdirSync(optionen.zielOrdner, { recursive: true }) windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
const kind = spawn( })
optionen.programm, const stuecke: Buffer[] = []
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel), kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
) const zeiger = setTimeout(() => {
kind.kill()
const auswertung = new RipAuswertung() scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
const leser = new ZeilenLeser() }, timeoutMs)
let abgebrochen = false // Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
const abbrechen = (): void => { // Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
abgebrochen = true // die längst gelieferte Titelliste).
kind.kill() const aufgeben = (): void => {
} clearTimeout(zeiger)
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) kind.kill()
scheitert(new Error(ABGELOEST))
const zeile = (z: string): void => { }
const ergebnis = auswertung.verarbeite(z) abbruch?.addEventListener('abort', aufgeben, { once: true })
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) kind.on('error', (fehler) => {
if (ergebnis.meldung !== undefined) { clearTimeout(zeiger)
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text) abbruch?.removeEventListener('abort', aufgeben)
} scheitert(fehler)
} })
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.on('close', () => {
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) clearTimeout(zeiger)
abbruch?.removeEventListener('abort', aufgeben)
kind.on('error', (fehler) => { const ausgabe = textVon(Buffer.concat(stuecke))
optionen.abbruch?.removeEventListener('abort', abbrechen) const titel = parseTitelInfo(ausgabe)
fertig({ status: 'error', fehler: String(fehler), lesefehler: false }) const streams = parseStreamInfo(ausgabe)
}) // Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
const ursachen: string[] = []
kind.on('close', (code) => { for (const zeile of ausgabe.split('\n')) {
optionen.abbruch?.removeEventListener('abort', abbrechen) const meldung = parseMsg(zeile)
leser.schluss().forEach(zeile) if (meldung === null) continue
if (abgebrochen) { if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
fertig({ status: 'cancelled' }) ursachen.push(KRITISCHE_CODES[meldung.code])
return }
} }
let dateien: string[] = [] fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
try { })
dateien = readdirSync(optionen.zielOrdner) })
.filter((name) => name.endsWith('.mkv')) }
.sort()
.map((name) => join(optionen.zielOrdner, name)) export type RipErgebnis =
} catch (fehler) { | { status: 'success'; dateien: string[]; lesefehler: boolean }
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler }) | { status: 'cancelled' }
return | { status: 'error'; fehler: string; lesefehler: boolean }
}
if (code === 0 && dateien.length > 0) { export interface RipLaufOptionen {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler }) programm: string
return geraetepfad: string
} zielOrdner: string
fertig({ /** 'all' oder eine Titel-Nummer als Text. */
status: 'error', titel: string
fehler: auswertung.fehlerText(code, dateien.length > 0), aufFortschritt?: (prozent: number) => void
lesefehler: auswertung.lesefehler, aufMeldung?: (code: number, text: string) => void
}) /** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr)
}) * das ist ein Disc-Defekt, kein Fortschrittsproblem. */
}) aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
} abbruch?: AbortSignal
}
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf das Ergebnis trägt
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
)
const auswertung = new RipAuswertung()
const leser = new ZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
let klemmteVorher = false
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.meldung !== undefined) {
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
}
const klemmt = auswertung.haengt()
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
klemmteVorher = klemmt
optionen.aufHaengt?.(
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
)
}
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
let dateien: string[] = []
try {
dateien = readdirSync(optionen.zielOrdner)
.filter((name) => name.endsWith('.mkv'))
.sort()
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
return
}
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
return
}
fertig({
status: 'error',
fehler: auswertung.fehlerText(code, dateien.length > 0),
lesefehler: auswertung.lesefehler,
})
})
})
}
+62 -12
View File
@@ -10,6 +10,9 @@
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des // MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner. // Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string { export function quelle(geraetepfad: string): string {
// 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie
// als `iso:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}`
let pfad = (geraetepfad ?? '').trim() let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return '' if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) { for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
@@ -111,9 +114,21 @@ export function parseTitelInfo(ausgabe: string): TitelInfo[] {
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM // ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält // Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
// jede Disc für englisch. // jede Disc für englisch.
// ⚠️ 5.6.1 (Commander-Fund 03.09.2026, Digimon Last Evolution Kizuna): Auch
// der TEXT von Attribut 1 ist MakeMKVs Anzeigesprache — deutsch eingestellt
// schreibt es `SINFO:1,5,1,6203,"Untertitel"`, nicht „Subtitles". „Audio"
// heißt zufällig in beiden Sprachen gleich, deshalb fehlten NUR die
// Untertitel. Verlässlich ist der CODE im vierten Feld: 6201 Video,
// 6202 Audio, 6203 Untertitel (gemessen an makemkvcon64 auf diesem PC).
export const STREAM_CODE_VIDEO = 6201
export const STREAM_CODE_AUDIO = 6202
export const STREAM_CODE_UNTERTITEL = 6203
export interface StreamInfo { export interface StreamInfo {
nr: number nr: number
typ: string typ: string
/** Typ-Code aus Attribut 1 (6201/6202/6203), 0 wenn keiner dastand. */
typCode: number
lang: string lang: string
sprache: string sprache: string
codec: string codec: string
@@ -126,7 +141,7 @@ export interface TitelStreams {
untertitel: StreamInfo[] untertitel: StreamInfo[]
} }
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/ const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),(\d+),"(.*)"\s*$/
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> { export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const titel = new Map<number, Map<number, StreamInfo>>() const titel = new Map<number, Map<number, StreamInfo>>()
@@ -136,7 +151,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const tNr = Number(treffer[1]) const tNr = Number(treffer[1])
const sNr = Number(treffer[2]) const sNr = Number(treffer[2])
const attr = Number(treffer[3]) const attr = Number(treffer[3])
const wert = treffer[4] const code = Number(treffer[4])
const wert = treffer[5]
let streams = titel.get(tNr) let streams = titel.get(tNr)
if (streams === undefined) { if (streams === undefined) {
streams = new Map() streams = new Map()
@@ -144,10 +160,13 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
} }
let eintrag = streams.get(sNr) let eintrag = streams.get(sNr)
if (eintrag === undefined) { if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' } eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
streams.set(sNr, eintrag) streams.set(sNr, eintrag)
} }
if (attr === 1) eintrag.typ = wert if (attr === 1) {
eintrag.typ = wert
eintrag.typCode = code
}
else if (attr === 3) eintrag.lang = wert else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert else if (attr === 6) eintrag.codec = wert
@@ -161,20 +180,20 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const untertitel: StreamInfo[] = [] const untertitel: StreamInfo[] = []
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) { for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
const s = streams.get(sNr)! const s = streams.get(sNr)!
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen. // Der Code entscheidet (5.6.1); das Wort nur als Notnagel für Ausgaben
if (s.typ === 'Audio') audio.push(s) // ohne Code („Audio", „Subtitles", „Untertitel").
else if (s.typ.startsWith('Subtitle')) untertitel.push(s) if (s.typCode === STREAM_CODE_AUDIO || (s.typCode === 0 && s.typ === 'Audio')) audio.push(s)
else if (s.typCode === STREAM_CODE_UNTERTITEL || (s.typCode === 0 && (s.typ.startsWith('Subtitle') || s.typ.startsWith('Untertitel')))) untertitel.push(s)
} }
ergebnis.set(tNr, { audio, untertitel }) ergebnis.set(tNr, { audio, untertitel })
} }
return ergebnis return ergebnis
} }
export interface SprachEintrag { // EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
lang: string // die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
sprache: string import type { SprachEintrag } from '../../gemeinsam/nachrichten'
spuren: number export type { SprachEintrag }
}
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt, /** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */ * Reihenfolge stabil (wie auf der Disc). */
@@ -200,6 +219,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') } return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
} }
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
if (streams === undefined) return { audio: [], untertitel: [] }
return sprachenZusammenfassen(new Map([[0, streams]]))
}
// ── Titelwahl-Heuristiken ─────────────────────────────────────────────── // ── Titelwahl-Heuristiken ───────────────────────────────────────────────
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus /** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) das * TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) das
@@ -249,6 +279,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0). // (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read' export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
*
* während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
* while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
*
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
* das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
*
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
* Defekt MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
export function offsetAusMeldung(text: string): string {
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
// Anführungszeichen, ein Offset ist immer groß.
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
return treffer === null ? '' : treffer[1]
}
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind // Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk // sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert). // des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
+185
View File
@@ -0,0 +1,185 @@
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
// Nullen für das, was die Disc nicht mehr hergibt:
//
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
//
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
// nicht (Commander 01.09.2026).
//
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
import { open } from 'node:fs/promises'
import { writeFileSync } from 'node:fs'
export const BLOCK_BYTES = 1024 * 1024
export const FEIN_BYTES = 64 * 1024
export const SEKTOR_BYTES = 2048
export interface UnlesbareStelle {
offset: number
bytes: number
}
export type RettungsErgebnis =
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
* viele es wurden. Wirft bei einem Lesefehler. */
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
export interface RettungsOptionen {
leser: Leser
schreiber: Schreiber
erwarteteBytes: number
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
abbruch?: AbortSignal
/** Versuche je Stück — Vorgabe 2. */
versuche?: number
}
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
for (let i = 0; i < versuche; i++) {
try {
const n = await leser(puffer, laenge, position)
if (n === laenge) return true
} catch {
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
}
}
return false
}
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
const letzte = stellen[stellen.length - 1]
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
letzte.bytes += bytes
return
}
stellen.push({ offset, bytes })
}
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
const versuche = o.versuche ?? 2
const puffer = Buffer.alloc(BLOCK_BYTES)
const feinPuffer = Buffer.alloc(FEIN_BYTES)
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
const stellen: UnlesbareStelle[] = []
let unlesbareSektoren = 0
let gelesen = 0
let letzterProzent = -1
const melden = (): void => {
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
if (prozent !== letzterProzent) {
letzterProzent = prozent
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
}
}
if (o.erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
}
try {
while (gelesen < o.erwarteteBytes) {
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
const m = Math.min(FEIN_BYTES, n - teil)
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
feinPuffer.copy(puffer, teil, 0, m)
continue
}
for (let s = 0; s < m; s += SEKTOR_BYTES) {
const k = Math.min(SEKTOR_BYTES, m - s)
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
sektorPuffer.copy(puffer, teil + s, 0, k)
} else {
puffer.fill(0, teil + s, teil + s + k)
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
stelleHinzu(stellen, gelesen + teil + s, k)
}
}
}
}
await o.schreiber(puffer, n)
gelesen += n
melden()
}
} catch (fehler) {
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
}
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
}
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
const zeilen = [
`Rettungs-Abbild von ${quelle}`,
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
]
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
return zeilen.join('\n') + '\n'
}
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
export async function rettungsAbbildDatei(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
abbruch?: AbortSignal,
): Promise<RettungsErgebnis> {
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
const q = quelle
const z = ziel
let ergebnis: RettungsErgebnis
try {
ergebnis = await rettungsAbbild({
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
schreiber: async (puffer, laenge) => {
await z.write(puffer, 0, laenge)
},
erwarteteBytes,
aufFortschritt,
abbruch,
})
} finally {
await q.close()
await z.close()
}
try {
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
} catch {
// Der Bericht ist Beigabe — das Abbild zählt.
}
return ergebnis
}
+176
View File
@@ -0,0 +1,176 @@
// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
// die Ampel das auf Linux prüfen kann.
//
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
// bei filmen z. B. nur das hauptfeature oder auch die extras."
//
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
//
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist
// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
// zu schweigen.
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
import type { TitelInfo } from './parser'
export type { TitelRolle }
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
* standardmäßig für seine eigene Mindestlänge. */
export const KURZ_S = 120
/** Folgen einer Staffel sind ähnlich lang 45 bis 55 Minuten sind eine
* Serie, 50 Minuten und 3 Minuten sind es nicht. */
export const AEHNLICH = 0.15
/** Mehrere Titel dicht am längsten heißen fast immer Seamless Branching"
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
* nicht eindeutig, und Rippy sagt das. */
export const FILM_GLEICHAUF = 0.05
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
* Sammeltitel (Alle Folgen am Stück"). Der ist der längste und würde
* eine Film-Auswahl sonst gewinnen und dann rippt Rippy alles doppelt. */
export const SAMMEL_TOLERANZ = 0.1
export interface TitelVorschlag extends TitelInfo {
/** Von Rippy vorausgewählt? */
gewaehlt: boolean
/** Als was Rippy diesen Titel einsortieren würde. */
rolle: TitelRolle
/** Warum — im Klartext, das Fenster zeigt es an. */
grund: string
}
export interface TitelWahl {
titel: TitelVorschlag[]
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
einordnung: string
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
}
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
export interface DiscHinweis {
istSerie: boolean
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
folgen: number
}
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: 1 langer +
* viele kurze = Film; 613 gleich lange = Serienstaffel"). Bewusst
* ZURÜCKHALTEND 'unklar' ist die häufigste und ehrlichste Antwort:
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
if (lang.length === 0) return 'unklar'
const laengster = lang[0].dauerS
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
const zweiter = lang[1]?.dauerS ?? 0
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
return 'unklar'
}
function dauerText(s: number): string {
const m = Math.round(s / 60)
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
}
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean {
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS)
// Mit weniger als zwei Teilen gibt es nichts zu sammeln.
if (kuerzere.length < 2) return false
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
if (summe <= 0) return false
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
}
/**
* Die Vorauswahl. Sie wählt VOR entschieden wird im Fenster.
*
* Drei Fälle:
* * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
* Inhaltsverzeichnis Folgen nennt.
* * Film: der längste Titel, der kein Sammeltitel ist.
* * Unklar: nichts vorausgewählt lieber fragen als raten.
*/
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
if (alle.length === 0) {
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
}
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
const kandidaten = alle
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
.sort((a, b) => b.dauerS - a.dauerS)
const gewaehlt = new Set<number>()
let einordnung: string
let unsicher = false
if (kandidaten.length === 0) {
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
unsicher = true
} else if (hinweis.istSerie && hinweis.folgen >= 2) {
const folgen = kandidaten.slice(0, hinweis.folgen)
for (const t of folgen) gewaehlt.add(t.nr)
const laengste = folgen[0].dauerS
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
if (folgen.length < hinweis.folgen) {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
unsicher = true
} else if (!passen) {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
unsicher = true
} else {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
}
} else {
const laengster = kandidaten[0]
const gleichauf = kandidaten.filter(
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
)
if (gleichauf.length > 1) {
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0]
gewaehlt.add(beste.nr)
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.`
unsicher = true
} else {
gewaehlt.add(laengster.nr)
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
}
}
const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
const titel: TitelVorschlag[] = alle.map((t) => ({
...t,
gewaehlt: gewaehlt.has(t.nr),
// Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
// Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als
// Extra abgelegt, nicht als zweiten Hauptfilm.
rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra',
grund: gewaehlt.has(t.nr)
? alsSerie
? 'Folge'
: 'Hauptinhalt'
: sammel.has(t.nr)
? 'Sammeltitel — enthält vermutlich alles am Stück'
: t.dauerS < KURZ_S
? 'zu kurz — Logo, Warnung oder Menü'
: 'Zugabe',
}))
return { titel, einordnung, unsicher }
}
+144 -4
View File
@@ -1,18 +1,53 @@
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für // Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch, // Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der // dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus // Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las
// einer Konfigurationsdatei, die niemand schrieb. // aus einer Konfigurationsdatei, die niemand schrieb.
// //
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im // node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) — // utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
// keine native Kompilierung, kein node-gyp in der Bau-Kette. // keine native Kompilierung, kein node-gyp in der Bau-Kette.
//
// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach
// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und
// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen),
// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch
// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz.
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs' import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten' import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
export const SCHEMA_VERSION = 3 export const SCHEMA_VERSION = 4
/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */
export interface Vorgang {
/** Der Vorgangsname — zugleich der Name des Roh-Ordners. */
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
rohBytes: number
rohGeloeschtAm: string | null
zielOrdner: string
fertigBytes: number
/** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */
komprimiert: boolean
preset: string
fehler: string
/** Der Kompressions-Auftrag als JSON Grundlage für ab Kompression
* wiederholen" (kern/ablauf/auftrag.ts). */
auftrag: string
lesefehler: boolean
/** Die fertigen Dateien als JSON-Liste das Aufräumen prüft, dass sie
* wirklich da sind, BEVOR es den Roh-Ordner löscht. */
fertigeDateien: string
}
export class Datenbank { export class Datenbank {
readonly pfad: string readonly pfad: string
@@ -48,6 +83,27 @@ export class Datenbank {
datum TEXT NOT NULL, datum TEXT NOT NULL,
dauer_s INTEGER NOT NULL, dauer_s INTEGER NOT NULL,
poster_pfad TEXT NOT NULL DEFAULT '' poster_pfad TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS vorgaenge (
id TEXT PRIMARY KEY,
laufwerk TEXT NOT NULL,
typ TEXT NOT NULL,
titel TEXT NOT NULL,
jahr INTEGER,
phase TEXT NOT NULL,
begonnen TEXT NOT NULL,
beendet TEXT,
roh_ordner TEXT NOT NULL,
roh_bytes INTEGER NOT NULL DEFAULT 0,
roh_geloescht_am TEXT,
ziel_ordner TEXT NOT NULL DEFAULT '',
fertig_bytes INTEGER NOT NULL DEFAULT 0,
komprimiert INTEGER NOT NULL DEFAULT 0,
preset TEXT NOT NULL DEFAULT '',
fehler TEXT NOT NULL DEFAULT '',
auftrag TEXT NOT NULL DEFAULT '{}',
lesefehler INTEGER NOT NULL DEFAULT 0,
fertige_dateien TEXT NOT NULL DEFAULT '[]'
);`, );`,
) )
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne // Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
@@ -129,7 +185,91 @@ export class Datenbank {
return zeile === undefined ? null : zeile.datum return zeile === undefined ? null : zeile.datum
} }
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
vorgangAnlegen(v: Vorgang): void {
this.db
.prepare(
`INSERT OR REPLACE INTO vorgaenge
(id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes,
roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag,
lesefehler, fertige_dateien)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
v.id,
v.laufwerk,
v.typ,
v.titel,
v.jahr,
v.phase,
v.begonnen,
v.beendet,
v.rohOrdner,
v.rohBytes,
v.rohGeloeschtAm,
v.zielOrdner,
v.fertigBytes,
v.komprimiert ? 1 : 0,
v.preset,
v.fehler,
v.auftrag,
v.lesefehler ? 1 : 0,
v.fertigeDateien,
)
}
/** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */
vorgangAktualisieren(id: string, teil: Partial<Omit<Vorgang, 'id'>>): void {
const alt = this.vorgangLesen(id)
if (alt === null) return
this.vorgangAnlegen({ ...alt, ...teil, id })
}
vorgangLesen(id: string): Vorgang | null {
const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as
| Record<string, unknown>
| undefined
return zeile === undefined ? null : vorgangAusZeile(zeile)
}
/** Alle Vorgänge, neueste zuerst. */
vorgangListe(): Vorgang[] {
const zeilen = this.db
.prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`)
.all() as Array<Record<string, unknown>>
return zeilen.map(vorgangAusZeile)
}
vorgangLoeschen(id: string): void {
this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id)
}
schliessen(): void { schliessen(): void {
this.db.close() this.db.close()
} }
} }
function vorgangAusZeile(z: Record<string, unknown>): Vorgang {
return {
id: String(z.id),
laufwerk: String(z.laufwerk),
typ: String(z.typ),
titel: String(z.titel),
jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr),
phase: String(z.phase) as VorgangPhase,
begonnen: String(z.begonnen),
beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet),
rohOrdner: String(z.roh_ordner),
rohBytes: Number(z.roh_bytes ?? 0),
rohGeloeschtAm:
z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am),
zielOrdner: String(z.ziel_ordner ?? ''),
fertigBytes: Number(z.fertig_bytes ?? 0),
komprimiert: Number(z.komprimiert ?? 0) === 1,
preset: String(z.preset ?? ''),
fehler: String(z.fehler ?? ''),
auftrag: String(z.auftrag ?? '{}'),
lesefehler: Number(z.lesefehler ?? 0) === 1,
fertigeDateien: String(z.fertige_dateien ?? '[]'),
}
}
@@ -0,0 +1,37 @@
// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht
// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte
// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene
// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment
// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm
// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake
// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts.
//
// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12,
// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären
// Pfade mit Leerzeichen wieder eine Falle.
import { spawn } from 'node:child_process'
export function istSkript(programm: string): boolean {
const p = programm.toLowerCase()
return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js')
}
/** Programm + Argumente, so wie spawn sie bekommen soll. */
export function werkzeugStart(
programm: string,
args: readonly string[],
umgebung: NodeJS.ProcessEnv = process.env,
): { programm: string; args: string[]; env: NodeJS.ProcessEnv } {
if (!istSkript(programm)) return { programm, args: [...args], env: umgebung }
return {
programm: process.execPath,
args: [programm, ...args],
env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' },
}
}
/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */
export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record<string, unknown>) => {
const start = werkzeugStart(programm, args)
return spawn(start.programm, start.args, { ...optionen, env: start.env })
}) as typeof spawn
@@ -0,0 +1,78 @@
// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der
// Roh-Datei landen, die der Auswahl-Dialog anbietet.
//
// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in
// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku
// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D)
// nennt als Regel des Standard-Profils
//
// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang
//
// mit: favlang = „matches favorite languages, always matches if no
// favorite language is set" · havemulti = „track is mono/stereo and there
// is a multi-channel track in same language" · havecore = „this track is
// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht.
//
// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles
// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs
// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton
// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei
// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig
// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg).
import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString'
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
* 'rippy' (Rippys Regel steht schon) | 'fehler' */
stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler'
regel: string
text: string
}
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
}
/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar):
* app_DefaultSelectionString REG_SZ -sel:all," */
export function regelAusQuery(ausgabe: string): string | null {
const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe)
return treffer === null ? null : treffer[1].trim()
}
/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */
export function spurauswahlSicherstellen(): SpurauswahlStand {
if (process.platform !== 'win32') {
return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' }
}
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
if (bisher !== null && bisher.length > 0) {
if (bisher === RIPPY_REGEL) {
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
}
return {
stand: 'eigen',
regel: bisher,
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
}
}
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
if (!gesetzt.ok) {
return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` }
}
return {
stand: 'gesetzt',
regel: RIPPY_REGEL,
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
}
}
+349
View File
@@ -0,0 +1,349 @@
// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit
// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die
// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung'
const hb = vi.hoisted(() => ({
aufrufe: [] as Array<Record<string, unknown>>,
ergebnis: 'success' as 'success' | 'error' | 'cancelled',
}))
vi.mock('../src/kern/komprimieren/handbrake', async () => {
const fs = await import('node:fs')
return {
komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => {
hb.aufrufe.push({ ...o })
o.aufFortschritt?.(50)
if (hb.ergebnis === 'cancelled') return { status: 'cancelled' }
if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' }
fs.writeFileSync(o.ausgabe, 'encoded')
return { status: 'success', ausgabe: o.ausgabe }
}),
}
})
import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen'
let wegwerf: string[] = []
afterEach(() => {
hb.aufrufe = []
hb.ergebnis = 'success'
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung {
return {
titel,
jahr,
confidence: 0.95,
meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 },
}
}
function aufbau(dateinamen: string[], rollen: Array<RohDatei['rolle']> = []): {
ablage: string
roh: string
dateien: RohDatei[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-'))
wegwerf.push(ablage)
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
mkdirSync(roh, { recursive: true })
const dateien = dateinamen.map((name, i) => {
const pfad = join(roh, name)
writeFileSync(pfad, 'roh'.repeat(10))
return {
pfad,
titelNr: i,
rolle: rollen[i] ?? 'hauptfilm',
dauerS: 3167,
groesseBytes: 30,
audioSprachen: [],
untertitelSprachen: [],
}
})
return { ablage, roh, dateien }
}
function auftrag(teil: Partial<KompressionsAuftrag> & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag {
return {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: 'LABEL|33',
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
...teil,
}
}
function umgebung(ablage: string, überschreiben: Partial<AblageUmgebung> = {}): AblageUmgebung {
return {
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
ablage: () => ablage,
einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }),
jetzt: () => new Date('2026-08-30T13:00:00Z'),
spurenScannen: async () => null,
...überschreiben,
}
}
const KEIN_ABBRUCH = new AbortController().signal
describe('komprimierenUndAblegen', () => {
it('komprimiert in Filme/<Titel (Jahr)> mit NFO und meldet die Phasen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const bibliothek: string[] = []
const phasen: string[] = []
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }),
(s) => phasen.push(s.phase),
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(true)
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')])
expect(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira'])
expect(ergebnis.text).toContain('komprimiert nach')
})
it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(false)
expect(ergebnis.text).toContain('ohne Kompression')
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0)
})
it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { handbrake: () => '' }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.komprimiert).toBe(false)
expect(readdirSync(roh)).toEqual([])
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00'))
})
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
hb.ergebnis = 'error'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fehler')
expect(ergebnis.text).toContain('bleibt stehen')
expect(ergebnis.text).toContain(roh)
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => {
hb.ergebnis = 'cancelled'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('abgebrochen')
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].audioSprachen = ['jpn']
dateien[0].untertitelSprachen = ['eng', 'deu']
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fertig')
expect(hb.aufrufe).toHaveLength(2)
// Datei 0: eigene Wahl — deu nach vorn, Vorwahl an.
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
// Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl.
expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu'])
expect(hb.aufrufe[1].untertitelStandard).toBe(false)
})
it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
dateien[0].audioSprachen = ['jpn']
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, {
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei')
})
it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({
rohOrdner: roh,
dateien,
zuordnung: zuordnung('tv', 'Spartacus', 2010),
staffel: 1,
ersteFolge: 3,
}),
umgebung(ablage, {
// Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber
// die Hand des Nutzers gilt.
episodenLaufzeiten: async () => [
[3, 3200],
[4, 2000],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01'))
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S01E03.mkv',
'Spartacus S01E04.mkv',
])
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min')
expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true)
})
it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[0].dauerS = 3167
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }),
umgebung(ablage, {
episodenLaufzeiten: async () => [
[1, 3180],
[2, 3240],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S02E01.mkv',
'Spartacus S02E02.mkv',
])
})
it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.mkv'))).toBe(true)
})
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ziel = join(ablage, 'Filme', 'Akira (1988)')
mkdirSync(ziel, { recursive: true })
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.zielOrdner).toBe(ziel)
expect(ergebnis.preset).toBe('Fast 1080p30')
expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)'])
})
it('der Fortschritt ist über beide Dateien gewichtet', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].groesseBytes = 3
dateien[1].groesseBytes = 1
const prozente: number[] = []
await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH)
// 50 % der ersten (3/4) = 37, 50 % der zweiten = 87.
expect(prozente).toContain(37)
expect(prozente).toContain(87)
})
})
describe('Wiederholen ersetzt die alte Fassung', () => {
it('Serie: die alten S01E03/E04 fallen erst nach dem Encode, dann greift die Umbenennung', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
const ziel = join(ablage, 'Serien', 'Spartacus', 'Season 01')
mkdirSync(ziel, { recursive: true })
const alt3 = join(ziel, 'Spartacus S01E03.mkv')
const alt4 = join(ziel, 'Spartacus S01E04.mkv')
writeFileSync(alt3, 'alt')
writeFileSync(alt4, 'alt')
const ergebnis = await komprimierenUndAblegen(
auftrag({
rohOrdner: roh,
dateien,
zuordnung: zuordnung('tv', 'Spartacus', 2010),
staffel: 1,
ersteFolge: 3,
zielOrdner: ziel,
ersetzt: [alt3, alt4],
}),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(readdirSync(ziel).filter((n) => n.endsWith('.mkv')).sort()).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
expect(readFileSync(alt3, 'utf8')).toBe('encoded')
expect(ergebnis.hinweise.join(' ')).toContain('Alte Fassung ersetzt')
expect(ergebnis.hinweise.join(' ')).not.toContain('existiert schon')
})
it('scheitert der zweite Lauf, bleibt die alte Fassung stehen', async () => {
hb.ergebnis = 'error'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ziel = join(ablage, 'Filme', 'Akira (1988)')
mkdirSync(ziel, { recursive: true })
const alt = join(ziel, 'title_t00.mkv')
writeFileSync(alt, 'alt')
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, ersetzt: [alt] }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fehler')
expect(readFileSync(alt, 'utf8')).toBe('alt')
})
})
+31
View File
@@ -0,0 +1,31 @@
// Die Werkzeug-Naht (kern/werkzeuge/aufruf.ts, 5.4.0): echte EXE-Pfade
// gehen unverändert durch; Node-Skripte (die Nachbauten des Klick-Beweises)
// laufen über die eigene Laufzeit mit ELECTRON_RUN_AS_NODE.
import { describe, expect, it } from 'vitest'
import { istSkript, werkzeugStart } from '../src/kern/werkzeuge/aufruf'
describe('werkzeugStart', () => {
it('eine EXE bleibt eine EXE — Argumente und Umgebung unverändert', () => {
const start = werkzeugStart('C:\\Programme\\MakeMKV\\makemkvcon.exe', ['-r', 'info', 'dev:G:'], { PATH: 'x' })
expect(start.programm).toBe('C:\\Programme\\MakeMKV\\makemkvcon.exe')
expect(start.args).toEqual(['-r', 'info', 'dev:G:'])
expect(start.env).toEqual({ PATH: 'x' })
})
it('ein Skript läuft über die eigene Laufzeit, das Skript wird erstes Argument', () => {
const start = werkzeugStart('F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', ['-r', 'info'], { PATH: 'x' })
expect(start.programm).toBe(process.execPath)
expect(start.args).toEqual(['F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', '-r', 'info'])
expect(start.env['ELECTRON_RUN_AS_NODE']).toBe('1')
expect(start.env['PATH']).toBe('x')
})
it('istSkript kennt .cjs/.mjs/.js — und sonst nichts', () => {
expect(istSkript('a.cjs')).toBe(true)
expect(istSkript('A.MJS')).toBe(true)
expect(istSkript('a.js')).toBe(true)
expect(istSkript('HandBrakeCLI.exe')).toBe(false)
expect(istSkript('a.json')).toBe(false)
expect(istSkript('')).toBe(false)
})
})
+139
View File
@@ -0,0 +1,139 @@
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
import { describe, expect, it } from 'vitest'
import {
auftragAusJson,
auftragZuJson,
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from '../src/kern/ablauf/auftrag'
describe('sprachwahl — die Regel des Commanders', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
audio: ['deu', 'eng'],
untertitel: ['deu'],
untertitelStandard: false,
})
})
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
})
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['eng'],
untertitelStandard: false,
})
})
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
})
})
describe('folgenNummern und folgenPlausibel', () => {
it('fortlaufend ab der ersten Folge', () => {
expect(folgenNummern(2, 3)).toEqual([3, 4])
expect(folgenNummern(0, 3)).toEqual([])
expect(folgenNummern(2, 0)).toEqual([1, 2])
})
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
const laufzeiten: Array<[number, number]> = [
[3, 3200],
[4, 2000],
]
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
expect(w).toHaveLength(1)
expect(w[0]).toContain('Folge 4')
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
})
})
describe('gewichteterFortschritt — nie zurück auf null', () => {
it('gewichtet nach Größe', () => {
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
})
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
})
})
describe('Auftrag als JSON — und wieder zurück', () => {
const a: KompressionsAuftrag = {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
dateien: [
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
],
zuordnung: null,
staffel: 1,
ersteFolge: 3,
discNr: 2,
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
lesefehler: false,
hinweise: ['Übersprungen: Titel 5.'],
begonnen: '2026-08-30T12:00:00.000Z',
preset: '',
zielOrdner: '',
ersetzt: [],
}
it('kommt unverändert zurück', () => {
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
})
it('Unsinn ist null, kein Absturz', () => {
expect(auftragAusJson('{')).toBeNull()
expect(auftragAusJson('{}')).toBeNull()
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
})
it('fehlende Felder bekommen Vorgaben', () => {
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
expect(b.dateien[0].rolle).toBe('hauptfilm')
expect(b.dateien[0].audioSprachen).toEqual([])
expect(b.staffel).toBeNull()
expect(b.preset).toBe('')
})
})
describe('sprachwahl — Untertitel-Vorwahl je Extra (5.6.0, immer optional)', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('eine gesetzte Vorwahl schlägt die Regel: nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['deu'], untertitelSprachen: ['eng', 'deu'], untertitelVorwahl: 'deu' }, global)).toEqual({
audio: ['deu'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('null heißt ausdrücklich keine Vorwahl — auch bei fremdem Ton', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'], untertitelVorwahl: null }, global).untertitelStandard).toBe(false)
})
it('fehlend heißt Regel wie bisher', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(true)
})
it('eine Vorwahl, die nicht in der Liste steht, wird ergänzt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'], untertitelVorwahl: 'deu' }, global).untertitel).toEqual(['deu', 'eng'])
})
})
+210
View File
@@ -0,0 +1,210 @@
// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag
// als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf
// mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur
// sagen, warum; Stopp und Jetzt.
import { describe, expect, it } from 'vitest'
import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik'
import type { KernKontext } from '../src/kern/kontext'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } }
}
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler }
}
function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo {
return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' }
}
function zuordnung(confidence: number): Zuordnung {
return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never }
}
describe('auftragAusVorschlag', () => {
it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => {
const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')]))
expect(auftrag).toEqual<RipAuftragTitel[]>([
{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 },
{ nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 },
])
})
})
describe('optionenAus', () => {
it('ohne alles: nichts vorgeben', () => {
expect(optionenAus(null, null)).toEqual<RipAuftragOptionen>({ staffel: null, ersteFolge: null })
})
it('die Staffel der Disc schlägt den Merker', () => {
expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 })
})
it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => {
expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null })
})
it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => {
expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 })
expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 })
})
})
function aufbau(sekunden = 3) {
const nachrichten: AutomatikStand[] = []
const hinweise: string[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => {
if (n.art === 'automatik') nachrichten.push(n.stand)
},
anHauptUndFenster: () => {},
hinweis: (t: string) => hinweise.push(t),
fehler: () => {},
protokoll: { schreiben: () => {} } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = []
const benachrichtigt: string[] = []
let tick: (() => void) | null = null
let gestoppt = 0
let an = true
let ripLaeuft = false
let titelStand: TitelStand = stand([titel(0, true), titel(1, false)])
let angaben: { staffel: number | null; discNr: number | null } | null = null
let merker: { staffel: number; naechsteFolge: number } | null = null
const abh: AutomatikAbhaengigkeiten = {
an: () => an,
sekunden: () => sekunden,
ripLaeuft: () => ripLaeuft,
titelLesen: async () => titelStand,
ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }),
discAngaben: () => angaben,
serienMerker: () => merker,
benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`),
takt: (fn) => {
tick = fn
return 1 as unknown as NodeJS.Timeout
},
taktStoppen: () => {
gestoppt += 1
tick = null
},
}
const dienst = new AutomatikDienst(kontext, abh)
return {
dienst,
nachrichten,
hinweise,
gestartet,
benachrichtigt,
tick: () => {
if (tick === null) throw new Error('kein Takt')
tick()
},
gestopptZahl: () => gestoppt,
setAn: (w: boolean) => (an = w),
setRipLaeuft: (w: boolean) => (ripLaeuft = w),
setTitel: (s: TitelStand) => (titelStand = s),
setAngaben: (a: typeof angaben) => (angaben = a),
setMerker: (m: typeof merker) => (merker = m),
}
}
describe('AutomatikDienst', () => {
it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => {
const a = aufbau(3)
await a.dienst.pruefen(geraet(), zuordnung(0.95), null)
expect(a.nachrichten[0].text).toContain('liest die Titel')
expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 })
a.tick()
a.tick()
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 })
expect(a.gestartet).toHaveLength(0)
a.tick()
expect(a.gestartet).toHaveLength(1)
expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }])
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' })
expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet')
expect(a.gestopptZahl()).toBe(1)
})
it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null)
expect(a.nachrichten).toHaveLength(1)
expect(a.nachrichten[0]).toMatchObject({ aktiv: false })
expect(a.nachrichten[0].text).toContain('nicht sicher genug')
expect(a.gestartet).toHaveLength(0)
})
it('schon gerippt: wartet und nennt das Datum', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z')
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
expect(a.gestartet).toHaveLength(0)
})
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
const a = aufbau()
a.setAn(false)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.setAn(true)
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
a.setRipLaeuft(true)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(0)
})
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
const a = aufbau()
a.setTitel(stand([titel(0, true), titel(1, false)], true))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen')
expect(a.gestartet).toHaveLength(0)
})
it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => {
const a = aufbau(30)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' })
expect(() => a.tick()).toThrow('kein Takt')
// Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand.
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(0)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)]))
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(1)
})
it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => {
const a = aufbau(1)
a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')]))
a.setAngaben({ staffel: null, discNr: 2 })
a.setMerker({ staffel: 1, naechsteFolge: 3 })
a.dienst.zuordnungMerken('G', zuordnung(0.99))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.tick()
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
})
it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.leeren('G')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' })
// Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts.
const b = aufbau(5)
b.dienst.leeren('G')
expect(b.nachrichten).toHaveLength(0)
})
it('Disc raus: Countdown weg, Stand weg', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.dienst.stand('G')).not.toBeNull()
a.dienst.discEntnommen('G')
expect(a.dienst.stand('G')).toBeNull()
expect(a.gestopptZahl()).toBe(1)
})
})
+112
View File
@@ -0,0 +1,112 @@
// Tests der Benachrichtigung (kern/dienste/benachrichtigung.ts, 5.5.0):
// Was an Discord und Telegram geht (Form laut deren Doku), welche Kanäle
// eingerichtet sind, und dass ein Fehlschlag gesagt wird, aber nichts
// kippt.
import { describe, expect, it } from 'vitest'
import type { KernNachricht } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { BenachrichtigungsDienst, discordAnfrage, kanaeleAus, kanaeleEingerichtet, telegramAnfrage } from '../src/kern/dienste/benachrichtigung'
import type { EinstellungsDienst } from '../src/kern/dienste/einstellungen'
import type { KernKontext } from '../src/kern/kontext'
function fakeKontext() {
const fehler: string[] = []
const hinweise: string[] = []
const zeilen: string[] = []
const nachrichten: KernNachricht[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => nachrichten.push(n),
anHauptUndFenster: (n: KernNachricht) => nachrichten.push(n),
hinweis: (t: string) => hinweise.push(t),
fehler: (t: string) => fehler.push(t),
protokoll: { schreiben: (t: string) => zeilen.push(t) } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
return { kontext, fehler, hinweise, zeilen, nachrichten }
}
function fakeEinstellungen(werte: Record<string, string>): EinstellungsDienst {
return { lesen: (s: string, vorgabe = '') => werte[s] ?? vorgabe } as unknown as EinstellungsDienst
}
describe('discordAnfrage / telegramAnfrage', () => {
it('Discord: POST an den Webhook mit { content, username } — Titel fett, dann Text', () => {
const a = discordAnfrage('https://discord.com/api/webhooks/1/abc', 'Rip fertig', 'Akira (1988)')
expect(a.url).toBe('https://discord.com/api/webhooks/1/abc')
const body = JSON.parse(a.body) as { content: string; username: string }
expect(body.content).toBe('**Rip fertig**\nAkira (1988)')
expect(body.username).toBe('Rippy')
})
it('Discord: content bleibt unter 2000 Zeichen', () => {
const a = discordAnfrage('https://x', 'T', 'x'.repeat(5000))
expect((JSON.parse(a.body) as { content: string }).content.length).toBeLessThanOrEqual(2000)
})
it('Telegram: bot<TOKEN>/sendMessage mit { chat_id, text }', () => {
const a = telegramAnfrage('123:ABC', '42', 'Rip fertig', 'Akira')
expect(a.url).toBe('https://api.telegram.org/bot123:ABC/sendMessage')
expect(JSON.parse(a.body)).toEqual({ chat_id: '42', text: 'Rip fertig\nAkira' })
})
})
describe('kanaeleAus / kanaeleEingerichtet', () => {
it('trimmt und erkennt: Discord allein reicht, Telegram nur mit Token UND Chat-ID', () => {
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'discordWebhook' ? ' https://d ' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : '')))).toBe(false)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : s === 'telegramChatId' ? '1' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus(() => ''))).toBe(false)
})
})
describe('BenachrichtigungsDienst', () => {
it('schickt über jeden eingerichteten Kanal und schreibt ins Protokoll', async () => {
const { kontext, fehler, zeilen } = fakeKontext()
const aufrufe: Array<{ url: string; body: string }> = []
const holen = (async (url: string | URL | Request, init?: RequestInit) => {
aufrufe.push({ url: String(url), body: String(init?.body) })
return { status: 204 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d', telegramToken: 't', telegramChatId: '9' }), holen)
expect(await d.senden('Titel', 'Text')).toEqual([])
expect(aufrufe.map((a) => a.url)).toEqual(['https://d', 'https://api.telegram.org/bott/sendMessage'])
expect(fehler).toEqual([])
expect(zeilen[0]).toContain('Benachrichtigt (Discord, Telegram): Titel')
})
it('ein HTTP-Fehler wird gesagt (kern-fehler), aber nicht geworfen', async () => {
const { kontext, fehler } = fakeKontext()
const holen = (async () => ({ status: 404 })) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: HTTP 404'])
expect(fehler).toEqual(['Benachrichtigung Discord: HTTP 404'])
})
it('ein Netzfehler ebenso', async () => {
const { kontext } = fakeKontext()
const holen = (async () => {
throw new Error('fetch failed')
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: fetch failed'])
})
it('ausgeschaltet: nichts geht raus — außer erzwungen (Testnachricht)', async () => {
const { kontext } = fakeKontext()
let aufrufe = 0
const holen = (async () => {
aufrufe += 1
return { status: 200 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ benachrichtigungen: 'aus', discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual([])
expect(aufrufe).toBe(0)
expect(await d.senden('T', 'x', true)).toEqual([])
expect(aufrufe).toBe(1)
})
it('testen ohne Kanal: sagt, was fehlt; mit Kanal: „Testnachricht verschickt."', async () => {
const ohne = fakeKontext()
await new BenachrichtigungsDienst(ohne.kontext, fakeEinstellungen({}), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(ohne.fehler[0]).toContain('kein Kanal eingerichtet')
const mit = fakeKontext()
await new BenachrichtigungsDienst(mit.kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(mit.hinweise).toEqual(['Testnachricht verschickt.'])
})
})
+42
View File
@@ -0,0 +1,42 @@
// Tests der Dateinamen in der Ablage (kern/ablage/dateinamen.ts, 5.6.0).
import { describe, expect, it } from 'vitest'
import { rollenNummern, zielDateiname } from '../src/kern/ablage/dateinamen'
describe('zielDateiname', () => {
const film = { titel: 'Akira', jahr: 1988, alsSerie: false }
it('ein Hauptfilm heißt wie der Film', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('Akira (1988).mkv')
})
it('zwei Hauptfilme werden Fassungen', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 2, original: 'title_t00.mkv' })).toBe('Akira (1988) - Fassung 1.mkv')
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 2, anzahl: 2, original: 'title_t01.mkv' })).toBe('Akira (1988) - Fassung 2.mkv')
})
it('Extras werden nummeriert', () => {
expect(zielDateiname({ ...film, rolle: 'extra', nummer: 3, anzahl: 5, original: 'title_t07.mkv' })).toBe('Akira (1988) - Extra 03.mkv')
})
it('die Endung bleibt, was MakeMKV schrieb', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'x.mp4' })).toBe('Akira (1988).mp4')
})
it('ohne Jahr ohne Klammer', () => {
expect(zielDateiname({ ...film, jahr: null, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'a.mkv' })).toBe('Akira.mkv')
})
it('Serie: Folgen behalten den Namen (die Episoden-Zuordnung benennt später), Extras heißen nach der Serie', () => {
const serie = { titel: 'Spartacus', jahr: 2010, alsSerie: true }
expect(zielDateiname({ ...serie, rolle: 'folge', nummer: 1, anzahl: 4, original: 'title_t02.mkv' })).toBe('title_t02.mkv')
expect(zielDateiname({ ...serie, rolle: 'extra', nummer: 1, anzahl: 1, original: 'title_t09.mkv' })).toBe('Spartacus - Extra 01.mkv')
})
it('ohne erkannten Titel bleibt der MakeMKV-Name', () => {
expect(zielDateiname({ titel: '', jahr: null, alsSerie: false, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('title_t00.mkv')
})
})
describe('rollenNummern', () => {
it('zählt je Rolle in Reihenfolge und kennt die Gesamtzahl', () => {
expect(rollenNummern(['hauptfilm', 'extra', 'extra', 'hauptfilm'])).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
])
})
})
+56
View File
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
expect(() => new Datenbank(kaputt)).toThrow() expect(() => new Datenbank(kaputt)).toThrow()
}) })
}) })
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
describe('Datenbank — Vorgänge', () => {
const vorgang = {
id: 'bluray-2026-09-01T20-00-00',
laufwerk: 'G',
typ: 'bluray',
titel: 'Spartacus',
jahr: 2010,
phase: 'rippt' as const,
begonnen: '2026-09-01T20:00:00.000Z',
beendet: null,
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.vorgangAnlegen(vorgang)
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
const neu = db.vorgangLesen(vorgang.id)!
expect(neu.phase).toBe('fertig')
expect(neu.komprimiert).toBe(true)
expect(neu.fertigBytes).toBe(4800)
expect(neu.titel).toBe('Spartacus')
expect(neu.rohGeloeschtAm).toBeNull()
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
db.vorgangLoeschen(vorgang.id)
expect(db.vorgangLesen(vorgang.id)).toBeNull()
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
})
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
const pfad = frischerPfad()
const alt = new Datenbank(pfad)
alt.einstellungSchreiben('ablage', 'E:\Rippy')
alt.schliessen()
const neu = new Datenbank(pfad)
aufraeumen.push(() => neu.schliessen())
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
expect(neu.vorgangListe()).toEqual([])
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
// der USB-Aussetzer).
import { describe, expect, it } from 'vitest'
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
const XML =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
describe('ereignisseAusXml', () => {
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
const e = ereignisseAusXml(XML)
expect(e).toHaveLength(3)
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
expect(e[2].id).toBe(11)
})
it('leere Ausgabe = keine Ereignisse', () => {
expect(ereignisseAusXml('')).toEqual([])
expect(ereignisseAusXml('\r\n')).toEqual([])
})
})
describe('beurteilen — Disc oder Hardware?', () => {
const alle = ereignisseAusXml(XML)
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
const b = beurteilen(alle, 24)
expect(b.urteil).toBe('hardware')
expect(b.controllerfehler).toBe(1)
expect(b.fehlerhafteBloecke).toBe(2)
expect(b.text).toContain('Mainboard')
expect(b.letzte).toHaveLength(3)
expect(b.letzte[0]).toContain('fehlerhafter Block')
})
it('nur fehlerhafte Blöcke: die Disc', () => {
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
expect(b.urteil).toBe('disc')
expect(b.text).toContain('DISC')
})
it('nichts: ruhig', () => {
const b = beurteilen([], 24)
expect(b.urteil).toBe('ruhig')
expect(b.text).toContain('nichts')
})
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
expect(b.controllerfehler).toBe(0)
expect(b.urteil).toBe('disc')
})
})
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
const args = buildWevtutilArgs(24, 50)
expect(args[0]).toBe('qe')
expect(args[1]).toBe('System')
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
expect(args).toContain('/f:xml')
expect(args).toContain('/c:50')
expect(args).toContain('/rd:true')
})
})
+62
View File
@@ -0,0 +1,62 @@
// Tests der Fensterlage (haupt/fensterlage.ts, 5.5.0): 80 % des
// Arbeitsbereichs, mittig, Mindestmaß; gemerkte Lage nur, wenn sie noch auf
// einem Bildschirm liegt; Lesen/Schreiben der Datei.
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { anfangsLage, lageLesen, lageSchreiben, liegtSichtbar, startLage } from '../src/haupt/fensterlage'
const ordner = mkdtempSync(join(tmpdir(), 'rippy-lage-'))
afterAll(() => rmSync(ordner, { recursive: true, force: true }))
describe('startLage', () => {
it('Full HD ohne Taskleiste: 80 % und mittig', () => {
expect(startLage({ x: 0, y: 0, width: 1920, height: 1040 })).toEqual({ x: 192, y: 104, width: 1536, height: 832, maximiert: false })
})
it('kleiner Bildschirm: Mindestmaß 1100 × 720, aber nie größer als der Bildschirm', () => {
expect(startLage({ x: 0, y: 0, width: 1280, height: 720 })).toEqual({ x: 90, y: 0, width: 1100, height: 720, maximiert: false })
expect(startLage({ x: 0, y: 0, width: 1024, height: 600 })).toEqual({ x: 0, y: 0, width: 1024, height: 600, maximiert: false })
})
it('Zweitmonitor rechts: der Versatz des Arbeitsbereichs zählt mit', () => {
const l = startLage({ x: 1920, y: 0, width: 2560, height: 1400 })
expect(l.x).toBe(1920 + 256)
expect(l.width).toBe(2048)
})
})
describe('liegtSichtbar / anfangsLage', () => {
const bildschirme = [
{ x: 0, y: 0, width: 1920, height: 1080 },
{ x: 1920, y: 0, width: 2560, height: 1440 },
]
it('auf einem Bildschirm: ja; im Nirgendwo eines abgestöpselten Monitors: nein', () => {
expect(liegtSichtbar({ x: 100, y: 100, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
expect(liegtSichtbar({ x: 2500, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
expect(liegtSichtbar({ x: -3000, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(false)
expect(liegtSichtbar({ x: 100, y: 100, width: 300, height: 200, maximiert: false }, bildschirme)).toBe(false)
})
it('anfangsLage: gemerkt, wenn sichtbar — sonst Start', () => {
const arbeit = { x: 0, y: 0, width: 1920, height: 1040 }
const gemerkt = { x: 2000, y: 50, width: 1400, height: 900, maximiert: true }
expect(anfangsLage(gemerkt, arbeit, bildschirme)).toBe(gemerkt)
expect(anfangsLage({ ...gemerkt, x: 9000 }, arbeit, bildschirme)).toEqual(startLage(arbeit))
expect(anfangsLage(null, arbeit, bildschirme)).toEqual(startLage(arbeit))
})
})
describe('lageLesen / lageSchreiben', () => {
it('Runde durch die Datei', () => {
const pfad = join(ordner, 'fenster-lage.json')
lageSchreiben(pfad, { x: 10, y: 20, width: 1300, height: 850, maximiert: true })
expect(lageLesen(pfad)).toEqual({ x: 10, y: 20, width: 1300, height: 850, maximiert: true })
})
it('fehlende oder kaputte Datei heißt Startlage (null)', () => {
expect(lageLesen(join(ordner, 'gibt-es-nicht.json'))).toBeNull()
const kaputt = join(ordner, 'kaputt.json')
writeFileSync(kaputt, '{"x": "zehn"}')
expect(lageLesen(kaputt)).toBeNull()
writeFileSync(kaputt, 'kein json')
expect(lageLesen(kaputt)).toBeNull()
})
})

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