54 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5 d21332e3c7 docs: SAVEPOINT — Einstellungen-Karten und Version 5.1.0
Ampel / ampel (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:10:10 +02:00
HitonabiandClaude Fable 5 b64ddf5f06 feat(v5): Einstellungen als erklärende Karten-Seite — Version 5.1.0
Ampel / ampel (push) Successful in 1m35s
Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine
Sektions-Navigation, rechts Karten mit je einem Satz Einordnung —
Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression
(Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung),
Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV
(eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf,
Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand,
Versionszeile) und Werkzeuge — NEU mit den drei „Eigener
Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher
ohne UI).

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

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

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

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

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

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

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

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

192 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

181 Tests gruen, Smoke gruen.

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

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

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

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

165 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

96 Tests gruen, Smoke gruen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:38:39 +02:00
HitonabiandClaude Opus 5 9050fea3f0 docs: SAVEPOINT v4.0-rc11
Der Schalter, den es nicht gibt — und vierzehn weitere Funde aus demselben
Rundgang. Jeder mit der Messung, an der er haengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:08 +02:00
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00
HitonabiandClaude Opus 5 b6a93aa726 docs: SAVEPOINT v4.0-rc10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:55:44 +02:00
HitonabiandClaude Opus 5 fed24237c9 fix(windows): makemkvcon ueberlebte Rippy und hielt das Laufwerk fest
Commander: „jetzt hast du den makemkvcon kram dir nicht angeguckt".
Stimmt — es stand als Frage im Notizbuch statt als Riegel im Code.

## Gemessen, nicht vermutet

Ein Elternprozess startete makemkvcon, dann wurde er hart beendet
(`taskkill /F` OHNE `/T` — genau das, was beim Dienst-Stopp und beim
Drueber-Installieren passiert):

    ohne Leine   makemkvcon PID 15368   vor dem Kill: True   danach: True
    mit  Leine   makemkvcon PID 43608   vor dem Kill: True   danach: False

Windows raeumt Kindprozesse nicht auf. Eine solche Waise HAELT DAS LAUFWERK —
jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl". Zwei
davon standen waehrend der Messungen auf diesem Rechner.

## Zwei Riegel

* **Die Leine** (`winlauf.kinder_an_die_leine`) — eine Arbeitsgruppe (Job
  Object) mit KILL_ON_JOB_CLOSE. Stirbt Rippy, sterben makemkvcon, HandBrake
  und flac mit. Auch beim Absturz, auch per Taskmanager. Einmal beim
  Dienststart gesetzt, deckt sie JEDEN Werkzeugaufruf ab.
* **Der Aufraeumer** (`winlauf.waisen_beenden`) — beendet beim Start
  Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt. Fuer das, was
  eine aeltere Fassung oder ein Absturz hinterlassen hat. Nur ELTERNLOSE:
  Ein makemkvcon eines laufenden Rippy bleibt unangetastet, `makemkv.exe`
  (die Oberflaeche) steht gar nicht erst auf der Liste.

Am echten Fall nachgestellt: Waise erzeugt, Rippy gestartet, Waise weg.

## Drei Prozesse duerfen NICHT mitsterben

Dienst, Fensterprogramm und das Aufraeum-Skript der Deinstallation loesen sich
ueber `eigenstaendig_starten` heraus (CREATE_BREAKAWAY_FROM_JOB). Mit
Rueckfall ohne die Fahne: Steckt Rippy in einer fremden Arbeitsgruppe ohne
Herausloese-Erlaubnis, verweigert Windows den Start rundweg — ein Fenster, das
gar nicht mehr aufgeht, waere schlimmer als eines, das mitstirbt.

## Die Falle beim Bauen

Der erste Anlauf meldete nur „ging nicht". Ursache: Ohne `argtypes` reicht
ctypes einen Griff als 32-Bit-int weiter. `GetCurrentProcess()` liefert aber
(HANDLE)-1 = 0xFFFFFFFFFFFFFFFF, ctypes wirft `ArgumentError: int too long to
convert`, und das breite `except` verschluckte es. `kernel32()` meldet jetzt
jede Signatur an; ein Test wacht darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:54:44 +02:00
HitonabiandClaude Opus 5 21c626eb4e docs: SAVEPOINT v4.0-rc9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:40:25 +02:00
HitonabiandClaude Opus 5 f0f719ca12 fix(windows): Zweiter Rip startete waehrend der Kompression ins leere Laufwerk
Der Commander meldete einen Rip-Fehlschlag „bei der Komprimierung", obwohl
der Rip laengst durch war:

    makemkvcon endete mit Code 11 — letzte Meldung:
    Das Öffnen der Disk schlug fehl  — keine MKV-Datei entstanden

Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.

Der Ablauf dahinter:
  1. Rip fertig → Rippy wirft die Disc aus (Standardeinstellung)
  2. Status wird auf `transcoding` gesetzt — ab hier sagte `has_active_job`
     NEIN, das Laufwerk sei frei; es kennt nur pending/running
  3. Die Disc-Wache sieht beim Auswurf einen Statuswechsel → „eingelegt"
  4. Die Vollautomatik startet einen ZWEITEN Rip — auf ein Laufwerk, dessen
     Schublade gerade herausfaehrt

Der zweite Rip lief in ein leeres Laufwerk. Auf dem Bildschirm sah das aus,
als sei die Kompression gescheitert — sie lief ungestoert weiter.

Drei Aenderungen:

* `store.job_offen` — der weitere Riegel (pending/running/transcoding/
  canceling) fuer die Vollautomatik. `has_active_job` bleibt unveraendert:
  Das fragt „haelt gerade jemand das Laufwerk?", und waehrend der Kompression
  tut das niemand — ein Auswurf von Hand bleibt erlaubt.
* `ripping.disc_fehlt` — vor dem makemkvcon-Start nachsehen, ob ueberhaupt
  eine Disc drin liegt. Statt zwei Minuten Warten und Code 11 gibt es einen
  Satz, den man versteht. Ein FEHLGESCHLAGENER Blick verweigert nichts:
  „ich weiss es nicht" darf nie zu „es geht nicht" werden.
* MSG 5010 in KRITISCHE_CODES — MakeMKVs Sammelmeldung sagt fuer sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:36:59 +02:00
HitonabiandClaude Opus 5 f6f0a4ccb6 feat(windows): MusicBrainz-Tags fuer Audio-CDs
Ampel / ampel (push) Successful in 1m25s
Commander: „go"

Die Dateien hiessen `Track 01.flac`. Eine Musiksammlung ohne Titel ist eine
Sammlung von Nummern.

## Wie MusicBrainz eine CD wiedererkennt

Nicht am Namen (den kennt die CD nicht) und nicht an einer Kennung auf der
Disc (die gibt es nicht), sondern an der LAGE ihrer Spuren. Daraus wird ein
Fingerabdruck gerechnet — SHA-1 ueber die Versaetze, base64 mit eigenem
Alphabet (musicbrainz.org/doc/Disc_ID_Calculation).

## Belegt OHNE Audio-CD

MusicBrainz gibt zu jeder bekannten Kennung die Spurlage heraus, aus der sie
gerechnet wurde. Wer die Lage zurueckbekommt und daraus dieselbe Kennung
rechnet, hat die Rechnung belegt:

    Spurlage „Back in Black" (abgefragt)  ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-
    MusicBrainz sagt dazu                 ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-

Und die ganze Kette am Stueck, mit echtem Encoder:

    Ordner   ACDC - Back in Black
    Dateien  01 - Hells Bells.flac, 02 - Shoot to Thrill.flac, …
    Tags     TITLE/ARTIST/ALBUM/ALBUMARTIST/DATE/TRACKNUMBER
             — mit metaflac aus der FERTIGEN Datei zurueckgelesen

## Zwei Fallen

**Der Vorlauf.** Die Kennung rechnet MIT den 150 Frames, das Lesen OHNE. Wer
das verwechselt, bekommt eine Kennung, die niemand kennt — und zwar ohne
Fehlermeldung, denn die Antwort ist dann schlicht „unbekannte Disc".

**`AC/DC`.** Als Ordnername haette der Schraegstrich unter Windows einen
Unterordner aufgemacht. Aufgefallen NUR, weil der Beweis den Namen ausgegeben
hat. Jetzt saeubert `sauberer_name` beides — Datei und Ordner.

## ⚠️ Mein Fehler dabei

Die Spurlage im Test hatte ich zuerst ERFUNDEN: Beim Beweis hatte ich nur
Spurzahl und Lead-Out ausgegeben und den Rest „passend" ergaenzt. Der Test
wurde prompt rot — zu Recht. Die Zahlen stehen jetzt so drin, wie MusicBrainz
sie herausgibt (erste Spur bei 182, nicht bei 150: diese Pressung hat einen
laengeren Vorlauf).

Ohne Treffer bleibt es bei `Track 01.flac`. Eine unbekannte Disc ist kein
Fehler, und ein Netzausfall darf keinen Rip umwerfen.

932 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:33:46 +02:00
HitonabiandClaude Opus 5 986fcf19b1 feat(windows): Audio-CDs rippen — die letzte Luecke ist zu
Ampel / ampel (push) Successful in 1m24s
Commander: „mach es"

Gemeint war die Lücke, die seit Wochen so im SAVEPOINT stand: **Audio-CDs
laufen unter Windows nicht.** `cdparanoia` und `abcde` sind Linux-Werkzeuge.

## Nicht abcde nachbauen — den Windows-Weg gehen

Eine Audio-CD hat kein Dateisystem. Die `Track01.cda`, die Windows zeigt,
sind 44 Byte grosse Platzhalter; die Musik liegt roh in 2352-Byte-Sektoren.
Windows bietet dafuer zwei Steuercodes an:

    IOCTL_CDROM_READ_TOC   Inhaltsverzeichnis (MSF je Spur, Audio/Daten)
    IOCTL_CDROM_RAW_READ   die Sektoren selbst

Gegengeprueft, dass die berechneten Codes den dokumentierten entsprechen:
0x24000 und 0x2403e.

Kodiert wird mit dem FLAC-Encoder vom offiziellen Xiph-Spiegel (1.5.0), den
Rippy beim Einrichten holt — wie MakeMKV. KEIN Pflichtwerkzeug: Ohne ihn
laeuft alles ausser Audio-CDs, und ein Fehlschlag darf das Einrichten nicht
truebe machen.

## Zwei Fallen, beide gemessen

**Die Leseadresse zaehlt in 2048er-Einheiten**, obwohl ein Audio-Sektor 2352
Bytes hat. Das ist dokumentiert und sieht falsch aus; mit 2352 liest man an
der falschen Stelle.

**`flac.exe` braucht `libFLAC.dll` daneben.** Mit nur der exe endete jeder
Aufruf mit 0xC0000135 — „DLL nicht gefunden" — und zwar ohne eine einzige
Zeile Ausgabe. Jetzt wird der ganze Win64-Ordner ausgepackt.

## Was geprueft ist

Ein Laufwerk und eine Audio-CD lassen sich in der Ampel nicht herstellen.
Deshalb steht alles Rechenbare in reinen Funktionen — MSF↔LBA, das Zerlegen
der TOC-Bytes, WAV-Kopf, Blockaufteilung, Dateinamen — und der Ablauf
bekommt Laufwerk und Encoder eingespritzt. 28 Tests dafuer.

Zusaetzlich mit dem ECHTEN Encoder gemessen: zwei Spuren erzeugten Tons
gerippt, und **FLAC bestaetigt seine eigenen Dateien** (`flac -t`, Code 0).
Am echten Laufwerk gegengeprueft, dass das Inhaltsverzeichnis gelesen wird —
die eingelegte Blu-ray meldet sich korrekt als DATEN-Track und wird nicht als
Musik behandelt.

Was noch fehlt: MusicBrainz-Tags. Die Dateien heissen `Track 01.flac`. Der
Weg dafuer steht (`tags_je_spur`), die Disc-Kennung fuer die Abfrage nicht.

915 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:26:23 +02:00
HitonabiandClaude Opus 5 1b84ec2a45 fix(windows): Vollstaendiger Rundgang durch die Docker-Reste
Ampel / ampel (push) Successful in 1m20s
Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."

Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.

## 1. `/dev/{name}` in drei Endpunkten — der schwerste

Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.

Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.

## 2. `os.path.isdir("/app")` — zum zweiten Mal

Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.

## 3. `shutil.which` in `schluessel.py`

Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.

## 4. `posixpath.join` auf echten Pfaden

`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.

## 5. Container-Pfad in einer Nutzermeldung

„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.

## 6. Container-Pfade als UI-Vorbelegung

Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.

## Und HandBrakes „Code 0"

Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.

## Der Waechter

`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.

Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.

887 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:14:10 +02:00
HitonabiandClaude Opus 5 1a529f4e75 fix(windows): „Disc wird gelesen" blieb waehrend des ganzen Rips stehen
Ampel / ampel (push) Successful in 1m13s
Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
bereits bei 30% und er liest immernoch."

Genau so war es — und die Erkennung kam auch nie zum Ende.

## Warum

`_auto_prescan` hatte GENAU EINE Absicherung: nicht zweimal gleichzeitig
(`_laeuft`). Ob auf dem Laufwerk gerade ein Rip laeuft, hat es nie gefragt.

Waehrend eines Rips haelt `makemkvcon` das Laufwerk. Ein zweites
`makemkvcon info` daneben wartet, bis es seine Zeitgrenze erreicht (gemessen:
eine Laufwerks-Abfrage braucht dann 14 s statt 5, der `info`-Aufruf laeuft in
seine 120 s). Solange steht die Marke `_laeuft` — und damit das Panel, das
ich heute frueh genau dafuer gebaut habe.

Es gibt keinen Grund, waehrend eines Rips zu scannen: Das Laufwerk ist
belegt, und **welche Disc drin ist, wissen wir bereits** — der Job laeuft ja
auf ihr.

## Zwei Stellen, weil es zwei Wege hinein gibt

1. `_auto_prescan` bricht ab, wenn auf dem Geraet ein Job laeuft. Das
   verhindert jeden Scan, der NACH dem Rip-Start angestossen wird.
2. Der Job-Start raeumt eine haengende Marke weg. Ein Scan, der KURZ VORHER
   begann, haelt sie sonst bis zu seinem Ende fest. Verloren geht dabei
   nichts: Unter `_laeuft` steht nur der Platzhalter, und der laufende Scan
   traegt sein Ergebnis spaeter selbst nach.

880 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:54:48 +02:00
HitonabiandClaude Opus 5 548742e771 fix(windows): Restzeit blieb ewig „wird gemessen" — zwei Docker-Annahmen
Ampel / ampel (push) Successful in 1m32s
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen'
aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"

Zwei Ursachen, beide unabhaengig, beide toedlich fuer sich allein.

## 1. Die Messreihe lag nur in Redis

`eta.py` schrieb sie ausschliesslich in den Cache — mit der Begruendung im
Modul-Kopf: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf
einem Windows-PC gibt es kein Redis: `cache_get` gab bei JEDEM Aufruf None
zurueck, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe mit
EINEM Punkt an — und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.

Jetzt wird in beide Ablagen geschrieben: in den Cache, wo es einen gibt (er
ueberlebt einen API-Neustart), und in ein Woerterbuch im Prozess. Das ist ein
paar Zahlen gross, gilt nur fuer die Dauer eines Jobs, und im eigenstaendigen
Betrieb gibt es ohnehin nur diesen einen Prozess. Alte Reihen werden nach
einem Tag weggeraeumt.

## 2. Der Ereignisstrom rechnete die Restzeit gar nicht

Die Rechnung stand nur in `/jobs`. Der SSE-Schnappschuss baute seine Jobs mit
dem nackten `_job_row_to_model` — also ohne Restzeit. **Seit der Umstellung
auf den Ereignisstrom (V2-3) liest die Oberflaeche aber genau diesen
Schnappschuss und nicht mehr `/jobs`.** Die Restzeit wurde also brav berechnet
und niemandem gezeigt.

Beides jetzt in `jobs_fuer_ui()` — dieselbe Lehre wie bei
`laufwerke_mit_disc` heute frueh: Eine Auskunft in zwei Fassungen ist eine
Fassung zu viel.

Nebenbei: Unlesbare Einstellungen duerfen die Jobliste nicht umwerfen. Seit
sie auch den Schnappschuss baut, haengt daran die ganze Oberflaeche — zwei
Snapshot-Tests wurden davon prompt rot.

## Beweis

Job in der Datenbank, Fortschritt 10 % -> 25 % ueber 130 s:

    nach 1. Messpunkt : eta_text=''            (richtig, eine Messung reicht nicht)
    nach 2. Messpunkt : 674 s, „noch ca. 11 min"

877 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:49:59 +02:00
HitonabiandClaude Opus 5 aeb3128a85 fix(windows): Dauerscan der Disc, aufblitzende Fenster, Docker-Einstellung uebernommen
Ampel / ampel (push) Successful in 1m30s
Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn er
sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche fenster
ganz kurz im hintergrund."

## Beides war dieselbe Schleife

In der Disc-Wache stand:

    except OSError:
        continue

Damit blieb `bekannt[pfad]` ungesetzt, und im naechsten Durchlauf war
`vorher is None` — also wieder „Erststart, liegt schon eine Disc drin". **Ein
einziger fehlgeschlagener Lesevorgang loeste einen neuen Vor-Scan aus.**

Und der scheitert regelmaessig: Waehrend der Vor-Scan laeuft, haelt
makemkvcon das Laufwerk (gemessen: /devices braucht dann 14 s statt 5). Also
Scan haelt das Laufwerk -> Statusabfrage scheitert -> naechster Durchlauf
haelt es fuer den ersten -> neuer Scan. Eine Schleife, die sich selbst am
Leben haelt.

Und weil derselbe makemkvcon-Aufruf in `prescan.py` als EINZIGER kein
`creationflags=OHNE_FENSTER` hatte, blitzte bei jeder Runde eine Konsole auf.
Das war meine Zeile von heute Vormittag.

Die Entscheidung steckt jetzt in `disc_entscheidung()` — pure Funktion, also
ohne Laufwerk pruefbar. Zwei getrennte Fragen statt einer: Haben wir dieses
Laufwerk je gelesen, und was war zuletzt drin? Ein Fehlschlag beantwortet die
zweite nicht und darf die erste nicht zuruecksetzen.

Nachgemessen: Ein Tabwechsel loest KEINEN neuen Scan aus (8 Erkennungen
vorher, 8 nachher). Meine erste Zaehlung von „7 vs 8" war ein Messfehler —
zwei verschieden grosse Abfragefenster.

## Aus dem Docker-Betrieb uebernommen

`docker/worker/entrypoint.sh` setzt vor jedem Worker-Start zwei Dinge:
`app_Key` und `app_UpdateEnable`. Der Key war schon uebernommen, die zweite
nicht — auf dem Rechner des Commanders nachgesehen: NICHT gesetzt.

Das ist MakeMKVs Web-Kontakt; darueber holt es die Disc-Schluessel fuer
4K-UHD nach (Meldung 3338, auf Windows gemessen). „Fuer Windows umschreiben"
heisst hier: Registry statt settings.conf, DWORD statt Text — die
Nachbarwerte (`app_BackupDecrypted`) zeigen die Form.

Gesetzt wird NUR, was fehlt: Wer den Web-Kontakt bewusst abgeschaltet hat,
behaelt ihn abgeschaltet. Ein Waechter-Test vergleicht kuenftig die
`app_*`-Namen aus der entrypoint.sh gegen die Windows-Seite — sonst driften
die beiden Betriebe auseinander.

871 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:30:29 +02:00
HitonabiandClaude Opus 5 f0d39b4d04 docs(savepoint): laufende Disc-Erkennung sichtbar, verwaiste makemkvcon notiert
Ampel / ampel (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:13:43 +02:00
HitonabiandClaude Opus 5 e67138e9ca feat(ui): Laufende Disc-Erkennung ist jetzt sichtbar
Ampel / ampel (push) Successful in 1m26s
Commander: „das die disc erkennung noch läuft muss sichtbar sein"

Er hat recht, und die Wartezeit ist gemessen: `makemkvcon info` laeuft an
einer Blu-ray in seine 120-Sekunden-Grenze (Disc nach 119 s erkannt). Zwei
Minuten, in denen NICHTS zu sehen war — weder die Disc noch ein Hinweis. Das
sah aus wie ein leeres Laufwerk, also wie ein Fehler.

## Der Zustand war da, er wurde nur weggeworfen

`_auto_prescan` legt beim Start `{"_laeuft": True}` in den Vorrat.
`laufwerke_mit_disc` liess so einen Eintrag KOMPLETT weg — damit keine
halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem „Rippen
starten"-Knopf erscheint. Richtig gedacht, falsch geloest.

Jetzt gibt es ein eigenes Feld `disc_wird_erkannt`. Die Oberflaeche kann
„wird gelesen" zeigen, ohne einen Titel zu behaupten, den es noch nicht gibt:
eine Karte mit Spinner ueber der Laufwerksliste und eine eigene Zeile im
Server-Status.

## Und der Haken dahinter

Genau waehrend der Erkennung ist die Laufwerks-Auskunft am teuersten:
makemkvcon haelt das Laufwerk, und `device_info` wartet mit. Gemessen:

    /devices waehrend des Scans     14,0 s
    Zeitgrenze des Schnappschusses   5,0 s   ->  devices = None

`None` heisst „konnte nicht nachsehen", und die Oberflaeche behaelt dann
ihren Stand — nach einem frischen Laden also GAR NICHTS. Die Anzeige waere
ausgerechnet in ihrer eigenen Phase leer geblieben.

`laufwerke_notdurft()` antwortet deshalb ohne ioctl: letzter bekannter Stand
plus die aktuelle Erkennungs-Marke aus dem Vorrat. Beim Kaltstart wenigstens
der Laufwerksname — die LISTE der Laufwerke ist billig, teuer ist erst das
Anfassen. Nichts wird erfunden: kein Typ, kein Modell. Und wer noch nie
erfolgreich gelesen hat, schweigt weiter mit `None`.

Im Browser gegengeprueft: „Disc wird gelesen — Laufwerk G:" mit Spinner,
Server-Status „Disc wird gelesen — das dauert bis zu zwei Minuten", danach
der richtige Titel.

859 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:12:35 +02:00
HitonabiandClaude Opus 5 dbb934d146 docs(savepoint): v4.0-rc7 — Drueberinstallieren, Linux-Reste, Ordner-Waehler, Jobzeile
Ampel / ampel (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:01:34 +02:00
HitonabiandClaude Opus 5 7b0c41ddfe fix(ui): Datum 1.1.1970, Disc nicht erkannt, kein Abbrechen — eine Ursache je Fall
Ampel / ampel (push) Successful in 1m16s
Vier Befunde aus einem Bildschirmfoto vom 29.08.2026.

## 1. „das Datum ist falsch" — und der Typ auch

    TYP „DISC"   STARTZEIT „1.1.1970, 01:00:00"   STATUS „running"

⚠️ Das war MEIN Fehler von heute Vormittag. Ich hatte `type`, `device` und
`startTime` in das Job-Ereignis aufgenommen — mit Feldnamen, **die es in der
Datenbankzeile nicht gibt**. Dort heissen sie `disc_type` und `created_at`;
die Uebersetzung macht `_job_row_to_model`, und sie bildet auch `running` auf
`processing` ab.

Die Folge war schlimmer als das Problem davor: Statt eines FEHLENDEN Feldes
kam ein LEERES. `new Date(null)` ist der 1.1.1970, und „DISC" war der
Rueckfall, den ich zwei Stunden vorher fuer genau diesen Fall eingebaut hatte.
Aus „offensichtlich kaputt" war „sieht plausibel aus" geworden.

Und mein Test hat es nicht gefunden, weil ich seine Beispielzeile selbst
erfunden habe — mit meinen falschen Feldnamen. Ein Test, der dieselbe Annahme
macht wie der Code, prueft nichts. Der neue geht von der ECHTEN Zeile aus.

Der Waechter bekommt jetzt dieselbe Uebersetzung eingespritzt, die auch
`/jobs` benutzt.

## 2. „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht"

Die Laufwerksliste wurde an DREI Stellen gebaut: `/devices` haengte die
erkannte Disc an, der Ereignis-Waechter und der SSE-Schnappschuss nicht. Das
Dashboard liest den Schnappschuss — und schloss aus der fehlenden Disc auf ein
leeres Laufwerk, waehrend ihr Titel eine Zeile weiter oben stand.

Jetzt gibt es `laufwerke_mit_disc()`, und ein Test zaehlt die Aufrufe von
`device_info` — bei zwei ist er rot.

## 3. „man kann einen rip garnicht abbrechen"

Dieselbe Ursache wie 1: Es gab genau EINEN Abbrechen-Knopf, in der Kachel
„laufender Job". Die erscheint nur bei Status `processing`; der Strom lieferte
`running`. Keine Kachel, kein Knopf, kein Weg zurueck — und aus demselben
Grund stand „Aktiv (0)", waehrend der Rip lief.

Zusaetzlich gibt es den Knopf jetzt in der Job-Zeile selbst, wo man ihn sucht.

## 4. „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der
##     Ablagepfad sein"

Dort stand eine nackte Zahl fuer den ERSTEN Ort. Welcher Ordner das war, sah
man nicht — und die zweite Zeile fiel ganz weg, wenn beide auf derselben
Platte lagen. Das stimmt fuer die ZAHL und war der falsche Schluss fuer den
ORT. Jetzt stehen beide da, mit Pfad, plus der Hinweis, dass der Platz
geteilt wird.

Im Browser gegengeprueft: „Disc erkannt — wartet auf ‚Rippen starten'",
BLU-RAY mit richtigem Datum, beide Pfade, keine Konsolenfehler.

852 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:00:11 +02:00
HitonabiandClaude Opus 5 b2acddbdfa fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler
Ampel / ampel (push) Successful in 1m27s
Vier Fragen des Commanders vom 29.08.2026, drei davon mit Codefolge.

## 1. „Was passiert wenn man die Setup.exe einfach drueber installiert?"

Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf PermissionError,
und die neue Fassung landete als `Rippy.exe.neu` daneben. Dazu die Meldung
„wird beim naechsten Start uebernommen".

**Diese Zusage hat niemand eingeloest.** `.neu` kam im ganzen Projekt genau
einmal vor: an der Stelle, die es schrieb. Wer drueberinstallierte, behielt
still die alte Fassung, und das Setup meldete Erfolg.

Jetzt wird der laufende Rippy vorher beendet (`dienst_beenden` gibt es seit
der Deinstallation und wartet auch die zwei Sekunden ab, die Windows fuer die
Dateihandles braucht). Eine von einer aelteren Setup-Fassung liegengelassene
`.neu` wird dabei uebernommen. Bleibt die Datei DANN noch gesperrt, gibt es
einen klaren Fehler statt einer Zusage — Rippy im Infobereich beenden und das
Setup erneut starten.

Der Tausch laeuft bewusst im SETUP und nicht beim Dienststart: Windows sperrt
eine laufende .exe, und `Rippy.exe` waere genau die zu ersetzende Datei.

## 3. Linux-Reste im Windows-Betrieb (Docker/Headless unveraendert)

**`caps.py`: `os.path.isdir("/app")`.** Damit hielt sich der eigenstaendige
Windows-Rippy fuer einen FREMDEN Worker — und das UI warnte vor fehlender
Pfad-Uebersetzung auf einer Maschine ohne Container und ohne Freigabe.
„Extern" heisst jetzt, was es meint: Rippy laeuft woanders als dieser Worker.

**`caps.py`: `shutil.which("makemkvcon")` + `os.path.ismount(daten_dir)`.**
Beide unter Windows immer falsch (Programme liegen nicht im PATH, ein
normaler Ordner ist kein Mount). Die Schluessel-Auskunft blieb dauerhaft
„unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. Der Mount-Test
bleibt fuer den Container, wo er einen Zweck hat.

**`rohdaten.py`: `/app/temp/raw` und `/app/media` fest.** Dieses Modul findet
die Rohdaten eines Jobs wieder — fuer den Wiederholen-Dialog und fuer
„Rohdaten mitloeschen". Unter Windows fand es NIE etwas: Der Dialog meldete
„keine Rohdaten", das Aufraeumen loeschte nichts, und die Bruchstuecke eines
abgebrochenen Rips blieben liegen (bei 4K-UHD bis 100 GB).

Sieben Tests wurden dabei rot, und zwar zu Recht: Sie pruefen Container-Regeln,
liefen aber unter Windows. Die Wurzeln sind jetzt einspritzbar — beide
Betriebsfaelle auf jedem Rechner pruefbar statt vom laufenden abhaengig.

## 4. „Der Durchsuchen button fehlt. Wie es der Installer auch macht"

Neu: `OrdnerWaehler` — Pfadfeld plus „Durchsuchen …", benutzt fuer Ablage und
Arbeitsverzeichnis. Es waere der DRITTE fest eingebaute Ordner-Browser
geworden (RipTargetModal, StorageMounts); dieser hier ist wiederverwendbar.
`/browse` weiss seit dem 28.08. selbst, in welchem Betrieb es laeuft.

⚠️ Beim Einbau fiel der Import unter den Tisch. `vite` pruefte das NICHT — das
Buendel blieb byte-gleich gross, und zur Laufzeit waere es der naechste leere
Bildschirm gewesen. Aufgefallen nur, weil die erwartete Anzahl Ersetzungen
nicht stimmte. Im Browser gegengeprueft: alle sieben Laufwerke, Navigation in
D:\, keine Konsolenfehler.

843 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:28:01 +02:00
HitonabiandClaude Opus 5 21b1757aa4 docs(savepoint): v4.0-rc6 — leerer Bildschirm und zwei weitere Container-Wurzeln
Ampel / ampel (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:15:58 +02:00
HitonabiandClaude Opus 5 423374e65a fix(windows): Roh- und Zielordner landeten in einem Ordner namens „app"
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch. Die
Rohdaten landeten in:

    F:\app\temp\raw\<job>\Evangelion 2.22_t00.mkv   (436 MB)

Also in einem Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.

## Zwei Container-Wurzeln im Worker

    RAW_DIR    = /app/temp/raw
    MEDIA_ROOT = /app/media

Unter Windows sind das keine Pfade, sondern Unfaelle. Schlimmer: Die Pruefung
`unter_wurzel(wahl, MEDIA_ROOT)` verwarf auch eine AUSDRUECKLICHE Wahl — ein
Arbeitsordner wie `D:\Roh` liegt nicht unter `/app/media`, also fiel er still
auf den Container-Standard zurueck.

**Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdruecklich
bestellt hat, unter Windows nie an.** Der Dialog zeigte ihn, das Setzen ging,
und der Worker ignorierte ihn — ohne ein Wort. Dasselbe galt fuer das Ziel:
Eine UNC-Freigabe liegt unter gar keiner lokalen Wurzel, also waere die
fertige Datei in `X:\app\media\bluray` gelandet.

## Die Wurzeln kommen jetzt aus dem Betrieb

Im Container aendert sich NICHTS: dort ist `/app/media` die Wurzel und `frei`
falsch. Nativ zaehlt die Wahl des Nutzers — dort IST sein Laufwerk die Grenze.

Die drei bestehenden Tests wurden rot, und zwar zu Recht: Sie pruefen die
Container-Regel, liefen aber unter Windows, wo `frei` gilt. Die Wurzeln sind
deshalb einspritzbar — beide Betriebsfaelle sind jetzt auf jedem Rechner
pruefbar, statt vom laufenden abzuhaengen.

837 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:15:17 +02:00
HitonabiandClaude Opus 5 27c9d9a4bb fix(ui): Leerer Bildschirm nach „Rippen starten" — ein halber Job riss alles mit
Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
gibt auch keine fehlermeldung."

Nachgestellt: Dienst lokal gestartet, Disc-Karte geoeffnet, „Rippen starten"
geklickt. Browser-Konsole:

    TypeError: Cannot read properties of undefined (reading 'toUpperCase')

## Er startet sehr wohl — man sieht es nur nie

Waehrend die Seite leer war, lief der Job (per API gemessen: status
`processing`, progress 12). Das „er startet nix" ist also der zweite Teil
desselben Fehlers: Die Oberflaeche war weg, bevor sie ihn zeigen konnte.

## Die Ursache

`_job_kurz` in `bus/waechter.py` trug nur `status`, `progress`, `title` und
`error` — **keinen `type`**. Das UI fuegt aus `job.created` einen halben Job
in seine Liste ein, `LiveLogSection` liest `job.type.toUpperCase()`, und React
baut bei einem Fehler im Zeichnen den GESAMTEN Baum ab. Es gab in diesem
Projekt keine einzige Fehlergrenze — also blieb ein leerer Bildschirm ohne
jede Meldung.

## Drei Reparaturen, weil es drei Fehler waren

1. **Das Ereignis traegt den Job.** `type`, `device` und `startTime` fahren
   mit. Sie aendern sich ueber die Lebenszeit eines Jobs nie, kosten also kein
   zusaetzliches Ereignis — und ohne `startTime` stand in der Jobliste
   sekundenlang „Invalid Date".
2. **Das UI vertraegt sein Fehlen.** `LiveLogSection` und `TypeBadge` nahmen
   einen vollstaendigen Job an. Zeile 108 derselben Datei hatte das
   Fragezeichen laengst, Zeile 48 nicht.
3. **Eine Fehlergrenze.** Ein Fehler in einer Karte darf nicht den ganzen
   Bildschirm mitnehmen — und schon gar nicht schweigend. Jetzt steht da, was
   los ist, die Navigation bleibt bedienbar, und der Hinweis sagt das
   Wichtigste: laufende Rips gehen weiter.

## Warum es niemand gefunden hat

`unterschiede()` bekommt die Kurzform schon fertig, und alle Tests reichen
ihre eigenen Woerterbuecher herein. **`_job_kurz` selbst war nie geprueft.**
Jetzt bewacht `UI_PFLICHTFELDER` den Vertrag mechanisch — es gibt kein
gemeinsames Typsystem zwischen Python und dem UI.

Gegengeprueft im Browser: derselbe Klick, keine Konsolenfehler, Job erscheint
als „Alle (1)".

833 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:10:12 +02:00
HitonabiandClaude Opus 5 d9780a4951 docs(savepoint): v4.0-rc5 — Rippy rippt, drei Fehler im Rip-Pfad behoben
Ampel / ampel (push) Successful in 1m38s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:52:52 +02:00
HitonabiandClaude Opus 5 954f293871 fix(windows): Rippen ging nicht — drei Container-Annahmen im Rip-Pfad
Commander: „Das Rippen der disk funktioniert nicht. makemkvcon endete mit
Code 11 — letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
entstanden"

In diesem einen Satz steckten drei Fehler. Alle drei an seinem Laufwerk
gemessen, nicht hergeleitet.

## 1. Die Quellenangabe — das war der Abbruch

    dev:\.\G:  MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
    dev:G:      68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich abgeschlossen"

`f"dev:{device_path}"` stand an VIER Stellen. Unter Linux richtig (/dev/sr0),
unter Windows nicht: `rippy.drives.windows` meldet den Geraete-Namensraum
`\.\G:`, den `CreateFileW` fuer die IOCTLs braucht — MakeMKV kennt ihn
nicht.

## 2. „Ö" statt „Ö"

`Ö` als UTF-8 (C3 96), gelesen als cp1252. `subprocess` stand auf `text=True`
ohne `encoding=`, nahm also die Gebietsschema-Kodierung; Rippy stellt seine
Konsole beim Start aber auf UTF-8 (sonst stirbt der Start an einer
Umlaut-Logzeile). Direkt gemessen kommen die Bytes je nach Umgebung als UTF-8
ODER cp1252 — deshalb wird jetzt bestimmt statt behauptet.

## 3. Die Ursache fehlte im Fehlertext

Gesucht wurde nach ENGLISCHEN Textbausteinen („evaluation period has
expired"). Auf einem deutschen Windows trifft keiner. Jetzt zaehlen die
Meldungs-NUMMERN, die sprachunabhaengig sind.

⚠️ Beim ersten Anlauf hatte ich 5021 aus dem Kopf mit „Volume-Key unbekannt"
beschriftet — falsch. Meine eigene Ausgabe zeigte nur meine Beschriftung
statt MakeMKVs Text, sodass es fast durchgegangen waere. Der echte Wortlaut
steht jetzt im Code.

## Nebenbefund: der Beta-Key lag zweimal am falschen Ort

`makemkv_daten.DATEN_DIR` war fest `/root/.MakeMKV`. Nach der Reparatur des
Ordners blieb MSG:5051 trotzdem stehen — weil MakeMKV unter Windows seine
Einstellungen in der REGISTRY haelt (HKCU\Software\MakeMKV), nicht in einer
settings.conf. Nachgesehen statt vermutet.

Und der wichtigste Teil davon: **Ein abgelehnter Schluessel ist schlimmer als
gar keiner.** Ohne Key las MakeMKV die Disc noch (68 TINFO), mit dem
abgelehnten verweigerte es alles (0 Titel). Deshalb wird jetzt geprueft und
bei Ablehnung zurueckgenommen.

## Beweis

Echter Rip ueber `ripping.run_makemkv`, kuerzester Titel:

    Status success, Code 0, „Evangelion 2.22_t03.mkv" 217,9 MB
    5036 „Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert."

828 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:52:06 +02:00
HitonabiandClaude Opus 5 73ed104bbc docs(savepoint): Arbeitsordner im Setup, Beta-Key auf Knopfdruck
Ampel / ampel (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:54:18 +02:00
HitonabiandClaude Opus 5 678fe276f9 feat(windows): Arbeitsordner im Setup, Beta-Key auf Knopfdruck
Ampel / ampel (push) Successful in 1m18s
Zwei Wuensche des Commanders vom 28.08.2026.

## 1. „kannst du noch einbauen das man den arbeitsordner usw. in den
##     Einstellungen setzen kann und direkt im setup?"

In den Einstellungen ging es seit 35370fd (freies Pfadfeld plus die Laufwerke
als Ein-Klick-Wahl). Im SETUP wurde bisher nur nach Programmordner und Ablage
gefragt — der Arbeitsordner tauchte erst auf, wenn schon installiert war.

Das ist die falsche Reihenfolge: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat es
zu spaet erfahren — genau so lief am 25.07.2026 die VM-Platte voll.

Neu ist auch ein Waechter, der alle drei Arten von halb angeschlossenem Feld
faengt (kein Eingabefeld, keine Vorbelegung, wird beim Installieren nicht
mitgeschickt). Der dritte Fall ist der gemeinste: Man waehlt etwas, es
passiert nichts, und niemand sagt warum.

## 2. „der könnte theoretisch auch automatisch ausgelesen werden, ich glaube
##     das web rippy kann das"

Er hatte recht. `makemkv_key.refresh_loop()` laeuft seit jeher taeglich mit
(main.py, Startereignis). GEMESSEN am 28.08.2026: Forum antwortet in 3,3 s,
gueltiger Key mit 62 Zeichen, und in seinen Einstellungen lag bereits einer.

Es war nur nichts davon zu sehen. Kein Knopf, keine Meldung, keine Zeitangabe
— im Feld stand ein Key, und ob der von Hand kam oder von selbst, wusste
niemand. Eine Automatik, die man nicht sehen kann, ist fuer den Benutzer
keine.

Dazu kam eine echte Luecke: Die Schleife startet 60 Sekunden nach dem Server.
Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt ohne Key — MakeMKV
faellt in den 30-Tage-Testmodus. Das Setup holt ihn jetzt selbst.

Der geholte Key wandert NICHT ueber die Leitung zurueck: Er steht in den
Einstellungen, und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn
zu verlieren.

Rechtlich unveraendert: oeffentliche Beta-LIZENZ der Software, KEIN
Disc-Schluessel. Rippy liefert und verteilt keine Disc-Schluessel.

803 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:53:12 +02:00
HitonabiandClaude Opus 5 d0281f7a1b style: Zeilenenden zurueck auf LF (kein Inhalt geaendert)
In 35370fd habe ich fuenf Dateien versehentlich von LF auf CRLF umgestellt.
Der Inhalt stimmte, aber der Diff war unlesbar: 1363 geaenderte Zeilen fuer
30 echte in RipTargetModal.tsx, 1184 fuer 29 in setup_fenster.py. Nachgewiesen
mit `git diff --ignore-cr-at-eol` — damit blieben 56/9 uebrig.

Ursache: Ein Rueckschreiben im Textmodus stellt die ganze Datei um. Die Regel
stand als Warnung schon in meinem Gedaechtnis, dort aber nur in der anderen
Richtung (CRLF -> LF). Sie gilt in beide.

Dieser Commit aendert AUSSCHLIESSLICH Zeilenenden. Nachgewiesen:
`git diff --ignore-cr-at-eol` listet keine dieser fuenf Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:52:08 +02:00
161 changed files with 29512 additions and 2155 deletions
+11
View File
@@ -52,6 +52,17 @@ jobs:
if [ -n "$pkg_dateien" ]; then if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt ==" echo "== Node/TypeScript erkannt =="
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
if [ "$node_major" -lt 22 ]; then
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
rot=1
fi
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -n "$pkg" ] || continue [ -n "$pkg" ] || continue
d=$(dirname "$pkg") d=$(dirname "$pkg")
+3
View File
@@ -55,3 +55,6 @@ logs/
*.db *.db
*.sqlite *.sqlite
dist/ dist/
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
design-entwuerfe/
+1131
View File
File diff suppressed because it is too large Load Diff
+1003 -5
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
überhaupt vorhanden?
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
```
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
Kern beendet mit 0
```
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
Messung zuvor ebenfalls grün.)
Nachstellen:
```
npm install electron@44 --no-save
node node_modules/electron/install.js
./node_modules/electron/dist/electron.exe sqlite-main.js
```
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
---
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
Tages jemand an der falschen Stelle.
+109
View File
@@ -0,0 +1,109 @@
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
// NUR LESEND — kein Auswerfen, kein Verriegeln.
const koffi = require('koffi');
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
const ctl = (typ, fn, methode, zugriff) =>
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
const kernel32 = koffi.load('kernel32.dll');
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
const DRIVE_CDROM = 5;
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
const maske = GetLogicalDrives();
const optische = [];
for (let i = 0; i < 26; i++) {
if (!(maske & (1 << i))) continue;
const buchstabe = String.fromCharCode(65 + i);
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
}
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
const lw = optische[0];
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
function oeffnen(zugriff) {
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
const adr = koffi.address(h);
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
return { h, fehler: 0 };
}
const ohneMedium = oeffnen(0);
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
const mitLesen = oeffnen(GENERIC_READ);
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
const h = ohneMedium.h;
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
const rueck = Buffer.alloc(4);
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
const anfrage = Buffer.alloc(12);
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
const antwort = Buffer.alloc(1024);
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
const text = (off) => {
const start = antwort.readUInt32LE(off);
if (!start || start >= antwort.length) return '';
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
return antwort.toString('latin1', start, ende).trim();
};
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
} else {
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
}
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
// ── 5. Audio- oder Datenspur?
const typBuf = Buffer.alloc(1);
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
const t = typBuf[0];
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
} else {
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
}
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
const laenge = Buffer.alloc(8);
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
const bytes = laenge.readBigUInt64LE(0);
const gb = Number(bytes) / 1e9;
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
} else {
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
}
CloseHandle(h);
if (mitLesen.h) CloseHandle(mitLesen.h);
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
+50
View File
@@ -0,0 +1,50 @@
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
// makemkvcon Rippy und haelt das Laufwerk fest.
const koffi = require('koffi');
const { spawn } = require('child_process');
const kernel32 = koffi.load('kernel32.dll');
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
// JobObjectExtendedLimitInformation = 9 (winnt.h)
const JobObjectExtendedLimitInformation = 9;
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
const job = CreateJobObjectW(null, null);
if (koffi.address(job) === 0n) {
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
const info = Buffer.alloc(144);
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
console.log('ELTERN-PID=' + process.pid);
console.log('KIND-PID=' + kind.pid);
// Offen halten, bis uns jemand hart abschiesst.
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
+12
View File
@@ -0,0 +1,12 @@
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
try {
const s = require('node:sqlite');
const db = new s.DatabaseSync(':memory:');
db.exec('CREATE TABLE t (a TEXT)');
db.prepare('INSERT INTO t VALUES (?)').run('geht');
const wert = db.prepare('SELECT a FROM t').get().a;
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
} catch (e) {
process.parentPort.postMessage({ ok: false, fehler: e.message });
}
+20
View File
@@ -0,0 +1,20 @@
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
//
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
const { app, utilityProcess } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
kind.on('message', (antwort) => {
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
app.exit(antwort.ok ? 0 : 1);
});
kind.on('exit', (code) => {
console.log('Kern beendet mit ' + code);
if (code !== 0) app.exit(1);
});
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+42 -5
View File
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
return f"eta:{job_id}" return f"eta:{job_id}"
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
#:
#: ## Der Befund des Commanders (29.08.2026)
#:
#: > „Über den kompletten vorgang steht dort Restzeit wird gemessen' aber
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
#:
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
#:
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
_REIHEN: dict = {}
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
REIHE_HALTBAR_SEKUNDEN = 86400
def _aufraeumen(jetzt: float) -> None:
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
for k, (stand, _) in list(_REIHEN.items()):
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
_REIHEN.pop(k, None)
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int, def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
jetzt: float, cache_get, cache_set) -> dict: jetzt: float, cache_get, cache_set) -> dict:
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben. """Messreihe fortschreiben und die Restzeit zurückgeben.
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
keine ETA, aber nichts scheitert. ohne Redis zustande kommt. Siehe `_REIHEN`.
""" """
if status not in LAUFENDE_STATUS or progress <= 0: if status not in LAUFENDE_STATUS or progress <= 0:
return {"sekunden": -1, "text": ""} return {"sekunden": -1, "text": ""}
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
reihe = cache_get(k) reihe = cache_get(k)
except Exception: except Exception:
reihe = None reihe = None
if not reihe:
# Kein Cache (oder leer) — dann der eigene Vorrat.
eintrag = _REIHEN.get(k)
reihe = eintrag[1] if eintrag else None
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt) reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
try: try:
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos. # Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
cache_set(k, reihe, expire=86400) cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
except Exception: except Exception:
pass pass
_REIHEN[k] = (jetzt, reihe)
_aufraeumen(jetzt)
sekunden = restzeit_sekunden(reihe, jetzt) sekunden = restzeit_sekunden(reihe, jetzt)
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)} return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
+532 -61
View File
@@ -6,7 +6,6 @@ from typing import List, Optional, Dict
import asyncio import asyncio
import json import json
import os import os
import posixpath
import shutil import shutil
import time import time
import uuid import uuid
@@ -122,11 +121,14 @@ async def startup_event():
ereignis_waechter = Waechter( ereignis_waechter = Waechter(
db, ereignis_bus, db, ereignis_bus,
laufwerke_lesen=lambda: [ # MIT der erkannten Disc — sonst meldet die Server-Status-Kachel
device_discovery.device_info(p) # „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026).
for p in device_discovery.list_optical_devices() laufwerke_lesen=laufwerke_mit_disc,
],
system_lesen=system_stand_sync, system_lesen=system_stand_sync,
# DIESELBE Uebersetzung wie /jobs — sonst tragen die Live-Ereignisse
# andere Feldnamen als der Schnappschuss (Befund 29.08.2026:
# „1.1.1970" und „DISC" in der Jobliste).
job_form=lambda z: _job_row_to_model(z).model_dump(),
) )
asyncio.create_task(ereignis_waechter.schleife()) asyncio.create_task(ereignis_waechter.schleife())
@@ -184,9 +186,27 @@ def _duplikat_suchen(fingerprint: str):
async def _auto_prescan(pfad: str): async def _auto_prescan(pfad: str):
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis.""" """Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis.
## Warum ein laufender Job hier alles stoppt (Befund 29.08.2026)
> „der Disk wird gelesen' panel braucht eeeeewig. Der Rip ist bereits bei
> 30% und er liest immernoch."
Genau so war es — und die Erkennung kam auch nie zum Ende. Waehrend eines
Rips haelt `makemkvcon` das Laufwerk; ein zweites `makemkvcon info`
daneben wartet, bis es seine Zeitgrenze erreicht (gemessen: eine
Laufwerks-Abfrage braucht dann 14 s statt 5). Die Marke `_laeuft` blieb
solange stehen, also stand auch das Panel.
Es gibt keinen Grund, waehrend eines Rips zu scannen: Das Laufwerk ist
belegt, und **welche Disc drin ist, wissen wir bereits** — der Job laeuft
ja auf ihr.
"""
if DISC_CACHE.get(pfad, {}).get("_laeuft"): if DISC_CACHE.get(pfad, {}).get("_laeuft"):
return return
if await asyncio.to_thread(db.has_active_job, pfad):
return
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"} DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
try: try:
prescan = PreScan() prescan = PreScan()
@@ -213,6 +233,19 @@ async def _auto_prescan(pfad: str):
except Exception as e: except Exception as e:
DISC_CACHE.pop(pfad, None) DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}") print(f"Auto-Pre-Scan {pfad}: {e}")
# ... und ins PROTOKOLL, nicht nur auf die Konsole
# (Befund 30.08.2026). Der Commander: „im log steht zwar
# erkannt, aber ein start des rips ist nicht moeglich."
# Genau so war es: Die letzte Zeile war das erfolgreiche
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
# danach standen nur auf einer Konsole, die niemand sieht.
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
try:
db.add_log("warning", "watcher",
"Disc-Erkennung auf %s fehlgeschlagen: %s"
% (pfad, e))
except Exception: # noqa: BLE001
pass # Protokollieren darf die Wache nie anhalten
async def _auto_rip_wenn_aktiviert(pfad: str): async def _auto_rip_wenn_aktiviert(pfad: str):
@@ -225,7 +258,10 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
einstellungen = await asyncio.to_thread(db.get_settings) einstellungen = await asyncio.to_thread(db.get_settings)
if not einstellungen.get("autoRipStart"): if not einstellungen.get("autoRipStart"):
return return
if await asyncio.to_thread(db.has_active_job, pfad): # `job_offen`, nicht `has_active_job`: Waehrend der Kompression ist das
# Laufwerk zwar frei, der Vorgang aber nicht durch — und die Disc wurde
# soeben ausgeworfen. Herleitung in store.job_offen.
if await asyncio.to_thread(db.job_offen, pfad):
return return
disc = DISC_CACHE.get(pfad) or {} disc = DISC_CACHE.get(pfad) or {}
if disc.get("_laeuft"): if disc.get("_laeuft"):
@@ -266,6 +302,51 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
start_rip(pfad, job_id, ziel) start_rip(pfad, job_id, ziel)
def disc_entscheidung(schon_gesehen: bool, vorher, status) -> str:
"""Was ist zu tun? `""` | `"erststart"` | `"eingelegt"` | `"entfernt"`.
Pure Funktion — deshalb ohne Laufwerk prüfbar, und genau daran hing der
Fehler unten.
## Der Befund des Commanders (29.08.2026)
> „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn er sie
> bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche fenster
> ganz kurz im hintergrund."
Beides war dieselbe Schleife. In der Disc-Wache stand:
except OSError:
continue
Damit blieb `bekannt[pfad]` ungesetzt, und im nächsten Durchlauf war
`vorher is None` — also wieder „Erststart, liegt schon eine Disc drin".
**Ein einziger fehlgeschlagener Lesevorgang löste einen neuen Vor-Scan
aus.**
Und der scheitert regelmäßig: Während der Vor-Scan läuft, hält
`makemkvcon` das Laufwerk (gemessen: `/devices` braucht dann 14 s statt
5). Also: Scan hält das Laufwerk → Statusabfrage scheitert → nächster
Durchlauf hält es für den ersten → neuer Scan. Eine Schleife, die sich
selbst am Leben hält — und weil derselbe Aufruf sein Konsolenfenster
nicht unterdrückte, blitzte bei jeder Runde eines auf.
Deshalb zwei getrennte Fragen statt einer: **Haben wir dieses Laufwerk je
gelesen?** (`schon_gesehen`) und **was war zuletzt drin?** (`vorher`). Ein
Fehlschlag beantwortet die zweite nicht — und darf die erste nicht
zurücksetzen.
"""
if not schon_gesehen:
return "erststart" if status == CDS_DISC_OK else ""
if status == vorher:
return ""
if status == CDS_DISC_OK:
return "eingelegt"
if status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
return "entfernt"
return ""
async def disc_watcher(): async def disc_watcher():
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und """Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte). stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
@@ -275,25 +356,45 @@ async def disc_watcher():
für den Heim-Use-Case gleichwertig und läuft überall. für den Heim-Use-Case gleichwertig und läuft überall.
""" """
bekannt: Dict[str, int] = {} bekannt: Dict[str, int] = {}
# Laufwerke, die schon einmal ERFOLGREICH gelesen wurden. Getrennt von
# `bekannt`, weil ein fehlgeschlagener Lesevorgang sonst wie ein
# Erststart aussieht — siehe unten.
gesehen: set = set()
while True: while True:
try: try:
for pfad in device_discovery.list_optical_devices(): for pfad in device_discovery.list_optical_devices():
try: try:
status = await asyncio.to_thread(drive_status, pfad) status = await asyncio.to_thread(drive_status, pfad)
except OSError: except OSError:
# ⚠️ Hier stand nur `continue` (Befund 29.08.2026).
#
# `bekannt[pfad]` blieb damit ungesetzt, und beim nächsten
# Durchlauf war `vorher is None` — also wieder „Erststart,
# liegt schon eine Disc drin". Ein einziger fehlgeschlagener
# Lesevorgang bewirkte einen NEUEN Vor-Scan.
#
# Und genau das passiert regelmäßig: Während der Vor-Scan
# läuft, hält `makemkvcon` das Laufwerk, und die
# Statusabfrage kommt nicht durch. Ergebnis: Scan hält das
# Laufwerk → Statusabfrage scheitert → nächster Durchlauf
# hält es für den ersten → neuer Scan. Eine Schleife, die
# sich selbst am Leben hält, und bei jeder Runde blitzte
# eine Konsole auf.
#
# `gesehen` merkt sich, ob wir dieses Laufwerk je gelesen
# haben. Ein Fehlschlag heißt jetzt „nichts Neues erfahren"
# — nicht „zum ersten Mal gesehen".
continue continue
vorher = bekannt.get(pfad) was = disc_entscheidung(pfad in gesehen, bekannt.get(pfad), status)
if vorher is None: gesehen.add(pfad)
# Erststart: liegt schon eine Disc drin, direkt erkennen if was == "erststart":
if status == CDS_DISC_OK: asyncio.create_task(_auto_prescan(pfad))
asyncio.create_task(_auto_prescan(pfad)) elif was == "eingelegt":
elif status != vorher: db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
if status == CDS_DISC_OK: asyncio.create_task(_auto_prescan(pfad))
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") elif was == "entfernt":
asyncio.create_task(_auto_prescan(pfad)) db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: DISC_CACHE.pop(pfad, None)
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
DISC_CACHE.pop(pfad, None)
bekannt[pfad] = status bekannt[pfad] = status
except Exception as e: except Exception as e:
print(f"Disc-Wache: {e}") print(f"Disc-Wache: {e}")
@@ -380,7 +481,28 @@ class Device(BaseModel):
status: str status: str
model: Optional[str] = None model: Optional[str] = None
serial: Optional[str] = None serial: Optional[str] = None
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
grund: str = ""
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster) disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
# erkennung noch läuft muss sichtbar sein")
#
# Der Zustand war schon da — `_auto_prescan` legt beim Start
# `{"_laeuft": True}` in den Vorrat. Nur wurde er nie herausgegeben:
# `laufwerke_mit_disc` ließ so einen Eintrag KOMPLETT weg, damit keine
# halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem
# „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch gelöst — für
# die Oberfläche sah es dadurch aus, als läge gar keine Disc im Laufwerk.
#
# Und das dauert: `makemkvcon info` läuft an einer Blu-ray in seine
# 120-Sekunden-Grenze (gemessen 29.08.2026 — Disc nach 119 s erkannt).
# Zwei Minuten Schweigen sehen aus wie ein Fehler.
#
# Ein eigenes Feld statt der halben Disc: Die Oberfläche kann „wird
# erkannt" zeigen, ohne einen Titel behaupten zu müssen, den es noch
# nicht gibt.
disc_wird_erkannt: bool = False
def _job_row_to_model(zeile: dict) -> Job: def _job_row_to_model(zeile: dict) -> Job:
@@ -434,7 +556,10 @@ async def _snapshot() -> dict:
""" """
def sammeln(): def sammeln():
return { return {
"jobs": [_job_row_to_model(z).model_dump() for z in db.list_jobs(limit=50)], # DIESELBE Funktion wie /jobs — sonst fehlt hier die
# Restzeit, und die Oberflaeche liest seit V2-3 nur noch
# diesen Schnappschuss (Befund 29.08.2026).
"jobs": [m.model_dump() for m in jobs_fuer_ui(limit=50)],
"workers": db.list_workers(), "workers": db.list_workers(),
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)], "logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
} }
@@ -443,16 +568,25 @@ async def _snapshot() -> dict:
# Laufwerke getrennt: device_info macht ioctls, die hängen können — # Laufwerke getrennt: device_info macht ioctls, die hängen können —
# ein defektes Laufwerk darf den Snapshot nicht aufhalten. # ein defektes Laufwerk darf den Snapshot nicht aufhalten.
try: try:
# MIT der erkannten Disc. Hier stand dieselbe Schleife ein DRITTES
# Mal (Befund 29.08.2026) — `/devices` hängte die Disc an, der
# Ereignis-Wächter und dieser Schnappschuss nicht. Das Dashboard
# liest genau diesen Schnappschuss und schloss aus der fehlenden
# Disc auf ein leeres Laufwerk: „Bereit — kein Datenträger im
# Laufwerk", während der Titel eine Zeile weiter oben stand.
zustand["devices"] = await asyncio.wait_for( zustand["devices"] = await asyncio.wait_for(
asyncio.to_thread( asyncio.to_thread(laufwerke_mit_disc), timeout=5)
lambda: [device_discovery.device_info(p)
for p in device_discovery.list_optical_devices()]),
timeout=5,
)
except (asyncio.TimeoutError, OSError): except (asyncio.TimeoutError, OSError):
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine". # „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand. # Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
zustand["devices"] = None #
# Vorher stand hier NUR `None`. Waehrend der Disc-Erkennung ist das
# aber der Normalfall (makemkvcon haelt das Laufwerk, /devices
# braucht dann 14 s statt 5) — und nach einem frischen Laden hatte
# das UI keinen alten Stand, den es haette behalten koennen. Der
# Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
# sein soll. `laufwerke_notdurft` antwortet ohne ioctl.
zustand["devices"] = laufwerke_notdurft()
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026). # ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
# #
@@ -671,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da) return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
HEUTIGEN Wert der Einstellung `workDir`.
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
wurde), statt nachzusehen. Der Job weiß es selbst.
"""
holen = getattr(job, "get", None)
eigen = ""
if holen:
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
if not eigen:
werte = einstellungen if einstellungen is not None else db.get_settings()
eigen = ((werte or {}).get("workDir") or "").strip()
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
# gemeint. "/" fällt in `kandidaten()` sauber durch.
return os.path.normpath(eigen or "/")
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in # Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard # /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das # abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
@@ -797,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg. „Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
""" """
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") einstellungen = db.get_settings()
alt = _ROHDATEN["treffer"] alt = _ROHDATEN["treffer"]
treffer = {} treffer = {}
for zeile in db.list_jobs(): for zeile in db.list_jobs():
if zeile.get("status") != "failed": if zeile.get("status") != "failed":
continue continue
job_id = zeile["id"] job_id = zeile["id"]
# Je Job SEINE Wahl — nicht die heutige Einstellung.
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir) ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id): if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
@@ -836,8 +1005,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
if job["id"] in vorrat: if job["id"] in vorrat:
return bool(vorrat[job["id"]]) return bool(vorrat[job["id"]])
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann # Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
# dort nicht hängen (im Gegensatz zu allem unter /app/media). # dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"])) # kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026).
from rippy import pfade
return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"]))
@app.get("/jobs", response_model=List[Job]) @app.get("/jobs", response_model=List[Job])
@@ -850,24 +1022,54 @@ async def get_jobs():
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie). Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
""" """
def sammle(): return await asyncio.to_thread(jobs_fuer_ui)
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
jetzt = time.monotonic()
modelle = []
for z in db.list_jobs():
modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modell.retry_art = phasen.retry_art(z)
schaetzung = eta.aktualisiere_und_schaetze(
z["id"], z.get("status") or "", z.get("progress") or 0,
jetzt, cache_get, cache_set,
)
modell.eta_sekunden = schaetzung["sekunden"]
modell.eta_text = schaetzung["text"]
modelle.append(modell)
return modelle
return await asyncio.to_thread(sammle)
def jobs_fuer_ui(limit: int = 100) -> list:
"""Die Jobs in UI-Form — MIT Restzeit, Wiederhol-Art und Rohdaten-Frage.
## Warum das eine Funktion ist (Befund 29.08.2026)
> „Über den kompletten vorgang steht dort Restzeit wird gemessen' aber
> messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
Zwei Ursachen, beide davon, dass es die Jobliste ZWEIMAL gab.
**1.** Diese Rechnung stand nur in `/jobs`. Der SSE-Schnappschuss baute
seine Jobs mit dem nackten `_job_row_to_model` — also ohne Restzeit. Seit
der Umstellung auf den Ereignisstrom (V2-3) liest die Oberfläche aber
genau diesen Schnappschuss und nicht mehr `/jobs`. Die Restzeit wurde also
weiterhin brav berechnet und niemandem gezeigt.
**2.** Die Messreihe lag ausschließlich in Redis, das es unter Windows
nicht gibt — Begründung und Rückfall in `eta.py`.
Dieselbe Lehre wie bei `laufwerke_mit_disc`: Eine Auskunft in zwei
Fassungen ist eine Fassung zu viel.
"""
# Unlesbare Einstellungen duerfen die Jobliste NICHT umwerfen — sie
# steuern hier nur, ob „Neu komprimieren" angeboten wird. Seit diese
# Funktion auch den SSE-Schnappschuss baut, haengt daran die ganze
# Oberflaeche (Befund 29.08.2026: zwei Snapshot-Tests wurden dadurch rot).
try:
einstellungen = db.get_settings(bei_fehler_leer=True)
except Exception: # noqa: BLE001
einstellungen = {}
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
jetzt = time.monotonic()
modelle = []
for z in db.list_jobs(limit=limit):
modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modell.retry_art = phasen.retry_art(z)
schaetzung = eta.aktualisiere_und_schaetze(
z["id"], z.get("status") or "", z.get("progress") or 0,
jetzt, cache_get, cache_set,
)
modell.eta_sekunden = schaetzung["sekunden"]
modell.eta_text = schaetzung["text"]
modelle.append(modell)
return modelle
def _rohdaten_groesse(pfade: list) -> tuple: def _rohdaten_groesse(pfade: list) -> tuple:
@@ -899,8 +1101,7 @@ async def job_rohdaten(job_id: str):
raise HTTPException(status_code=404, detail="Job nicht gefunden") raise HTTPException(status_code=404, detail="Job nicht gefunden")
def sammle(): def sammle():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
pfade = _rohdaten_suchen(job_id, work_dir)
bytes_gesamt, dateien = _rohdaten_groesse(pfade) bytes_gesamt, dateien = _rohdaten_groesse(pfade)
return { return {
"pfade": pfade, "pfade": pfade,
@@ -929,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
geloescht_gb = 0.0 geloescht_gb = 0.0
if rohdaten: if rohdaten:
def raeume(): def raeume():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
pfade = _rohdaten_suchen(job_id, work_dir)
bytes_gesamt, _ = _rohdaten_groesse(pfade) bytes_gesamt, _ = _rohdaten_groesse(pfade)
for pfad in pfade: for pfad in pfade:
shutil.rmtree(pfad, ignore_errors=True) shutil.rmtree(pfad, ignore_errors=True)
@@ -1124,6 +1324,14 @@ async def create_job(request: JobCreateRequest):
meta_json = json.dumps(meta_dict) if meta_dict else None meta_json = json.dumps(meta_dict) if meta_dict else None
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
# Eine noch laufende Disc-Erkennung ist ab jetzt gegenstandslos: Das
# Laufwerk gehoert dem Rip, und WELCHE Disc drin ist, wissen wir. Ohne
# das Wegraeumen stuende „Disc wird gelesen" bis zum Ende des Rips
# (Commander 29.08.2026: „Der Rip ist bereits bei 30% und er liest
# immernoch"). Der laufende Scan traegt sein Ergebnis spaeter ohnehin
# selbst nach.
if (DISC_CACHE.get(device_path) or {}).get("_laeuft"):
DISC_CACHE.pop(device_path, None)
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json) await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
await asyncio.to_thread( await asyncio.to_thread(
db.add_log, "info", "api", db.add_log, "info", "api",
@@ -1317,12 +1525,35 @@ async def storage_targets():
return await asyncio.to_thread(sammle) return await asyncio.to_thread(sammle)
def geraetepfad(name: str) -> str:
"""Die Kennung aus der URL zum echten Geraetepfad — oder 404.
## Der Befund (29.08.2026)
Hier stand an DREI Stellen:
device_path = f"/dev/{name}"
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf —
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter
Windows also immer mit 404**, ohne dass irgendwo stuende, warum.
Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`),
also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht
mehr auseinanderlaufen.
"""
pfad = device_discovery.pfad_zu_kennung(name)
if not pfad:
raise HTTPException(status_code=404,
detail=f"Laufwerk {name} nicht gefunden")
return pfad
@app.post("/devices/{name}/eject") @app.post("/devices/{name}/eject")
async def eject_device(name: str): async def eject_device(name: str):
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft.""" """Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
device_path = f"/dev/{name}" device_path = geraetepfad(name)
if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
if await asyncio.to_thread(db.has_active_job, device_path): if await asyncio.to_thread(db.has_active_job, device_path):
raise HTTPException( raise HTTPException(
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job" status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
@@ -1342,9 +1573,7 @@ async def scan_tracks_starten(name: str):
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
GET /devices/{name}/tracks. Dauert je nach Disc 20120 s. GET /devices/{name}/tracks. Dauert je nach Disc 20120 s.
""" """
device_path = f"/dev/{name}" device_path = geraetepfad(name)
if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
if await asyncio.to_thread(db.has_active_job, device_path): if await asyncio.to_thread(db.has_active_job, device_path):
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job") raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
@@ -1356,7 +1585,7 @@ async def scan_tracks_starten(name: str):
@app.get("/devices/{name}/tracks") @app.get("/devices/{name}/tracks")
async def scan_tracks_ergebnis(name: str): async def scan_tracks_ergebnis(name: str):
"""Ergebnis des Titel-Scans (Polling-Ziel des UI).""" """Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
device_path = f"/dev/{name}" device_path = geraetepfad(name)
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}") daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
if not daten: if not daten:
return {"status": "none"} return {"status": "none"}
@@ -1384,7 +1613,7 @@ async def retry_transcode(job_id: str):
raise HTTPException(status_code=409, detail="Job rippt noch") raise HTTPException(status_code=409, detail="Job rippt noch")
einstellungen = await asyncio.to_thread(db.get_settings) einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/") work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir) gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
if not gefunden: if not gefunden:
raise HTTPException( raise HTTPException(
@@ -2348,6 +2577,13 @@ async def system_info():
werte = rippy_config.laden() werte = rippy_config.laden()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
werte = {} werte = {}
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
# was eingestellt war (Befund 30.08.2026, siehe
# `betrieb.mit_einstellungen`).
werte = betriebs_auskunft.mit_einstellungen(
werte, db.get_settings(bei_fehler_leer=True))
for ort in betriebs_auskunft.platz_orte(werte): for ort in betriebs_auskunft.platz_orte(werte):
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann # Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer # das naechste vorhandene Elternverzeichnis messen: Der Nutzer
@@ -2361,6 +2597,12 @@ async def system_info():
continue continue
info["plaetze"].append({ info["plaetze"].append({
"name": ort["name"], "name": ort["name"],
# Der PFAD gehoert dazu (Commander 29.08.2026): „bei Platz
# fuer rippy' sollte eher das arbeitsverzeichnis und der
# Ablagepfad sein." Eine Zahl ohne Ort sagt nicht, WO der
# Platz knapp wird.
"pfad": ort["pfad"],
"gleiches_laufwerk": bool(ort.get("gleiches_laufwerk")),
"frei_gb": round(nutzung.free / 1024**3, 1), "frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1), "gesamt_gb": round(nutzung.total / 1024**3, 1),
}) })
@@ -2406,6 +2648,71 @@ async def get_keydb_status():
return await asyncio.to_thread(sammle) return await asyncio.to_thread(sammle)
@app.post("/system/makemkv-key/holen")
async def makemkv_key_holen():
"""Holt den kostenlosen MakeMKV-Beta-Key JETZT aus dem Forum.
## Warum es diesen Knopf gibt (Commander 28.08.2026)
> „Bezüglich des MKV Beta Keys — der könnte theoretisch auch automatisch
> ausgelesen werden, ich glaube das web rippy kann das"
Er hatte recht: `makemkv_key.refresh_loop()` läuft seit jeher täglich mit
(main.py, Startereignis). Nachgemessen: Forum antwortet in 3,3 Sekunden,
Key mit 62 Zeichen.
Nur war davon **nichts zu sehen**. Kein Knopf, keine Zeitangabe, kein
Hinweis — im Einstellungsfeld stand ein Key, und ob der von Hand kam oder
von selbst, wusste niemand. Eine Automatik, die man nicht sehen kann, ist
für den Benutzer keine.
Der Key wechselt etwa monatlich. Wer nicht warten will, bis die Schleife
das nächste Mal nachsieht, drückt hier.
Rechtlich unverändert: öffentliche Beta-LIZENZ der Software, KEIN
Disc-Schlüssel.
"""
import makemkv_key as key_modul
try:
key = await asyncio.to_thread(key_modul.fetch_current_key)
except Exception as e: # noqa: BLE001
raise HTTPException(
status_code=502,
detail=f"Das MakeMKV-Forum antwortet nicht ({type(e).__name__}). "
"Der Key lässt sich weiter von Hand eintragen.")
if not key:
raise HTTPException(
status_code=502,
detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
"Vermutlich hat sich die Seite geändert.")
geaendert = await asyncio.to_thread(key_modul._apply_key, key)
# Nimmt MakeMKV ihn an? Nur MakeMKV selbst weiss das.
#
# Am 29.08.2026 auf dem Rechner des Commanders: der frische Schluessel
# wurde mit MSG 5020 („ungueltig") und 5021 („Programmversion zu alt")
# abgelehnt — sein MakeMKV 1.18.4 ist aelter als der Schluessel verlangt.
# Ohne diese Rueckfrage haette hier „erfolgreich geholt" gestanden, und
# der naechste Rip waere trotzdem gescheitert.
from rippy.rip import makemkv_aufruf
from rippy.tools import katalog as werkzeug_katalog
urteil = await asyncio.to_thread(
makemkv_aufruf.key_setzen_und_pruefen, key,
werkzeug_katalog.finden("makemkv"))
await asyncio.to_thread(
db.add_log, "success" if urteil["ok"] else "warning", "makemkv-key",
"Beta-Key aus dem Forum geholt (…%s)%s%s"
% (key[-6:], "" if geaendert else " — war schon der aktuelle",
"" if urteil["ok"] else " — ABGELEHNT: " + urteil["grund"]))
# Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
# und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
# verlieren.
return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:],
"angenommen": urteil["ok"], "grund": urteil["grund"]}
@app.post("/system/keydb") @app.post("/system/keydb")
async def set_keydb(request: KeydbRequest): async def set_keydb(request: KeydbRequest):
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte). """Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
@@ -2656,16 +2963,180 @@ async def get_devices():
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon, prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status. der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
""" """
return [Device(**info) for info in
await asyncio.to_thread(laufwerke_mit_disc)]
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
_LETZTER_GRUND: Dict[str, str] = {}
def _grund_melden(pfad: str, grund: str) -> None:
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
Wechsel.
## Der Befund des Commanders (30.08.2026)
> „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar
> erkannt, aber ein start des rips ist nicht moeglich)"
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon.
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
beschreiben, sondern weiterhelfen.
"""
if _LETZTER_GRUND.get(pfad, "") == grund:
return
vorher = _LETZTER_GRUND.get(pfad, "")
_LETZTER_GRUND[pfad] = grund
try:
if grund:
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
elif vorher:
db.add_log("info", "watcher",
"Laufwerk %s antwortet wieder." % pfad)
except Exception: # noqa: BLE001
pass # Protokollieren darf die Laufwerksliste nie aufhalten
def _job_haelt_das_laufwerk(pfad: str) -> bool:
"""Laeuft auf diesem Laufwerk gerade ein Rip?
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
gehoert. Am Protokoll des Commanders abgelesen:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
12:50:12 makemkvcon endete mit Code 11
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
bereits, was drinliegt, der Job laeuft ja darauf.
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
"""
try:
return bool(db.has_active_job(pfad))
except Exception: # noqa: BLE001
return False # im Zweifel nachsehen, wie bisher
def laufwerke_mit_disc() -> list:
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
## Der Befund des Commanders (29.08.2026)
> „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht"
Auf der Server-Status-Kachel stand „Bereit — kein Datenträger im
Laufwerk", während die Disc erkannt war und ihr Titel eine Zeile weiter
oben in der Jobliste stand.
Die Ursache: Es gab ZWEI Wege zu den Laufwerken. `/devices` hängte die
erkannte Disc aus `DISC_CACHE` an; der Ereignis-Wächter rief nur
`device_info()` und ließ sie weg. Das UI liest seit V2-3 den Ereignisstrom
— also die Fassung ohne Disc. Es fragte `l.disc?.title`, bekam nichts, und
schloss daraus auf ein leeres Laufwerk.
Eine Auskunft in zwei Fassungen ist eine Auskunft zu viel. Jetzt gibt es
nur diese hier.
"""
geraete = [] geraete = []
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
for pfad in device_discovery.list_optical_devices(): for pfad in device_discovery.list_optical_devices():
info = await asyncio.to_thread(device_discovery.device_info, pfad) if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
info = {k: v for k, v in letzte[pfad].items()
if k not in ("disc", "disc_wird_erkannt")}
else:
info = device_discovery.device_info(pfad)
_grund_melden(pfad, info.get("grund") or "")
disc = DISC_CACHE.get(pfad) disc = DISC_CACHE.get(pfad)
if disc and not disc.get("_laeuft"): if disc and disc.get("_laeuft"):
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
# sagen, dass gerade gearbeitet wird: Zwei Minuten Schweigen
# sehen aus wie ein leeres Laufwerk (Commander 29.08.2026).
info["disc_wird_erkannt"] = True
elif disc:
info["disc"] = disc info["disc"] = disc
geraete.append(Device(**info)) geraete.append(info)
LETZTE_LAUFWERKE[:] = geraete
return geraete return geraete
#: Der zuletzt vollständig gelesene Laufwerks-Stand. Siehe `laufwerke_notdurft`.
LETZTE_LAUFWERKE: List[dict] = []
def laufwerke_notdurft() -> Optional[list]:
"""Was sich OHNE Laufwerks-Abfrage sagen lässt — oder None.
## Warum es diesen Rückfall gibt (Befund 29.08.2026)
Der Commander wollte sehen, dass die Disc-Erkennung noch läuft. Genau
dann ist die Auskunft aber am teuersten: Während `makemkvcon info` die
Disc liest, hält es das Laufwerk, und `device_info` wartet mit. Gemessen:
/devices waehrend des Scans 14,0 s
Zeitgrenze des Schnappschusses 5,0 s -> devices = None
`None` heißt „konnte nicht nachsehen", und die Oberfläche behält dann
ihren Stand — nach einem frischen Laden also **gar nichts**. Ausgerechnet
in der Phase, die sichtbar werden sollte, war der Bildschirm leer.
Der Ausweg braucht kein ioctl: Dass gerade erkannt wird, steht im
Vorrat (`DISC_CACHE`), und wie die Laufwerke heißen, wissen wir vom
letzten vollständigen Lauf. Beides zusammen ist eine ehrliche Auskunft —
„das sahen wir zuletzt, und an DIESEM Laufwerk arbeiten wir gerade".
`None`, wenn wir noch nie erfolgreich gelesen haben: Dann ist Schweigen
richtig, denn behaupten ließe sich nichts.
"""
bekannt = LETZTE_LAUFWERKE
if not bekannt:
# Kaltstart mitten in der Erkennung: Wir haben noch keinen Stand.
# Die LISTE der Laufwerke ist trotzdem billig (sie zaehlt nur
# Buchstaben bzw. /dev-Knoten auf) — teuer ist erst `device_info`,
# das das Laufwerk selbst anfasst. Also das Wenige melden, das
# sicher ist, und nichts erfinden: kein Typ, kein Modell.
try:
pfade_jetzt = device_discovery.list_optical_devices()
except OSError:
return None
bekannt = [{"id": p.rstrip(":\\").rsplit("\\", 1)[-1].rstrip(":") or p,
"name": p, "path": p, "type": "unknown", "status": "ready"}
for p in pfade_jetzt if (DISC_CACHE.get(p) or {}).get("_laeuft")]
if not bekannt:
return None
frisch = []
for alt in bekannt:
eintrag = dict(alt)
disc = DISC_CACHE.get(eintrag.get("path")) or {}
eintrag.pop("disc_wird_erkannt", None)
eintrag.pop("disc", None)
if disc.get("_laeuft"):
eintrag["disc_wird_erkannt"] = True
elif disc:
eintrag["disc"] = disc
frisch.append(eintrag)
return frisch
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch: # GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt # erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber # war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
+6 -2
View File
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
import os import os
import posixpath import posixpath
from rippy.platform.winlauf import OHNE_FENSTER
import re import re
import subprocess import subprocess
import tempfile import tempfile
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
ziel = _mountpoint(name) ziel = _mountpoint(name)
try: try:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["timeout", "3", "ls", ziel], capture_output=True, timeout=5 ["timeout", "3", "ls", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=5
) )
return ergebnis.returncode == 0 return ergebnis.returncode == 0
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
""" """
try: try:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6 ["timeout", "4", "ls", "-d", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=6
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return "unklar" return "unklar"
+7 -3
View File
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
NICHTS = "" NICHTS = ""
def _meta(meta_json) -> dict: def meta_von(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.""" """Metadaten lesen, ohne an kaputtem JSON zu scheitern.
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
"""
if isinstance(meta_json, dict): if isinstance(meta_json, dict):
return meta_json return meta_json
if not meta_json: if not meta_json:
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
""" """
if (job.get("status") or "") != "failed": if (job.get("status") or "") != "failed":
return NICHTS return NICHTS
marke = _meta(job.get("meta")).get(RIP_FERTIG) marke = meta_von(job.get("meta")).get(RIP_FERTIG)
if marke is True: if marke is True:
return NEU_KOMPRIMIEREN return NEU_KOMPRIMIEREN
if marke is False: if marke is False:
+36 -27
View File
@@ -10,7 +10,7 @@ import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
from rippy import drives as _laufwerks_schicht from rippy import drives as _laufwerks_schicht
from rippy.tools import katalog as werkzeug_katalog from rippy.platform.winlauf import OHNE_FENSTER
from clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from clients.jikan import JikanClient from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
@@ -113,12 +113,14 @@ def disc_wurzel(device_path: str):
ergebnis = subprocess.run( ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint], ["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER,
) )
if ergebnis.returncode != 0: if ergebnis.returncode != 0:
return None, lambda: None return None, lambda: None
def abhaengen(): def abhaengen():
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15) subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
creationflags=OHNE_FENSTER)
return mountpoint, abhaengen return mountpoint, abhaengen
@@ -329,7 +331,8 @@ class PreScan:
["cdparanoia", "-Q", device_path], ["cdparanoia", "-Q", device_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10 timeout=10,
creationflags=OHNE_FENSTER,
) )
for line in result.stdout.split('\n'): for line in result.stdout.split('\n'):
if 'track' in line.lower(): if 'track' in line.lower():
@@ -353,31 +356,37 @@ class PreScan:
if label: if label:
toc["title"] = normalize_disc_label(label) toc["title"] = normalize_disc_label(label)
# Titel-Quelle 3: die Titelliste von makemkvcon (optional). # Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
# #
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which` # ## Warum sie weg ist (Befund 30.08.2026)
# (Befund 28.08.2026). `which` sucht nur im PATH — unter #
# Windows liegen Programme in „Programme", nicht im PATH. # Der Commander: „das erkennen der disk dauert sehr sehr
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei # lange. Das ging mal viel schneller."
# installiertem MakeMKV None zurück, und dieser Zweig lief #
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py` # Hier stand ein `makemkvcon -r --noscan --minlength=300
# gibt. # info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
makemkv = werkzeug_katalog.finden("makemkv") # vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
if makemkv: # 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
result = subprocess.run( #
[makemkv, "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"], # Dass es frueher schnell war, hat einen unschoenen Grund:
capture_output=True, # Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
text=True, # `shutil.which`, und das findet unter Windows nichts
timeout=120 # (Programme liegen nicht im PATH). `be3fac5` hat das am
) # 28.08.2026 richtig repariert — und damit erst die Kosten
for line in result.stdout.split('\n'): # sichtbar gemacht, die hier immer schon standen.
if line.startswith('TINFO:'): #
parts = line.split(',') # Der Aufwand war umsonst: Das Ergebnis landete allein in
if len(parts) >= 5: # `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
toc["tracks"].append({ # holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title", # und `GET /devices/{id}/tracks` — also dann, wenn sie
"duration": int(parts[2]) if len(parts) > 2 else 0 # gebraucht wird, statt bei jedem Einlegen auf Verdacht.
}) # Der Weg fuer eine Disc, die man gar nicht rippen will,
# sind so zwei Minuten Warten fuer nichts.
#
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
# billig. Wer den Aufruf je wieder braucht, braucht dazu
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
# `tools.katalog` — die Importe sind mit ihm gegangen.
except Exception as e: except Exception as e:
print(f"Pre-Scan TOC Error: {e}") print(f"Pre-Scan TOC Error: {e}")
return toc return toc
+137 -15
View File
@@ -38,18 +38,71 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`. Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
""" """
import posixpath import os
import subprocess import subprocess
# Container-Standard für Roh-Rips (RAW_DIR im Worker). from rippy import pfade as _pfade
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
RAW_STANDARD = "/app/temp/raw" RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media" MEDIA_ROOT = "/app/media"
def _ui_einstellungen() -> dict:
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
gerade nicht antwortet.
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
einer klemmenden Datenbank nicht scheitern dann gilt eben die
Vorgabe, wie bisher.
"""
try:
from rippy import store as db
return db.get_settings(bei_fehler_leer=True) or {}
except Exception: # noqa: BLE001
return {}
def wurzeln(werte=None) -> tuple:
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
## Warum das nicht fest sein darf (Befund 29.08.2026)
Dieses Modul findet die Rohdaten eines Jobs wieder fuer den
Wiederholen-Dialog (auf der Platte liegen X GB Rohdaten") und fuer
Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
`/app/media`.
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
Dialog meldete keine Rohdaten", das Aufraeumen loeschte nichts, und die
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen bei einer
4K-UHD bis zu 100 GB.
"""
from rippy import betrieb, config
if werte is None:
try:
werte = config.laden()
except Exception: # noqa: BLE001
werte = {}
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
# was eingestellt ist (Befund 30.08.2026).
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
betrieb.frei_blaettern(werte))
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da(). # Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
PRUEF_TIMEOUT_SEKUNDEN = 4 PRUEF_TIMEOUT_SEKUNDEN = 4
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list: def kandidaten(job_id: str, work_dir: str, media_unterordner,
orte_wurzeln=None) -> list:
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure). """Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
`media_unterordner` sind die Namen der obersten Ebene unter /app/media `media_unterordner` sind die Namen der obersten Ebene unter /app/media
@@ -65,13 +118,37 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
""" """
if not job_id: if not job_id:
return [] return []
orte = [posixpath.join(RAW_STANDARD, job_id)] roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
wahl = (work_dir or "").strip().rstrip("/") from rippy import pfade
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
orte.append(posixpath.join(wahl, job_id)) orte = [pfade.verbinden(roh, job_id)]
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
#
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
#
# "F:\\".rstrip("/\\") -> "F:"
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
#
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
wahl = (work_dir or "").strip()
vergleich = wahl.rstrip("/\\")
grenze = (medien or "").rstrip("/\\")
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
if vergleich and (frei or vergleich == grenze
or vergleich.startswith(grenze + "/")):
orte.append(pfade.verbinden(wahl, job_id))
for name in media_unterordner or []: for name in media_unterordner or []:
if name: if name:
orte.append(posixpath.join(MEDIA_ROOT, name, job_id)) orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
gesehen, eindeutig = set(), [] gesehen, eindeutig = set(), []
for ort in orte: for ort in orte:
if ort not in gesehen: if ort not in gesehen:
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
return eindeutig return eindeutig
def nativ_nachsehen() -> bool:
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
Eigene Funktion, damit beide Zweige ueberall pruefbar sind dieselbe
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
`pruefen`.
"""
return os.name == "nt"
def pruefen(pfad: str, laufen=None) -> str: def pruefen(pfad: str, laufen=None) -> str:
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze. """Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
""" """
if not pfad: if not pfad:
return "weg" return "weg"
if laufen is None and nativ_nachsehen():
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
#
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
#
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
# 14:54:40 WindowsTerminal.exe
#
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
# gehen ganz kurz auf und dann wieder zu."
#
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
# unter Windows grundsaetzlich unsichtbar.
#
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
# Fehler zurueckkommen, nicht unabbrechbar haengen.
try:
return "da" if os.path.isdir(pfad) else "weg"
except OSError:
return "unklar"
starten = laufen or subprocess.run starten = laufen or subprocess.run
try: try:
ergebnis = starten( ergebnis = starten(
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
return pruefen(pfad, laufen) == "da" return pruefen(pfad, laufen) == "da"
def suche(job_id: str, work_dir: str, listdir, isdir) -> list: def suche(job_id: str, work_dir: str, listdir, isdir,
orte_wurzeln=None) -> list:
"""Die Orte, an denen wirklich etwas liegt. """Die Orte, an denen wirklich etwas liegt.
`listdir` und `isdir` werden übergeben statt importiert so ist die Suche `listdir` und `isdir` werden übergeben statt importiert so ist die Suche
@@ -156,12 +271,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und `listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
das ist ein lokales Verzeichnis die Freigaben sind Unterordner davon. das ist ein lokales Verzeichnis die Freigaben sind Unterordner davon.
""" """
orte_wurzeln = orte_wurzeln or wurzeln()
try: try:
unterordner = sorted(listdir(MEDIA_ROOT)) unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError: except OSError:
unterordner = [] unterordner = []
gefunden = [] gefunden = []
for ort in kandidaten(job_id, work_dir, unterordner): for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
try: try:
if isdir(ort): if isdir(ort):
gefunden.append(ort) gefunden.append(ort)
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
return gefunden return gefunden
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict: def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
orte_wurzeln=None) -> dict:
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist. """Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
@@ -181,12 +298,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
behalten, statt Abwesenheit zu behaupten (siehe pruefen()). behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
""" """
pruefe = pruefer or pruefen pruefe = pruefer or pruefen
orte_wurzeln = orte_wurzeln or wurzeln()
try: try:
unterordner = sorted(listdir(MEDIA_ROOT)) unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError: except OSError:
unterordner = [] unterordner = []
gefunden, unklar = [], False gefunden, unklar = [], False
for ort in kandidaten(job_id, work_dir, unterordner): for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
antwort = pruefe(ort) antwort = pruefe(ort)
if antwort == "da": if antwort == "da":
gefunden.append(ort) gefunden.append(ort)
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
except OSError: except OSError:
continue continue
for name in namen: for name in namen:
voll = posixpath.join(pfad, name) # `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
voll = _pfade.verbinden(pfad, name)
try: try:
if isfile(voll): if isfile(voll):
bytes_gesamt += getsize(voll) bytes_gesamt += getsize(voll)
+522 -1
View File
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: []) monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine # Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses. # Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
monkeypatch.setattr(main.db, "get_settings", lambda: {}) #
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
# einen der Parameter benutzte — `system_info` fiel damit aus dem
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
zustand = asyncio.run(main._snapshot()) zustand = asyncio.run(main._snapshot())
@@ -411,3 +419,516 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
# Ein Hinweis auf docker compose darf NUR im Container erscheinen. # Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]: if not daten["im_container"]:
assert daten["hilfe_befehl"] == "" assert daten["hilfe_befehl"] == ""
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
#
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
#
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
#
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
# auch `running` auf `processing` ab.
#
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
def _echte_db_zeile():
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
from datetime import datetime
return {
"id": "j1",
"disc_type": "bluray", # NICHT "type"
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
"finished_at": None,
"status": "running", # wird zu "processing"
"device": r"\.\G:",
"progress": 12,
"title": "Evangelion 2.22",
"error": None,
"meta": None,
}
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
import main
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
assert ui["type"] == "bluray"
assert ui["startTime"].startswith("2026-08-29")
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
Pflichtfelder leer und leer sieht im Browser aus wie eine Auskunft."""
import main
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
kurz = _job_kurz(ui)
for feld in UI_PFLICHTFELDER:
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
assert kurz["type"] == "bluray"
assert kurz["status"] == "processing"
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
import inspect
import main
quelle = inspect.getsource(main.startup_event)
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
assert "_job_row_to_model" in quelle
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
Commander: Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
(beide ohne). Das Dashboard liest den Schnappschuss und schloss aus der
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
weiter oben stand.
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
"""
import inspect
import main
quelle = inspect.getsource(main)
# Die eine erlaubte Fundstelle ist die Funktion selbst.
stellen = quelle.count("device_discovery.device_info(")
assert stellen == 1, (
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
for name in ("_snapshot", "startup_event", "get_devices"):
text = inspect.getsource(getattr(main, name))
assert "device_info(" not in text, \
"%s baut die Laufwerksliste selbst" % name
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraete = main.laufwerke_mit_disc()
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
{"_laeuft": True, "title": "Wird erkannt…"})
assert "disc" not in main.laufwerke_mit_disc()[0]
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
sein."
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg
damit keine halbfertige Disc-Karte mit Wird erkannt" als Titel und
einem Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
(gemessen: Disc nach 119 s erkannt).
"""
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
{"_laeuft": True, "title": "Wird erkannt…"})
geraet = main.laufwerke_mit_disc()[0]
assert geraet["disc_wird_erkannt"] is True
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
assert "disc" not in geraet
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraet = main.laufwerke_mit_disc()[0]
assert not geraet.get("disc_wird_erkannt")
assert geraet["disc"]["title"] == "Evangelion 2.22"
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
import main
assert "disc_wird_erkannt" in main.Device.model_fields
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
#
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
# sein soll.
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
"type": "bluray", "status": "ready"}])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
geraete = main.laufwerke_notdurft()
assert geraete[0]["disc_wird_erkannt"] is True
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
behalte deinen Stand", und das ist die ehrliche Antwort."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
assert main.laufwerke_notdurft() is None
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
nur das Abfragen des Laufwerks ist teuer."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
geraete = main.laufwerke_notdurft()
assert geraete[0]["disc_wird_erkannt"] is True
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
"type": "bluray", "status": "ready",
"disc_wird_erkannt": True}])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraet = main.laufwerke_notdurft()[0]
assert "disc_wird_erkannt" not in geraet
assert geraet["disc"]["title"] == "Evangelion 2.22"
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
#
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
# fenster ganz kurz im hintergrund."
#
# Beides war dieselbe Schleife: `except OSError: continue` liess
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
# naechste Statusabfrage scheitert — und so weiter.
def _CDS():
import main
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(False, None, ok) == "erststart"
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
import main
_, leer, _ = _CDS()
assert main.disc_entscheidung(False, None, leer) == ""
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
"""Der Kern des Befundes."""
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(True, ok, ok) == ""
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
noch nie gesehen" verwechselt, scannt endlos.
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
"""
import main
ok, _, _ = _CDS()
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
assert main.disc_entscheidung(True, ok, ok) == ""
def test_auswurf_wird_erkannt():
import main
ok, leer, offen = _CDS()
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
weg, sobald das Laufwerk kurz beschaeftigt ist."""
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(True, ok, 3) == ""
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
import inspect
import main
quelle = inspect.getsource(main.disc_watcher)
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
Commander: Über den kompletten vorgang steht dort Restzeit wird
gemessen' aber messung wird nicht abgeschlossen."
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
seine Jobs mit dem nackten `_job_row_to_model` also ohne. Seit V2-3
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
also brav berechnet und niemandem gezeigt.
"""
import inspect
import main
quelle = inspect.getsource(main._snapshot)
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
def test_die_jobliste_traegt_die_restzeit_felder():
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
import main
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
assert feld in main.Job.model_fields, feld
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
#
# Commander: „der Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
# bereits bei 30% und er liest immernoch."
#
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
# wissen wir — der Job laeuft ja auf ihr.
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
import asyncio
import main
gescannt = []
class NieBenutzt:
def scan(self, pfad):
gescannt.append(pfad)
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
monkeypatch.setattr(main, "PreScan", NieBenutzt)
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
main.DISC_CACHE.pop(r"\.\G:", None)
asyncio.run(main._auto_prescan(r"\.\G:"))
assert gescannt == []
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
import asyncio
import main
class Ergebnis:
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
def to_dict(self):
return {"title": "Akira"}
class Scanner:
def scan(self, pfad):
return Ergebnis()
monkeypatch.setattr(main, "PreScan", Scanner)
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
lambda p: asyncio.sleep(0))
main.DISC_CACHE.pop(r"\.\G:", None)
asyncio.run(main._auto_prescan(r"\.\G:"))
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
main.DISC_CACHE.pop(r"\.\G:", None)
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
Ende stehen der Scan davor haelt sie ja fest."""
import inspect
import main
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
if not quelle:
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
quelle = inspect.getsource(main)
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
#
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
#
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
import asyncio
import main
gestartet = []
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
def test_job_offen_zaehlt_die_kompression_mit():
from rippy.store import JOB_OFFEN
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
import inspect
from rippy import store
assert "transcoding" not in inspect.getsource(store.has_active_job)
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 makemkvcon endete mit Code 11
Der Waechter fragt alle drei Sekunden ab drei CreateFileW plus IOCTLs
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
"""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
stand = main.laufwerke_mit_disc()
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
main.laufwerke_mit_disc()
assert gefragt == ["/dev/sr0"]
+70
View File
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v)) speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
assert ergebnis == {"sekunden": -1, "text": ""} assert ergebnis == {"sekunden": -1, "text": ""}
assert speicher == {} # nichts geschrieben assert speicher == {} # nichts geschrieben
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
#
# Commander 29.08.2026: „Über den kompletten vorgang steht dort Restzeit wird
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
#
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
# Punkt, und `restzeit_sekunden` braucht zwei.
def _ohne_cache():
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
return (lambda k: None), (lambda k, v, expire=None: False)
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
"""DER Fall des Commanders."""
import eta
eta._REIHEN.clear()
lesen, schreiben = _ohne_cache()
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
def test_der_cache_hat_weiterhin_vorrang():
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
API-Neustart, das Woerterbuch im Prozess nicht."""
import eta
eta._REIHEN.clear()
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
ergebnis = eta.aktualisiere_und_schaetze(
"j2", "running", 25, 620.0,
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
assert ergebnis["sekunden"] > 0
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
"""Rip und Kompression haben nichts miteinander zu tun."""
import eta
eta._REIHEN.clear()
lesen, schreiben = _ohne_cache()
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
def test_alte_reihen_wachsen_nicht_unbegrenzt():
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
import eta
eta._REIHEN.clear()
lesen, schreiben = _ohne_cache()
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
eta.aktualisiere_und_schaetze("neu", "running", 10,
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
assert eta.schluessel("alt") not in eta._REIHEN
assert eta.schluessel("neu") in eta._REIHEN
+70
View File
@@ -1,4 +1,6 @@
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key).""" """Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
import pytest
from makemkv_key import extract_key from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]). # Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text") treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY assert treffer == _KEY
assert treffer.startswith("T-") assert treffer.startswith("T-")
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
#
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
# > ausgelesen werden, ich glaube das web rippy kann das"
#
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
@pytest.fixture
def klient():
from fastapi.testclient import TestClient
from main import app
return TestClient(app)
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["geholt"] is True
assert daten["geaendert"] is True
assert daten["endet_auf"] == _KEY[-6:]
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
ein zweiter Weg, ihn zu verlieren."""
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
text = klient.post("/system/makemkv-key/holen").text
assert _KEY not in text
assert _KEY[:20] not in text
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
monkeypatch):
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
def wirft(*a, **k):
raise OSError("Verbindung abgelehnt")
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "Forum" in antwort.json()["detail"]
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "geändert" in antwort.json()["detail"]
+13 -5
View File
@@ -197,10 +197,16 @@ def test_englisch_wird_bevorzugt(tmp_path):
assert titel_aus_bdmt(str(tmp_path)) == "English Title" assert titel_aus_bdmt(str(tmp_path)) == "English Title"
def test_makemkvcon_wird_ueber_den_katalog_gesucht(): def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei """Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
installiertem MakeMKV None zurueck -- Windows-Programme liegen in ENTFERNT er lief unter Windows nie (`shutil.which` findet dort nichts,
Programme", nicht im PATH. Dieser Zweig lief dort also NIE.""" Programme liegen in Programme", nicht im PATH) und war unter Docker
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
der Aufruf noch die shutil.which-Suche zurueckkommen.
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden`
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
die Entfernung deshalb erst dort gemeldet.)"""
import os import os
hier = os.path.dirname(os.path.abspath(__file__)) hier = os.path.dirname(os.path.abspath(__file__))
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
quelle = "".join(code) quelle = "".join(code)
assert "shutil.which(" not in quelle, ( assert "shutil.which(" not in quelle, (
"shutil.which findet unter Windows keine Programme aus 'Programme'") "shutil.which findet unter Windows keine Programme aus 'Programme'")
assert 'werkzeug_katalog.finden("makemkv")' in quelle assert "makemkvcon" not in quelle, (
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
+81 -7
View File
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
assert orte[0] == f"/app/temp/raw/{JOB}" assert orte[0] == f"/app/temp/raw/{JOB}"
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
# prompt sieben Tests rot geworden).
CONTAINER = ("/app/temp/raw", "/app/media", False)
def test_suche_liefert_nur_was_existiert(): def test_suche_liefert_nur_was_existiert():
vorhanden = {f"/app/media/rippy/{JOB}"} vorhanden = {f"/app/media/rippy/{JOB}"}
gefunden = rohdaten.suche( gefunden = rohdaten.suche(
JOB, "", JOB, "",
listdir=lambda p: ["movies", "rippy"], listdir=lambda p: ["movies", "rippy"],
isdir=lambda p: p in vorhanden, isdir=lambda p: p in vorhanden,
) orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"] assert gefunden == [f"/app/media/rippy/{JOB}"]
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
assert f"/app/media/{JOB}" in orte assert f"/app/media/{JOB}" in orte
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
#: Gegenstück zu CONTAINER weiter oben.
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
def test_laufwerks_wurzel_bleibt_absolut():
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
auf Laufwerk F, nicht dessen Wurzel die Suche sah damit an einer
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
beschädigten Datenträger für etwas, das schon dalag.
"""
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
assert "F:" + chr(92) + JOB in orte
assert "F:" + JOB not in orte
# Ohne Schluss-Trenner muss dasselbe herauskommen
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
"F:" + chr(92) + "Roh" + chr(92) + JOB)
def test_media_root_mit_schluss_trenner_zaehlt_auch():
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
nicht am Trenner scheitern."""
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
assert f"/app/media/{JOB}" in orte
def test_ohne_job_id_nichts(): def test_ohne_job_id_nichts():
assert rohdaten.kandidaten("", "/app/media", ["x"]) == [] assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
return p == f"/app/temp/raw/{JOB}" return p == f"/app/temp/raw/{JOB}"
gefunden = rohdaten.suche( gefunden = rohdaten.suche(
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt) JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"] assert gefunden == [f"/app/temp/raw/{JOB}"]
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
raise OSError("kein /app/media") raise OSError("kein /app/media")
gefunden = rohdaten.suche( gefunden = rohdaten.suche(
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True) JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"] assert gefunden == [f"/app/temp/raw/{JOB}"]
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
JOB, "", JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"], listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen), isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
) orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"] assert gefunden == [f"/app/media/rippy/{JOB}"]
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg" return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
e = rohdaten.suche_mit_status( e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe) JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [], "unklar": True} assert e == {"pfade": [], "unklar": True}
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg" return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
e = rohdaten.suche_mit_status( e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe) JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False} assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
return "unklar" if "totes-nas" in pfad else "weg" return "unklar" if "totes-nas" in pfad else "weg"
e = rohdaten.suche_mit_status( e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe) JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e["pfade"] == [f"/app/media/rippy/{JOB}"] assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
assert e["unklar"] is True assert e["unklar"] is True
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
14:54:40 WindowsTerminal.exe
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 also
die Antwort weg fuer JEDES Verzeichnis.
"""
gestartet = []
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
monkeypatch.setattr(rohdaten.subprocess, "run",
lambda *a, **k: gestartet.append(a))
assert rohdaten.pruefen(str(tmp_path)) == "da"
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
assert gestartet == [], "es darf kein Prozess gestartet werden"
def test_im_container_bleibt_der_kindprozess(monkeypatch):
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
aufrufe = []
class Antwort:
returncode = 0
monkeypatch.setattr(rohdaten.subprocess, "run",
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
assert rohdaten.pruefen("/app/media/x") == "da"
assert aufrufe[0][0] == "timeout"
assert aufrufe[0][-1] == "/app/media/x"
+21 -9
View File
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api' import { api } from './lib/api'
import { EventStreamProvider, useStrom } from './lib/useEventStream' import { EventStreamProvider, useStrom } from './lib/useEventStream'
import { BetriebProvider } from './lib/useBetrieb' import { BetriebProvider } from './lib/useBetrieb'
import { Fehlergrenze } from './components/Fehlergrenze'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings' type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
@@ -161,10 +162,19 @@ function AppInhalt() {
{/* Main Content Viewport */} {/* Main Content Viewport */}
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
{currentPage === 'dashboard' && <Dashboard />} {/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
{currentPage === 'anleitung' && <AnleitungPage />} Navigation stehen und man kommt woanders hin. Ohne das war am
{currentPage === 'logs' && <LogsPage />} 29.08.2026 nach einem Klick auf Rippen starten" der ganze
{currentPage === 'settings' && <SettingsPage />} Bildschirm leer samt Kopfzeile. */}
<Fehlergrenze bereich={
currentPage === 'dashboard' ? 'Das Dashboard'
: currentPage === 'anleitung' ? 'Die Anleitung'
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
{currentPage === 'dashboard' && <Dashboard />}
{currentPage === 'anleitung' && <AnleitungPage />}
{currentPage === 'logs' && <LogsPage />}
{currentPage === 'settings' && <SettingsPage />}
</Fehlergrenze>
</main> </main>
</div> </div>
) )
@@ -178,10 +188,12 @@ export default function App() {
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker // vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
// compose ps" — einen Rat, den dort niemand befolgen kann. // compose ps" — einen Rat, den dort niemand befolgen kann.
return ( return (
<BetriebProvider> <Fehlergrenze>
<EventStreamProvider> <BetriebProvider>
<AppInhalt /> <EventStreamProvider>
</EventStreamProvider> <AppInhalt />
</BetriebProvider> </EventStreamProvider>
</BetriebProvider>
</Fehlergrenze>
) )
} }
@@ -36,6 +36,8 @@ interface Device {
serial?: string serial?: string
model?: string model?: string
disc?: DiscInfo disc?: DiscInfo
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
disc_wird_erkannt?: boolean
} }
function posterUrl(disc?: DiscInfo): string | null { function posterUrl(disc?: DiscInfo): string | null {
@@ -179,6 +181,34 @@ export default function DeviceDiscovery() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/*
Die Erkennung LÄUFT NOCH das muss man sehen.
Commander 29.08.2026: das die disc erkennung noch läuft muss
sichtbar sein". An einer Blu-ray dauert sie rund zwei Minuten
(`makemkvcon info` läuft in seine 120-Sekunden-Grenze; gemessen:
Disc nach 119 s erkannt). Vorher war in dieser Zeit NICHTS zu
sehen weder die Disc noch ein Hinweis. Das sah aus wie ein
leeres Laufwerk, also wie ein Fehler.
*/}
{devices.filter(d => d.disc_wird_erkannt).map(device => (
<div
key={`erkennung-${device.id}`}
className="mb-6 rounded-2xl border border-slate-700 bg-slate-900/60 px-5 py-4 flex items-center gap-3"
>
<RefreshCw size={18} className="animate-spin text-amber-400 flex-shrink-0" />
<div>
<p className="font-semibold text-slate-200">
Disc wird gelesen {device.name}
</p>
<p className="text-xs text-slate-400 mt-0.5">
Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis
zu zwei Minuten; danach steht hier, was drin ist.
</p>
</div>
</div>
))}
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */} {/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
{devices.filter(d => d.status === 'ready' && d.disc).map(device => ( {devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div <div
+100
View File
@@ -0,0 +1,100 @@
/*
* Eine Fehlergrenze damit ein Fehler nicht die ganze Oberfläche verschluckt.
*
* ## Der Befund des Commanders (29.08.2026)
*
* > wenn man auf rippen starten klickt passiert irgendwas, was das Programm
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
* > auch keine fehlermeldung."
*
* Nachgestellt und in der Browser-Konsole gemessen:
*
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
*
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
* 12 %). Nur zeigte die Oberfläche das nie sie war weg. Grund: React baut
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
* einzige Fehlergrenze.
*
* ## Warum die eigentliche Reparatur woanders sitzt
*
* Die Ursache war ein Ereignis, das den halben Job schickte
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
*
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
* mitnehmen und schon gar nicht schweigend. Der nächste Fehler dieser Art
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
* Oberfläche bleibt bedienbar.
*
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
*/
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
/** Was hier kaputtgehen kann — steht in der Meldung. */
bereich?: string
}
interface State {
fehler: Error | null
}
export class Fehlergrenze extends Component<Props, State> {
state: State = { fehler: null }
static getDerivedStateFromError(fehler: Error): State {
return { fehler }
}
componentDidCatch(fehler: Error, info: ErrorInfo) {
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
fehler, info.componentStack)
}
neuLaden = () => {
this.setState({ fehler: null })
}
render() {
if (!this.state.fehler) return this.props.children
return (
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
{this.props.bereich
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
</h2>
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
Der Fehler betrifft nur die Anzeige.{' '}
<strong>Laufende Rips gehen weiter</strong> Rippy arbeitet im
Hintergrund, auch wenn dieser Bereich leer bleibt.
</p>
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
{this.state.fehler.message || String(this.state.fehler)}
</p>
<div className="flex gap-2 mt-4">
<button
onClick={this.neuLaden}
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
>
Nochmal versuchen
</button>
<button
onClick={() => window.location.reload()}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
>
Seite neu laden
</button>
</div>
</div>
)
}
}
+4 -1
View File
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
<CardTitle>Live-Log</CardTitle> <CardTitle>Live-Log</CardTitle>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
{currentJob {currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)` // `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
: 'Kein aktiver Job'} : 'Kein aktiver Job'}
</p> </p>
</div> </div>
+162
View File
@@ -0,0 +1,162 @@
/*
* Ein Pfadfeld mit Durchsuchen " wie im Installer.
*
* ## Der Wunsch des Commanders (29.08.2026)
*
* > Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das
* > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt.
* > Wie es der Installer auch macht"
*
* Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt
* man den Ordner, danach musste man ihn abtippen. Einen Pfad wie
* `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig.
*
* ## Warum eine eigene Datei
*
* Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre:
* `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in
* ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen.
*
* Der Browser selbst ist die API `/browse` weiß seit dem 28.08.2026, in
* welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste
* der Laufwerke. Diese Komponente muss darüber nichts wissen.
*/
import { useEffect, useState } from 'react'
import { ArrowUp, Folder, FolderOpen } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
interface Eintrag {
name: string
path: string
}
interface Props {
label: string
value: string
placeholder?: string
onChange: (pfad: string) => void
}
export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) {
const [offen, setOffen] = useState(false)
const [pfad, setPfad] = useState('')
const [eltern, setEltern] = useState<string | null>(null)
const [ordner, setOrdner] = useState<Eintrag[]>([])
const [fehler, setFehler] = useState('')
const [laedt, setLaedt] = useState(false)
const laden = async (ziel: string) => {
setLaedt(true)
setFehler('')
try {
const r = await api.get('/browse', { params: { path: ziel } })
setPfad(r.data.path || '')
setEltern(r.data.parent)
setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : [])
} catch (e: any) {
// Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen —
// man kommt mit „nach oben" wieder heraus.
setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar')
setOrdner([])
} finally {
setLaedt(false)
}
}
useEffect(() => {
if (!offen) return
// Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt
// für /browse „oberste Ebene" — nativ also die Laufwerksliste.
laden(value || '')
}, [offen])
return (
<div className="space-y-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label={label}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
<Button variant="secondary" onClick={() => setOffen(true)}>
Durchsuchen
</Button>
</div>
<Modal
isOpen={offen}
onClose={() => setOffen(false)}
title={label}
maxWidth="lg"
>
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => eltern !== null && laden(eltern)}
disabled={eltern === null}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
title="Eine Ebene höher"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
{pfad || 'Laufwerke'}
</span>
</div>
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
{laedt && (
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen </p>
)}
{!laedt && fehler && (
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
)}
{!laedt && !fehler && ordner.length === 0 && (
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
)}
{ordner.map((o) => (
<button
key={o.path}
onClick={() => laden(o.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{o.name}
</button>
))}
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<FolderOpen size={13} />
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button
variant="amber"
disabled={!pfad}
onClick={() => {
onChange(pfad)
setOffen(false)
}}
>
Diesen Ordner nutzen
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}
+10 -6
View File
@@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
const betrieb = useBetrieb() const betrieb = useBetrieb()
const [targets, setTargets] = useState<TargetConfig[]>([ /*
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, * Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, *
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, * Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
]) * Windows-PC gibt es die nicht und fuer den Augenblick zwischen Oeffnen
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
*/
const [targets, setTargets] = useState<TargetConfig[]>([])
const [browsePath, setBrowsePath] = useState<string>('') const [browsePath, setBrowsePath] = useState<string>('')
const [browseParent, setBrowseParent] = useState<string | null>(null) const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([]) const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
@@ -223,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen)) setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen)) setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' const basis = s.outputDir || betrieb.ablage_vorgabe
setTargets([ setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
+4 -1
View File
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
} }
export function TypeBadge({ type, className = '' }: TypeBadgeProps) { export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || { const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30', badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: type.toUpperCase(), label: type ? type.toUpperCase() : 'DISC',
} }
return ( return (
+5 -2
View File
@@ -77,8 +77,11 @@ const VORGABE: Betrieb = {
werkzeuge_verwalten: false, werkzeuge_verwalten: false,
frei_blaettern: false, frei_blaettern: false,
}, },
ablage_vorgabe: '/app/media', // Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
arbeits_vorgabe: '/app/temp', // Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
ablage_vorgabe: '',
arbeits_vorgabe: '',
hilfe_befehl: 'docker compose -p rippy ps', hilfe_befehl: 'docker compose -p rippy ps',
geladen: false, geladen: false,
} }
+81 -4
View File
@@ -44,7 +44,8 @@ interface Job {
interface SystemInfo { interface SystemInfo {
api_version: string api_version: string
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[] plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean;
frei_gb: number; gesamt_gb: number }[]
workers: { name: string; encoders: string[] }[] workers: { name: string; encoders: string[] }[]
} }
@@ -65,6 +66,9 @@ interface LaufwerkLive {
id: string id: string
status: string status: string
disc?: { title?: string, year?: number | null, disc_type?: string } disc?: { title?: string, year?: number | null, disc_type?: string }
/** Die Erkennung laeuft noch es gibt noch keinen Titel, aber auch kein
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
disc_wird_erkannt?: boolean
} }
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media // Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
@@ -328,7 +332,13 @@ export default function Dashboard() {
: 'Rip läuft (MakeMKV, verlustfrei)') : 'Rip läuft (MakeMKV, verlustfrei)')
: discImLaufwerk : discImLaufwerk
? 'Disc erkannt — wartet auf „Rippen starten"' ? 'Disc erkannt — wartet auf „Rippen starten"'
: 'Bereit — kein Datenträger im Laufwerk' // Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
// Fehler (Commander 29.08.2026).
: laufwerke.some(l => l.disc_wird_erkannt)
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
: 'Bereit — kein Datenträger im Laufwerk'
const filteredJobs = jobs.filter(j => { const filteredJobs = jobs.filter(j => {
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding' if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
@@ -584,6 +594,38 @@ export default function Dashboard() {
Details Details
</Button> </Button>
{/*
Abbrechen DIREKT in der Zeile (Commander
29.08.2026: man kann einen rip garnicht
abbrechen").
Es gab genau einen Abbrechen-Knopf, und der
steckte in der Kachel laufender Job". Die
erschien nur bei Status `processing` der
Ereignisstrom lieferte aber den rohen
DB-Status `running`. Also keine Kachel, also
kein Knopf, also kein Weg zurueck.
Die Ursache ist behoben; ein zweiter Weg zum
Abbrechen ist trotzdem richtig, denn genau
hier sucht man ihn.
*/}
{['processing', 'transcoding', 'pending'].includes(job.status) && (
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`)
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
.catch((e: any) => toast('error',
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
>
Abbrechen
</Button>
)}
{job.status === 'canceling' && (
<span className="text-xs font-mono text-slate-400">bricht ab </span>
)}
{job.status === 'completed' && ( {job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}> <Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download <Download size={13} /> Download
@@ -749,10 +791,45 @@ export default function Dashboard() {
wollte, ob die NAS noch Platz für eine Disc hat, sah die wollte, ob die NAS noch Platz für eine Disc hat, sah die
falsche Zahl (Commander-Befund 26.07.2026). */} falsche Zahl (Commander-Befund 26.07.2026). */}
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> {/*
Jeder Ort mit seinem PFAD (Commander 29.08.2026): bei
Platz für rippy' sollte eher das arbeitsverzeichnis und
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
für den ERSTEN Ort welcher Ordner das war, sah man nicht.
*/}
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
<div key={ort.name + ort.pfad} className="mb-1.5">
<div className="flex items-center justify-between text-xs font-mono">
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" />
{ort.name}:
</span>
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
? 'text-amber-400 font-bold' : 'text-slate-200'}>
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
</span>
</div>
{ort.pfad && (
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
{ort.pfad}
</p>
)}
</div>
))}
{!betrieb.kann.container_pfade
&& (systemInfo?.plaetze || []).length > 1
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist
statt die zweite Zeile wegzulassen. */
<p className="text-[11px] font-mono text-slate-500 mb-1">
Beide auf demselben Laufwerk der Platz wird geteilt.
</p>
)}
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" /> <HardDrive size={14} className="text-amber-400" />
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'} Container-Platte:
</span> </span>
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}> <span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'} {freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
+83 -11
View File
@@ -9,6 +9,7 @@ import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input' import { Input, Select, Toggle } from '../components/ui/Input'
import { OrdnerWaehler } from '../components/OrdnerWaehler'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design' import { ENCODER_BADGES } from '../lib/design'
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder' import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
@@ -213,6 +214,9 @@ export default function SettingsPage() {
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die // Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen). // Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([]) const [ziele, setZiele] = useState<StorageZiel[]>([])
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
const [keyHolt, setKeyHolt] = useState(false)
const [keyMeldung, setKeyMeldung] = useState('')
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null) const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false) const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null) const keystoreInput = useRef<HTMLInputElement>(null)
@@ -253,6 +257,43 @@ export default function SettingsPage() {
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([])) api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
} }
/*
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
*
* Der geholte Key wird nicht angezeigt er wandert in die Einstellungen,
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
* zwei Wege, ihn zu verlieren.
*/
const keyJetztHolen = async () => {
setKeyHolt(true)
setKeyMeldung('')
try {
const r = await api.post('/system/makemkv-key/holen')
const antwort = await api.get('/settings')
setSettings({ ...defaultSettings, ...antwort.data })
if (r.data?.angenommen === false) {
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
// trotzdem.
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
} else {
setKeyMeldung(r.data?.geaendert
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
}
} catch (e: any) {
const grund = e?.response?.data?.detail
|| 'Das Forum ist nicht erreichbar.'
setKeyMeldung('Fehlgeschlagen: ' + grund)
toast('error', grund)
} finally {
setKeyHolt(false)
}
}
const keydbHochladen = async (datei: File) => { const keydbHochladen = async (datei: File) => {
setKeydbBusy(true) setKeydbBusy(true)
try { try {
@@ -960,11 +1001,11 @@ export default function SettingsPage() {
* Liste kennt D:\Rippy-Arbeit oder eine UNC-Freigabe. * Liste kennt D:\Rippy-Arbeit oder eine UNC-Freigabe.
* Deshalb hier ein Feld, in das man ihn schreiben kann. * Deshalb hier ein Feld, in das man ihn schreiben kann.
*/ */
<Input <OrdnerWaehler
label="Arbeitsverzeichnis für Roh-Rips (Standard)" label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir} value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe} placeholder={betrieb.arbeits_vorgabe}
onChange={(e) => handleChange('workDir', e.target.value)} onChange={(pfad) => handleChange('workDir', pfad)}
/> />
) : ( ) : (
<Select <Select
@@ -1057,11 +1098,11 @@ export default function SettingsPage() {
der man waehlen koennte es gibt Laufwerke, Ordner und der man waehlen koennte es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */ Bedienung, die nichts bedient. */
<Input <OrdnerWaehler
label="Ordner" label="Ordner"
value={settings.outputDir} value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe} placeholder={betrieb.ablage_vorgabe}
onChange={(e) => handleChange('outputDir', e.target.value)} onChange={(pfad) => handleChange('outputDir', pfad)}
/> />
) : ( ) : (
<Select <Select
@@ -1295,13 +1336,44 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
<Input {/*
type="password" Der Beta-Key holt sich selbst man sah es nur nirgends.
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey} Commander am 28.08.2026: Bezüglich des MKV Beta Keys der
onChange={(e) => handleChange('makemkvAppKey', e.target.value)} könnte theoretisch auch automatisch ausgelesen werden, ich
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)" glaube das web rippy kann das." Er hatte recht:
/> `makemkv_key.refresh_loop()` läuft täglich mit, seit es die
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
Zeichen). Hier stand aber nur ein Passwortfeld ob der Wert
darin von Hand kam oder von selbst, war nicht zu erkennen.
Eine Automatik, die man nicht sehen kann, ist für den
Benutzer keine.
*/}
<div className="space-y-2">
<Input
type="password"
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey}
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
disabled={keyHolt}
onClick={keyJetztHolen}
>
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
</Button>
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'}`}>
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ 'der Key wechselt etwa monatlich.'}
</span>
</div>
</div>
{/* {/*
Schlüsselspeicher der Hauptweg für 4K-UHD (Befund 25.07.2026, Schlüsselspeicher der Hauptweg für 4K-UHD (Befund 25.07.2026,
+161 -35
View File
@@ -47,7 +47,7 @@ import time
import requests import requests
from rippy import store as db from rippy import store as db
from rippy.rip import makemkv_daten from rippy.rip import makemkv_aufruf, makemkv_daten
import medien import medien
from rippy.core import notify from rippy.core import notify
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
return pfad == sauber or pfad.startswith(sauber + "/") return pfad == sauber or pfad.startswith(sauber + "/")
def _zielbasis(target_dir, disc_type: str) -> str: def _zielbasis(target_dir, disc_type: str, wurzeln=None) -> str:
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard. """Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
posixpath statt os.path aus demselben Grund wie in _arbeitsverzeichnis: posixpath statt os.path aus demselben Grund wie in _arbeitsverzeichnis:
@@ -167,16 +167,70 @@ def _zielbasis(target_dir, disc_type: str) -> str:
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt. wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
""" """
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
if target_dir: if target_dir:
normalisiert = posixpath.normpath(target_dir) normalisiert = _normalisiert(target_dir)
if unter_wurzel(normalisiert, MEDIA_ROOT): if frei or unter_wurzel(normalisiert, wurzel):
return normalisiert return normalisiert
return posixpath.join(RIP_OUTPUT_DIR, disc_type) # Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
from rippy import pfade
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str: def _betriebs_wurzeln() -> tuple:
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
## Warum das hier gebraucht wird (Befund 29.08.2026)
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
Wahl** ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
also fiel er still auf den Container-Standard zurück.
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
bestellt hat (kannst du noch einbauen das man den arbeitsordner setzen
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
der Worker ignorierte ihn ohne ein Wort.
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
`frei` ist falsch.
"""
from rippy import betrieb, config
try:
werte = config.laden()
except Exception: # noqa: BLE001
werte = {}
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
betrieb.frei_blaettern(werte))
def _normalisiert(wert: str) -> str:
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
"""
from rippy import pfade
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
else posixpath.normpath(wert)
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
wurzeln=None) -> str:
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting """Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
`workDir` Container-Default /app/temp/raw. `workDir` Vorgabe dieses Betriebs (Container: /app/temp/raw).
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50, Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie
@@ -187,26 +241,48 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
Standard und ist damit der Wert, der bei Vollautomatik-Rips greift, bei Standard und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
denen niemand gefragt wird. denen niemand gefragt wird.
""" """
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...), wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
for kandidat in (job_wahl, einstellungen.get("workDir")): for kandidat in (job_wahl, einstellungen.get("workDir")):
wert = (kandidat or "").strip() wert = (kandidat or "").strip()
if wert: if not wert:
normalisiert = posixpath.normpath(wert) continue
if unter_wurzel(normalisiert, MEDIA_ROOT): normalisiert = _normalisiert(wert)
return normalisiert # Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
return RAW_DIR # Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
# von /app/media wäre dort ein Pfad ins Nichts.
if frei or unter_wurzel(normalisiert, wurzel):
return normalisiert
return vorgabe
def _frei_bytes(pfad: str) -> int: def _frei_bytes(pfad: str) -> int:
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).""" """Freier Platz am Pfad (nächster existierender Elternordner zählt).
kandidat = pfad
while kandidat and not os.path.exists(kandidat): ## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
kandidat = os.path.dirname(kandidat)
An dieser Stelle stand sie als eigene Schleife:
while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(kandidat)
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck an
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
Meldung stehen, und im Protokoll stuende nichts, womit man das
aufklären koennte.
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig mit
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
"""
from rippy import pfade
try: try:
return shutil.disk_usage(kandidat or "/").free return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
except OSError: except OSError:
return -1 return -1
@@ -243,17 +319,27 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
key = (einstellungen.get("makemkvAppKey") or "").strip() key = (einstellungen.get("makemkvAppKey") or "").strip()
if not key: if not key:
return return
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
try: try:
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True) # WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
try: # die settings.conf. Begruendung und Messung in
with open(pfad, encoding="utf-8", errors="replace") as f: # `rippy.rip.makemkv_aufruf.key_ablegen`.
alt = f.read() #
except OSError: # UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
alt = "" # ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
with open(pfad, "w", encoding="utf-8", newline="\n") as f: # einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key)) # paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
except OSError as e: # laeuft und sonst am ersten Titel scheitert.
from ripping import werkzeug
# Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start
# tut — nur in der Windows-Form.
makemkv_aufruf.einstellungen_sicherstellen()
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
if not urteil["ok"]:
db.add_log("warning", "worker",
"MakeMKV nimmt den hinterlegten Beta-Key nicht an: %s"
% urteil["grund"])
except (OSError, ImportError) as e:
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}") db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
@@ -547,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen: if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
return return
gesehen.add(text) gesehen.add(text)
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}") # Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
# sind uebersetzt, die Nummern nicht — sie sind die einzige
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
# ersatzweise an einer URL im Text festhalten.
db.add_log("info", "makemkv",
f"Job {job_id}: MSG {code}{text[:300]}")
einstellungen = db.get_settings(bei_fehler_leer=True) einstellungen = db.get_settings(bei_fehler_leer=True)
ist_video = disc_type in ("dvd", "bluray", "uhd") ist_video = disc_type in ("dvd", "bluray", "uhd")
@@ -675,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
"Normale BD/DVD gehen weiterhin." "Normale BD/DVD gehen weiterhin."
) )
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
# eine halbe Disc für eine ganze.
if ergebnis.get("lesefehler"):
db.add_log(
"warning", "worker",
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
"von innen nach außen, nicht kreisend) oder ein anderes "
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
)
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die # Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
# Kompression arbeitet auf der Datei, nicht am Laufwerk. # Kompression arbeitet auf der Datei, nicht am Laufwerk.
# #
@@ -802,7 +910,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
final_container: str, final_lokal: str) -> str: final_container: str, final_lokal: str,
fremd=None) -> str:
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "". """Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
@@ -824,7 +933,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
Quelle sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit. Quelle sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
""" """
mapping = os.getenv("RIPPY_PATH_MAP", "") mapping = os.getenv("RIPPY_PATH_MAP", "")
fremder_worker = not os.path.isdir("/app") # ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
#
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
# damit an den laufenden Rechner gebunden.
if fremd is None:
from caps import _extern
fremd = _extern() == "ja"
fremder_worker = fremd
for zweck, container, lokal in ( for zweck, container, lokal in (
("die Quelle (Rohdaten)", raw_container, raw_lokal), ("die Quelle (Rohdaten)", raw_container, raw_lokal),
@@ -1002,7 +1124,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
"status": "error", "status": "error",
"error": ( "error": (
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: " f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten" # Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
# Roh-Datei retten will, sucht sonst am falschen Ort.
f"{hb.get('error')} — Roh-Datei bleibt unter "
f"{os.path.dirname(quelle)} erhalten"
), ),
} }
_job_abschliessen(job_id, ergebnis) _job_abschliessen(job_id, ergebnis)
+58 -8
View File
@@ -8,13 +8,39 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
import os import os
import platform import platform
import re import re
import shutil
import subprocess import subprocess
from rippy.platform.winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.tools import katalog as werkzeuge from rippy.tools import katalog as werkzeuge
def _extern(werte=None, container=None) -> str:
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
Drei Fälle, und nur einer davon ist ja":
* **Im Container** der Worker ist Teil von Rippy. Nein.
* **Nativ und eigenständig** (Windows-App) dieser Prozess IST Rippy.
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
etwas zu übersetzen wäre.
* **Nativ und verteilt** ein Worker auf einem anderen Rechner, der sich
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
"""
from rippy import betrieb, config
if container is None:
container = betrieb.im_container()
if container:
return "nein"
if werte is None:
try:
werte = config.laden()
except Exception: # noqa: BLE001
werte = {}
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
def _hb() -> str: def _hb() -> str:
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist. """Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
@@ -166,7 +192,7 @@ def hole_handbrake_presets() -> list:
try: try:
aus = subprocess.run( aus = subprocess.run(
[_hb(), "--preset-list"], [_hb(), "--preset-list"],
capture_output=True, text=True, timeout=30, capture_output=True, timeout=30, **HB_LESEN,
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
return parse_preset_liste((aus.stdout or "") + (aus.stderr or "")) return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
@@ -279,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
return "" return ""
try: try:
aus = subprocess.run( aus = subprocess.run(
[_hb(), "--help"], capture_output=True, text=True, timeout=30, [_hb(), "--help"], capture_output=True, timeout=30,
creationflags=OHNE_FENSTER creationflags=OHNE_FENSTER, **HB_LESEN
) )
return (aus.stdout or "") + (aus.stderr or "") return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
@@ -343,8 +369,18 @@ def werkzeug_versionen() -> dict:
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus # Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt # RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026). # den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel. #
"extern": "nein" if os.path.isdir("/app") else "ja", # ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
# Maschine, auf der es weder Container noch Freigabe gibt.
#
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
# Ordnername.
"extern": _extern(),
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker # Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
# grundsätzlich nicht komprimieren. # grundsätzlich nicht komprimieren.
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""), "pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
@@ -368,7 +404,7 @@ def werkzeug_versionen() -> dict:
try: try:
aus = subprocess.run( aus = subprocess.run(
[_hb(), "--version"], [_hb(), "--version"],
capture_output=True, text=True, timeout=15, capture_output=True, timeout=15, **HB_LESEN,
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or "")) treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
@@ -402,7 +438,21 @@ def werkzeug_versionen() -> dict:
daten_dir = makemkv_daten.DATEN_DIR daten_dir = makemkv_daten.DATEN_DIR
except Exception: except Exception:
daten_dir = "" daten_dir = ""
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir): # ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
#
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
from rippy import betrieb
_im_container = betrieb.im_container()
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
else os.path.isdir(daten_dir))
if werkzeuge.finden("makemkv") and daten_da:
try: try:
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein" info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
except Exception: except Exception:
+349 -17
View File
@@ -37,6 +37,9 @@ from rippy.drives.linux import ( # noqa: F401
) )
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401 from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from rippy.platform.winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.tools import katalog as werkzeuge from rippy.tools import katalog as werkzeuge
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"} _NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
@@ -86,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None return shutil.which("cdparanoia") is not None
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
#:
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
#: deutschsprachiges MakeMKV):
#:
#: Encountered 29 errors of type 'Read Error' - see
#: http://www.makemkv.com/errors/read/
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
#:
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
#: hierher statt dieser Textsuche.
LESEFEHLER_MARKE = "makemkv.com/errors/read"
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list: def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar). """Baut das MakeMKV-Kommando (pure Funktion, testbar).
@@ -102,7 +123,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
"--noscan", "--noscan",
"--progress=-same", "--progress=-same",
"mkv", "mkv",
f"dev:{device_path}", makemkv_quelle(device_path),
str(titel), str(titel),
output_dir, output_dir,
] ]
@@ -257,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
wäre eine Minute Wartezeit für nichts. wäre eine Minute Wartezeit für nichts.
""" """
ergebnis = subprocess.run( ergebnis = subprocess.run(
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"], [werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, text=True, timeout=timeout, capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
ausgabe = ergebnis.stdout or "" ausgabe = text_von(ergebnis.stdout)
return parse_titel_info(ausgabe), parse_stream_info(ausgabe) return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
@@ -271,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
Titel oder 'all' also ein Aufruf je Titel, Fortschritt anteilig).""" Titel oder 'all' also ein Aufruf je Titel, Fortschritt anteilig)."""
gesamt = len(titel_liste) gesamt = len(titel_liste)
alle_dateien = [] alle_dateien = []
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
lesefehler = False
for index, nr in enumerate(titel_liste): for index, nr in enumerate(titel_liste):
def anteilig(p, _i=index): def anteilig(p, _i=index):
if progress_cb: if progress_cb:
@@ -278,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr), ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
log_cb=log_cb) log_cb=log_cb)
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
if ergebnis.get("status") == "cancelled": if ergebnis.get("status") == "cancelled":
return ergebnis return ergebnis
if ergebnis.get("status") != "success": if ergebnis.get("status") != "success":
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}" ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
ergebnis["lesefehler"] = lesefehler
return ergebnis return ergebnis
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
return {"status": "success", "output_dir": output_dir, "files": alle_dateien} return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
"lesefehler": lesefehler}
def laengster_titel(dauern: dict, meta: dict = None): def laengster_titel(dauern: dict, meta: dict = None):
@@ -330,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode).""" """Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try: try:
ergebnis = subprocess.run( ergebnis = subprocess.run(
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"], [werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, text=True, timeout=timeout, capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return {} return {}
return parse_tinfo_dauern(ergebnis.stdout or "") return parse_tinfo_dauern(text_von(ergebnis.stdout))
def parse_scan_dauer(ausgabe: str) -> int: def parse_scan_dauer(ausgabe: str) -> int:
@@ -360,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
try: try:
ergebnis = subprocess.run( ergebnis = subprocess.run(
[werkzeug("handbrake"), "--scan", "-i", pfad], [werkzeug("handbrake"), "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout, capture_output=True, timeout=timeout, **HB_LESEN,
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
@@ -474,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
".mov": "av_mov", ".webm": "av_webm"}
def build_handbrake_cmd(input_path: str, output_path: str, def build_handbrake_cmd(input_path: str, output_path: str,
preset: str = DEFAULT_HB_PRESET, preset: str = DEFAULT_HB_PRESET,
audio_sprachen=None, untertitel_sprachen=None) -> list: audio_sprachen=None, untertitel_sprachen=None) -> list:
@@ -499,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str,
Sprachen drin und das ist bei einer verlustfreien Ablage richtig. Sprachen drin und das ist bei einer verlustfreien Ablage richtig.
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro `--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen. diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
""" """
befehl = [ befehl = [
@@ -508,11 +541,46 @@ def build_handbrake_cmd(input_path: str, output_path: str,
"--output", output_path, "--output", output_path,
"--preset", preset, "--preset", preset,
] ]
# Den Container zur Endung erzwingen (Befund 29.08.2026).
#
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
# endete mit Code 0"
#
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
# nichts und nannte das „fehlgeschlagen".
#
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
# die auseinanderlaufen koennen.
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
if format_name:
befehl += ["--format", format_name]
audio = [s for s in (audio_sprachen or []) if s] audio = [s for s in (audio_sprachen or []) if s]
if audio: if audio:
befehl += ["--audio-lang-list", ",".join(audio)] befehl += ["--audio-lang-list", ",".join(audio)]
befehl.append("--first-audio") befehl.append("--first-audio")
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"] # `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
#
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
#
# unknown option (--audio-codec)
# HandBrake has exited. $? = 0
#
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
# in derselben Sekunde wieder weg, in der es startete, und meldete das
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
# festschreibt, statt ihn zu finden.
#
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
untertitel = [s for s in (untertitel_sprachen or []) if s] untertitel = [s for s in (untertitel_sprachen or []) if s]
if untertitel: if untertitel:
befehl += ["--subtitle-lang-list", ",".join(untertitel)] befehl += ["--subtitle-lang-list", ",".join(untertitel)]
@@ -577,6 +645,23 @@ def sprachliste(wert) -> list:
return sauber return sauber
def _datei_daneben(erwartet: str) -> str:
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
HandBrake ueberhaupt schreiben kann.
"""
ordner = os.path.dirname(erwartet)
stamm = os.path.splitext(os.path.basename(erwartet))[0]
for endung in FORMATE:
kandidat = os.path.join(ordner, stamm + endung)
if kandidat != erwartet and os.path.isfile(kandidat):
return kandidat
return ""
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
progress_cb=None, abbruch_cb=None, progress_cb=None, abbruch_cb=None,
audio_sprachen=None, untertitel_sprachen=None) -> dict: audio_sprachen=None, untertitel_sprachen=None) -> dict:
@@ -600,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
audio_sprachen, untertitel_sprachen), audio_sprachen, untertitel_sprachen),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True,
bufsize=1, bufsize=1,
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
**HB_LESEN,
) )
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb) return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e: except Exception as e:
@@ -629,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
return text[len(kopf):].strip() if text.startswith(kopf) else "" return text[len(kopf):].strip() if text.startswith(kopf) else ""
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
HB_ZEILEN_PUFFER = 12
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
HB_ZEILEN_MELDUNG = 4
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
def unbekannter_schalter(zeile: str) -> str:
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
## Der Befund des Commanders (30.08.2026)
> Kompression fehlgeschlagen bei Spartacus _t01.mkv: HandBrake endete
> mit Code 0 Roh-Datei bleibt erhalten"
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
in der sie begann fuer einen Scan-Durchlauf haette das nicht gereicht.
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
Befehlszeile, die Rippy baute:
unknown option (--audio-codec)
HandBrake has exited.
$? = 0
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
eigenen Befehlszeile als ERFOLG. Rippy sah nur Code 0" und keine Datei —
und riet daraufhin auf Zielordner nicht beschreibbar". Das war falsch,
und es schickte die Suche in die vollkommen falsche Richtung.
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
statt wieder einen ganzen Rip zu kosten.
"""
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
return treffer.group(1).strip() if treffer else ""
def hb_schluss(zeilen) -> str:
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
Ausgabe, nur hoerte niemand zu.
"""
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
if not sauber:
return ""
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict: def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die """Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.""" Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
falsches_preset = "" falsches_preset = ""
falscher_schalter = ""
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
letzte_zeilen = []
try: try:
for line in process.stdout: for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt # Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
@@ -640,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
# sich die Prozentzahl bewegt (Befund 25.07.2026). # sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb: if abbruch_cb:
abbruch_cb() abbruch_cb()
if line.strip():
letzte_zeilen.append(line)
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
del letzte_zeilen[0]
if not falsches_preset: if not falsches_preset:
falsches_preset = unbekanntes_preset(line) falsches_preset = unbekanntes_preset(line)
if not falscher_schalter:
falscher_schalter = unbekannter_schalter(line)
progress = get_progress_from_line(line) progress = get_progress_from_line(line)
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei # Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
@@ -657,6 +805,57 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
if process.returncode == 0 and os.path.exists(output_path): if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path} return {"status": "success", "output_path": output_path}
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
# 0", und dort ist er nicht zu erraten. Genau das kostete am
# 30.08.2026 einen fertigen 16,5-GB-Rip.
if falscher_schalter:
return {
"status": "error",
"error": (
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
+ hb_schluss(letzte_zeilen)
),
"return_code": process.returncode,
}
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
#
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
#
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
if process.returncode == 0:
daneben = _datei_daneben(output_path)
if daneben:
return {"status": "success", "output_path": daneben,
"hinweis": "HandBrake hat %s geschrieben statt %s "
"(der Container kommt aus dem Preset)."
% (os.path.basename(daneben),
os.path.basename(output_path))}
# Frueher stand hier geraten „Meist ist der Zielordner nicht
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
# und leer, der Grund ein falscher Schalter) — und die Vermutung
# schickte die Suche in die falsche Richtung. Jetzt wird der
# Ordner GENANNT und HandBrake selbst zitiert.
return {
"status": "error",
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
"entstanden (Zielordner: %s)."
% os.path.dirname(output_path)
+ hb_schluss(letzte_zeilen)),
"return_code": 0,
}
if falsches_preset: if falsches_preset:
return { return {
"status": "error", "status": "error",
@@ -671,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
} }
return { return {
"status": "error", "status": "error",
"error": f"HandBrake endete mit Code {process.returncode}", "error": (f"HandBrake endete mit Code {process.returncode}"
+ hb_schluss(letzte_zeilen)),
"return_code": process.returncode, "return_code": process.returncode,
} }
@@ -709,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name return tmp.name
def disc_fehlt(device_path: str, zustand=None) -> str:
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
## Warum vorher nachgesehen wird (Befund 29.08.2026)
Der Commander bekam:
makemkvcon endete mit Code 11 letzte Meldung:
Das Öffnen der Disk schlug fehl keine MKV-Datei entstanden
Auffaellig war, was FEHLTE: kein Ursache:". Waere der Geraetepfad schuld
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
Nach einem fertigen Rip wirft Rippy die Disc aus ein zweiter Versuch
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
und meldet einen Satz, aus dem niemand das schliessen kann.
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist und
ich weiss es nicht" darf nie zu „es geht nicht" werden.
"""
if zustand is None:
try:
from rippy import drives as _schicht
zustand = _schicht.treiber().drive_status
except Exception: # noqa: BLE001
return ""
try:
stand = zustand(device_path)
except Exception: # noqa: BLE001
return ""
if stand == CDS_NO_DISC:
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
"wieder hinein." % device_path)
if stand == CDS_TRAY_OPEN:
return "Die Schublade von %s ist offen." % device_path
return ""
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all", def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
log_cb=None) -> dict: log_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt. """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
@@ -723,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if not check_makemkv_installed(): if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"} return {"status": "error", "error": "makemkvcon ist nicht installiert"}
fehlt = disc_fehlt(device_path)
if fehlt:
return {"status": "error", "error": fehlt}
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
try: try:
@@ -730,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
build_makemkv_cmd(device_path, output_dir, titel), build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, # BINAER lesen und selbst dekodieren (siehe `text_von`):
bufsize=1, # `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
# schrieb aber UTF-8 — daraus wurde beim Commander
# „Das Öffnen der Disk schlug fehl".
#
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
# nicht (Python warnt und puffert doch). Ungepuffert kommt
# jede Fortschrittszeile sofort an — bei einem Rip, der
# Stunden laeuft, ist das der Unterschied zwischen einem
# Balken und einem eingefrorenen Fenster.
bufsize=0,
creationflags=OHNE_FENSTER, creationflags=OHNE_FENSTER,
) )
letzte_meldung = "" letzte_meldung = ""
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
lesefehler = False
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur # Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key # "Failed to open disc" — die URSACHE ("volume key is unknown", Key
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren # abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
# (Befund 24.07., Summer-Wars-UHD). # (Befund 24.07., Summer-Wars-UHD).
kritische_meldungen = [] kritische_meldungen = []
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
KRITISCH = ( KRITISCH = (
"volume key is unknown", "volume key is unknown",
"evaluation period has expired", "evaluation period has expired",
@@ -748,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
"Too old version", "Too old version",
) )
try: try:
for line in process.stdout: for rohzeile in process.stdout:
line = text_von(rohzeile)
progress = get_progress_from_prgv(line) progress = get_progress_from_prgv(line)
if progress >= 0: if progress >= 0:
if progress_cb: if progress_cb:
@@ -758,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if meldung is None: if meldung is None:
continue continue
code, letzte_meldung = meldung code, letzte_meldung = meldung
if any(muster in letzte_meldung for muster in KRITISCH): if LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES:
kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
elif any(muster in letzte_meldung for muster in KRITISCH):
kritische_meldungen.append(letzte_meldung) kritische_meldungen.append(letzte_meldung)
if log_cb: if log_cb:
try: try:
@@ -786,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
"output_dir": output_dir, "output_dir": output_dir,
"files": mkv_dateien, "files": mkv_dateien,
"return_code": process.returncode, "return_code": process.returncode,
"lesefehler": lesefehler,
} }
return { return {
"status": "error", "status": "error",
"lesefehler": lesefehler,
"error": ( "error": (
f"makemkvcon endete mit Code {process.returncode}" f"makemkvcon endete mit Code {process.returncode}"
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "") + (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
@@ -838,10 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
log_cb=log_cb) log_cb=log_cb)
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
output_dir: str = None, auswerfen: bool = True) -> dict:
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
"""
from rippy.drives import windows as win
from rippy.rip import audio_cd
class Leser:
toc = staticmethod(win.audio_toc)
@staticmethod
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
fortschritt)
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
# Disc, die niemand kennt.
from rippy.rip import musicbrainz_cd
try:
spuren = win.audio_toc(device_path)
antwort = musicbrainz_cd.abfragen(
musicbrainz_cd.kennung_aus_spuren(spuren))
except Exception: # noqa: BLE001
antwort = None
tags = musicbrainz_cd.tags_je_spur(antwort)
ordner = musicbrainz_cd.album_ordner(antwort)
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
werkzeuge.finden("flac"),
fortschritt=progress_cb, tags_je_spur=tags)
if auswerfen and ergebnis.get("status") == "success":
wirf_disc_aus(device_path)
return ergebnis
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None, def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
auswerfen: bool = True) -> dict: auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer """Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x).""" Auswurf" (abcde macht das selbst per -x)."""
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
#
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
#
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
# Wochen so im SAVEPOINT.
if os.name == "nt":
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
auswerfen)
if not check_abcde_installed(): if not check_abcde_installed():
return { return {
"status": "error", "status": "error",
+8 -1
View File
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
for pfad in fest: for pfad in fest:
if os.path.isfile(pfad): if os.path.isfile(pfad):
return pfad return pfad
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "" # ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
from rippy.tools import katalog
return (katalog.finden("makemkv")
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
def parse_laufwerke(ausgabe: str) -> list: def parse_laufwerke(ausgabe: str) -> list:
+30
View File
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [ assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
"cpu-x264", "cpu-x265", "cpu-av1", "cpu-x264", "cpu-x265", "cpu-av1",
] ]
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
#
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
# Freigabe gibt.
def test_im_container_ist_nichts_extern():
from caps import _extern
assert _extern({}, container=True) == "nein"
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
from caps import _extern
assert _extern({"profil": "standalone"}, container=False) == "nein"
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
from caps import _extern
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
container=False) == "ja"
+9
View File
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
return tasks._erreichbarkeit_pruefen( return tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1", "/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)", "/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
# der eigenstaendige Rippy fuer einen fremden Worker.
fremd=not im_container,
) )
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
meldung = tasks._erreichbarkeit_pruefen( meldung = tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "/app/media/rippy/job1", "/app/media/rippy/job1", "/app/media/rippy/job1",
"/app/media/movies/X", "/app/media/movies/X", "/app/media/movies/X", "/app/media/movies/X",
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
# der Test am laufenden Rechner (Befund 29.08.2026).
fremd=True,
) )
assert "deckt diesen Pfad aber nicht ab" in meldung assert "deckt diesen Pfad aber nicht ab" in meldung
assert "von keinem Eintrag übersetzt" in meldung assert "von keinem Eintrag übersetzt" in meldung
+72 -12
View File
@@ -121,24 +121,33 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV). # Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
# prompt rot geworden).
CONTAINER = ("/app/media", "/app/temp/raw", False)
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung(): def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard. """Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
niemand gefragt wird. niemand gefragt wird.
""" """
import ablauf as tasks import ablauf as tasks
einst = {"workDir": "/app/media/movies"} einst = {"workDir": "/app/media/movies"}
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy" w = CONTAINER
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies" assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch # Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
# Leere Wahl fällt sauber auf die Einstellung zurück # Leere Wahl fällt sauber auf die Einstellung zurück
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies" assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein(): def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
def test_zielbasis_lehnt_praefix_ausbruch_ab(): def test_zielbasis_lehnt_praefix_ausbruch_ab():
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
Windows-Reparatur nichts."""
import ablauf as tasks import ablauf as tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies" w = CONTAINER
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
# Ausbruch per Praefix-Namen fällt auf den Standard zurück # Ausbruch per Praefix-Namen fällt auf den Standard zurück
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese" assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc" assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab(): def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
import ablauf as tasks import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR w = CONTAINER
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
#
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
# sogar eine AUSDRUECKLICHE Wahl.
#
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
# ignorierte ihn — ohne ein Wort.
def test_nativ_zaehlt_die_wahl_des_nutzers():
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
deshalb still verworfen."""
import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
Wurzel."""
import ablauf as tasks
unc = r"\\192.168.179.62\rippy\movies"
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
import ablauf as tasks
ziel = tasks._zielbasis("", "bluray", NATIV)
assert ziel.startswith(NATIV[0])
assert "/app/" not in ziel
+160 -3
View File
@@ -13,7 +13,9 @@ from ripping import (
build_makemkv_cmd, build_makemkv_cmd,
get_progress_from_line, get_progress_from_line,
get_progress_from_prgv, get_progress_from_prgv,
hb_schluss,
parse_msg, parse_msg,
unbekannter_schalter,
write_abcde_config, write_abcde_config,
) )
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv" assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
assert "--preset" in cmd assert "--preset" in cmd
assert "--first-audio" in cmd # beste Spur pro Sprache behalten assert "--first-audio" in cmd # beste Spur pro Sprache behalten
assert "--audio-codec" in cmd assert "--aencoder" in cmd
assert "--all-subtitles" in cmd assert "--all-subtitles" in cmd
def test_handbrake_kennt_keinen_schalter_audio_codec():
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
unknown option (--audio-codec)
HandBrake has exited. $? = 0
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
das mit Rückgabewert 0 als ERFOLG ein fertiger 16,5-GB-Rip lief damit
ins Leere, ohne dass irgendwo ein Grund stand.
Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
ist eine externe Schnittstelle (AGENTS Regel D) er gehört am echten
Programm gemessen, nicht aus dem Gedächtnis behauptet.
"""
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
assert "--audio-codec" not in cmd
assert cmd[cmd.index("--aencoder") + 1] == "copy"
def test_unbekannter_schalter_wird_erkannt():
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
assert unbekannter_schalter(
"unknown option (--audio-codec)") == "--audio-codec"
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
assert unbekannter_schalter(
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
assert unbekannter_schalter("") == ""
assert unbekannter_schalter(None) == ""
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
0 ist, sagt er nichts (Befund 30.08.2026)."""
assert hb_schluss([]) == ""
assert hb_schluss(None) == ""
assert hb_schluss([" ", " "]) == ""
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
assert "fünf" in text and "vier" in text
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
assert "eins" not in text
def test_handbrake_progress_parsing(): def test_handbrake_progress_parsing():
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen # Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45 assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
@@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
assert ergebnis["return_code"] == 3 assert ergebnis["return_code"] == 3
def test_fehler_ohne_preset_problem_bleibt_der_alte(): def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
Bis zum 30.08.2026 stand hier nur HandBrake endete mit Code N", und
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
"""
import ripping import ripping
class FakeProcess: class FakeProcess:
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
return 1 return 1
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["error"] == "HandBrake endete mit Code 1" assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
assert "irgendwas ganz anderes" in ergebnis["error"]
assert ergebnis["return_code"] == 1
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
Ohne diese Erkennung landete er unten bei HandBrake meldet Erfolg,
aber es ist keine Datei entstanden" samt der falschen Vermutung
Zielordner nicht beschreibbar" — und die schickte die Suche in die
vollkommen falsche Richtung.
"""
import ripping
class FakeProcess:
def __init__(self):
self.stdout = iter([
"[13:30:54] hb_init: starting libhb thread\n",
"unknown option (--audio-codec)\n",
"HandBrake has exited.\n",
])
self.returncode = 0
def kill(self):
pass
def wait(self):
return 0
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["status"] == "error"
assert "--audio-codec" in ergebnis["error"]
assert "Fehler in Rippy" in ergebnis["error"]
# ... und NICHT die alte Vermutung über den Zielordner
assert "beschreibbar" not in ergebnis["error"]
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) ----------- # --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
@@ -519,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn" assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu" assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
assert "--first-audio" in cmd and "--all-subtitles" in cmd assert "--first-audio" in cmd and "--all-subtitles" in cmd
# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ─────────────────────
#
# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der
# Disk schlug fehl — keine MKV-Datei entstanden"
#
# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem
# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein
# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen
# Satz, aus dem niemand das schliessen kann.
def test_ein_leeres_laufwerk_wird_vorher_erkannt():
import ripping
from rippy.drives.linux import CDS_NO_DISC
grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC)
assert "Kein Datentraeger" in grund
assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein"
def test_offene_schublade_auch():
import ripping
from rippy.drives.linux import CDS_TRAY_OPEN
assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN)
def test_mit_disc_wird_nicht_gemeckert():
import ripping
from rippy.drives.linux import CDS_DISC_OK
assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == ""
def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT():
"""„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das
Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist."""
import ripping
def wirft(p):
raise OSError(5, "Zugriff verweigert")
assert ripping.disc_fehlt("G:", zustand=wirft) == ""
def test_run_makemkv_sieht_vorher_nach():
"""Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft
man weiterhin zwei Minuten ins Leere."""
import inspect
import ripping
quelle = inspect.getsource(ripping.run_makemkv)
vor_popen = quelle.index("subprocess.Popen")
assert "disc_fehlt(" in quelle[:vor_popen], \
"die Disc-Pruefung gehoert vor den makemkvcon-Start"
def test_die_sammelmeldung_5010_wird_erklaert():
"""5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts."""
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES
assert 5010 in KRITISCHE_CODES
assert "Disc" in KRITISCHE_CODES[5010]
+11
View File
@@ -0,0 +1,11 @@
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
node_modules/
out/
*.tsbuildinfo
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
dist-setup*/
vendor/
.electron-cache/
+113
View File
@@ -0,0 +1,113 @@
# Rippy v5 bauen — Anleitung
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
> die Messungen dazu liegen in `beweise/`.
## Voraussetzungen
| Werkzeug | Version | Anmerkung |
|----------|---------|-----------|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
| npm | 11 | kommt mit Node 24 |
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
## Einrichten — zwei Schritte, nicht einer
```
npm install
node node_modules/electron/install.js
```
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
## Die Läufe
| Befehl | Was er tut |
|--------|------------|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an.
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
`SAVEPOINT.md` mit Ausgabe belegt.
## Das Setup bauen (W-6)
```
npm run paket
```
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
**Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
lassen, die -Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
(`npx electron-builder --win --config electron-builder.yml
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
den gesperrten Ordner löschen, sobald er wieder frei ist.
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
liegen nicht im Git, 74 MB):
```
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
Aufruf ohne eine Zeile Ausgabe)
```
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
in `bau/lizenzen/QUELLEN.md`.
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
ERSETZEN) — `electron-updater` prüft
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
erkennen.
## Festgelegte Versionen und warum
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
Release prüfen, ob eine neue Linie ansteht.
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 57) und
Vitest 4 (verträgt 68), am 30.08.2026 per `npm view` ermittelt.
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,29 @@
Copyright (C) 2000-2009 Josh Coalson
Copyright (C) 2011-2025 Xiph.Org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.Org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+35
View File
@@ -0,0 +1,35 @@
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
holt es beim Einrichten vom Hersteller.
## HandBrakeCLI
- Lizenz: GNU General Public License v2 — Volltext in
`HANDBRAKE-GPL-2.0.txt` (bezogen von
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
- Quelltext und Projekt: https://handbrake.fr bzw.
https://github.com/HandBrake/HandBrake — der Quelltext der
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
## FLAC (flac.exe + libFLAC.dll)
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
`FLAC-COPYING.Xiph` (bezogen von
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
30.08.2026).
- Projekt: https://xiph.org/flac/
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
keine Schriften aus dem Netz.
@@ -0,0 +1,93 @@
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

+42
View File
@@ -0,0 +1,42 @@
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
appId: de.krbrz.rippy
productName: Rippy
directories:
output: dist-setup
buildResources: bau
files:
- out/**
- package.json
extraResources:
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
- from: vendor
to: vendor
- from: bau/lizenzen
to: lizenzen
- from: bau/tray.png
to: bau/tray.png
win:
icon: bau/icon.ico
target:
- nsis
nsis:
oneClick: true
perMachine: false
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
allowToChangeInstallationDirectory: false
deleteAppDataOnUninstall: false
artifactName: RippySetup-${version}.${ext}
publish:
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
# ersten echten Release gemessen — § 3.6 ⚠).
provider: generic
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
electronDownload:
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
# lädt die Binärdateien selbst über diesen Cache-Weg.
cache: .electron-cache
+67
View File
@@ -0,0 +1,67 @@
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
// haupt → out/haupt/index.js (Electron Main)
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
// bruecke → out/bruecke/index.js (Preload des Fensters)
// fenster → out/fenster/ (React)
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import type { Plugin } from 'vite'
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
function cspNurImBau(): Plugin {
return {
name: 'csp-nur-im-bau',
apply: 'build',
transformIndexHtml(html) {
return html.replace(
'<head>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
)
},
}
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/haupt',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/haupt/index.ts'),
kern: resolve(__dirname, 'src/kern/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/bruecke',
rollupOptions: {
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
},
},
},
renderer: {
root: 'src/fenster',
plugins: [react(), tailwindcss(), cspNurImBau()],
build: {
outDir: 'out/fenster',
rollupOptions: {
input: { index: resolve(__dirname, 'src/fenster/index.html') },
},
},
},
})
+7289
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.1.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev": "electron-vite dev",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
"build": "npm run typecheck && electron-vite build",
"test": "vitest run",
"smoke": "electron-vite build && electron . --smoke",
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
"werkzeug:electron": "node node_modules/electron/install.js"
},
"dependencies": {
"electron-updater": "^6.8.9",
"koffi": "^3.1.6"
},
"devDependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource/instrument-sans": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^5.2.0",
"electron": "44.0.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
"typescript": "5.9.3",
"vite": "^7.3.6",
"vitest": "^4.1.11"
}
}
+59
View File
@@ -0,0 +1,59 @@
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
// Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
//
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
let seiteBereit = false
const wartendePorts: IpcRendererEvent[] = []
ipcRenderer.on('kern-port', (ereignis) => {
if (seiteBereit) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
} else {
wartendePorts.push(ereignis)
}
})
let letzterStatus: unknown = null
const statusHorcher = new Set<(status: unknown) => void>()
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
letzterStatus = status
for (const horcher of statusHorcher) horcher(status)
})
contextBridge.exposeInMainWorld('rippy', {
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
bereit(): void {
seiteBereit = true
for (const ereignis of wartendePorts.splice(0)) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
}
},
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
aufStatus(horcher: (status: unknown) => void): void {
statusHorcher.add(horcher)
if (letzterStatus !== null) horcher(letzterStatus)
},
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
melden(art: string): void {
ipcRenderer.send('fenster-meldung', String(art))
},
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
},
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
},
})
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
export {}
declare global {
interface Window {
rippy: {
bereit(): void
aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null>
autostartSetzen(an: boolean): Promise<boolean>
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
import '@fontsource-variable/bricolage-grotesque'
import '@fontsource/instrument-sans/400.css'
import '@fontsource/instrument-sans/500.css'
import '@fontsource/instrument-sans/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/700.css'
import './stil.css'
createRoot(document.getElementById('wurzel')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="./haupt.tsx"></script>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk das aufklappbare
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
* Vorgang beginnt. */
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
version: string
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20
const stand: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
schluessel: null,
beschaffung: null,
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
}
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
break
case 'pong':
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'disc-info': {
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
break
}
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'beschaffung-status':
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
// wiederholen den Text — nur echte Änderungen zählen).
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
stand.ripProtokoll = {
...stand.ripProtokoll,
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
}
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
)
} else if (nachricht.status.phase === 'fehler') {
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
} else if (nachricht.status.phase === 'abgebrochen') {
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
}
break
}
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
melden()
}
/** Idempotent der erste Aufruf registriert den Horcher und meldet der
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
export function verbindungStarten(): void {
if (gestartet) return
gestartet = true
window.addEventListener('message', (ereignis: MessageEvent) => {
const daten: unknown = ereignis.data
if (
typeof daten === 'object' &&
daten !== null &&
(daten as Record<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
export function ripStarten(id: string): void {
anKern({ art: 'rip-start', id }, `Rip ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
}
export function schluesselPruefen(): void {
if (port === null) return
port.postMessage({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
}
+66
View File
@@ -0,0 +1,66 @@
@import 'tailwindcss';
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
Geräte-Präzision Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
@theme {
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
--font-sans: 'Instrument Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', Consolas, monospace;
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
--color-buehne: oklch(0.13 0.012 60);
--color-karte: oklch(0.17 0.015 60);
--color-samt: oklch(0.16 0.015 60);
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
--color-gold-hell: oklch(0.88 0.09 80);
--color-gold: oklch(0.83 0.11 75);
--color-gold-tief: oklch(0.74 0.13 60);
/* Die Aufnahme-LED aus dem Schaltpult. */
--color-rec: oklch(0.72 0.17 45);
/* Schriftstufen — warmes Weiß bis Nebel. */
--color-schnee: oklch(0.93 0.01 80);
--color-dunst: oklch(0.68 0.015 80);
--color-nebel: oklch(0.55 0.015 80);
/* Zustandsfarben, alle in der warmen Familie. */
--color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25);
}
:root {
color-scheme: dark;
}
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer liegt als
festes Bühnenbild hinter allem. */
.buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
}
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
.filmkorn {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
opacity: 0.05;
mix-blend-mode: overlay;
}
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
.segment-gluehen {
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
}
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
::-webkit-scrollbar-track { background: transparent; }
+306
View File
@@ -0,0 +1,306 @@
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
beschreibung: string
/** Datum des früheren Rips dieser Disc die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
posterPfad: string
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand {
/** Welches Werkzeug — bisher nur 'makemkv'. */
werkzeug: string
laeuft: boolean
text: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'installer-starten'; pfad: string }
| {
art: 'schluessel-status'
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
version: string
}
}
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
| { art: 'rip-start'; id: string }
| { art: 'rip-abbruch'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
| { art: 'fenster-port' }
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
| { art: 'installer-ergebnis'; fehler: string | null }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
update: { letzteErfolgreichePruefung: string; text: string }
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
)
case 'installer-starten':
return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
typeof wert.id === 'string' &&
typeof wert.phase === 'string' &&
typeof wert.prozent === 'number' &&
typeof wert.text === 'string' &&
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean'
)
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
return typeof wert.id === 'string' && wert.id.length > 0
}
if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen'
)
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'fenster-port') return true
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
return false
}
@@ -0,0 +1,44 @@
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
// (VCN) denselben Film in 2 statt 60+ Minuten.
//
// Regeln:
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
// Name (die „Invalid preset"-Falle).
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
// DVDs richtig; hochskaliert wird nichts).
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'],
['nvenc', 'NVENC'],
['qsv', 'QSV'],
]
/** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
export function presetEmpfehlung(
discTyp: string,
backends: readonly string[],
presets: readonly string[],
): string {
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
for (const [backend, kennung] of FAMILIEN) {
if (!backends.includes(backend)) continue
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
if (treffer !== undefined) return treffer
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner
}
return DEFAULT_HB_PRESET
}
+13
View File
@@ -0,0 +1,13 @@
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
// eine Administrator-Abfrage und könnte kein Tray zeigen.
import { app } from 'electron'
export function autostartSetzen(an: boolean): void {
app.setLoginItemSettings({ openAtLogin: an })
}
export function autostartAktiv(): boolean {
return app.getLoginItemSettings().openAtLogin
}
+44
View File
@@ -0,0 +1,44 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
import { BrowserWindow } from 'electron'
import { join } from 'node:path'
export function fensterErstellen(): BrowserWindow {
const fenster = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 720,
minHeight: 480,
show: true,
autoHideMenuBar: true,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
backgroundColor: '#171412',
title: 'Rippy',
webPreferences: {
preload: join(__dirname, '../bruecke/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) {
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
} else {
void fenster.loadFile(
join(__dirname, '../fenster/index.html'),
startBereich.length > 0 ? { hash: startBereich } : undefined,
)
}
return fenster
}
+317
View File
@@ -0,0 +1,317 @@
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
//
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
start()
}
function start(): void {
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen()
if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
}
let fenster: BrowserWindow | null = null
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
let beendenGewollt = smokeModus
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
// warten dann.
const aktiveVorgaenge = new Set<string>()
let updateGestartet = false
const smoke = {
fenster: false,
kern: false,
datenbank: false,
leine: leine.ok,
pong: false,
}
const kernVerwaltung = new KernVerwaltung(
join(app.getPath('userData'), 'rippy.db'),
(nachricht) => {
if (nachricht.art === 'kern-bereit') {
kernBereit = nachricht
smoke.kern = true
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
} else if (nachricht.art === 'rip-status') {
ripStatusVerarbeiten(nachricht.status)
} else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !smokeModus) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text),
)
}
} else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
(nachricht.info.schonGerippt !== null
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
: ''),
)
}
},
() => statusSenden(),
smokeModus,
)
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id)
} else {
aktiveVorgaenge.delete(status.id)
}
if (fenster !== null && !fenster.isDestroyed()) {
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
fenster.setProgressBar(status.prozent / 100)
} else if (status.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else {
fenster.setProgressBar(-1)
}
}
if (status.phase === 'rippt') {
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else if (status.phase === 'komprimiert') {
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
if (smokeModus) return
if (status.phase === 'fertig') {
toast('Rippy ist fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
}
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
// Manifest Administratorrechte an — CreateProcess bricht dann mit
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
async function installerStarten(pfad: string): Promise<void> {
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
// Rippys eigenem Downloads-Ordner.
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
return
}
const fehler = await shell.openPath(pfad)
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
}
function toast(titel: string, text: string): void {
try {
if (!Notification.isSupported()) return
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
meldung.on('click', () => fensterZeigen())
meldung.show()
} catch (fehler) {
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
}
}
function statusSenden(): void {
if (fenster === null || fenster.isDestroyed()) return
const status: HauptStatus = {
version: app.getVersion(),
leine,
kern: kernVerwaltung.zustand(),
autostart: autostartAktiv(),
update: updateStand(),
}
fenster.webContents.send('haupt-status', status)
}
function fensterVerdrahten(): void {
if (fenster === null) return
fenster.webContents.on('did-finish-load', () => {
smoke.fenster = true
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
kernVerwaltung.fensterVerbinden(fenster!.webContents)
statusSenden()
smokePruefen()
})
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => {
if (beendenGewollt) return
ereignis.preventDefault()
fenster?.hide()
})
}
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen()
fensterVerdrahten()
return
}
if (fenster.isMinimized()) fenster.restore()
fenster.show()
fenster.focus()
}
function beenden(): void {
beendenGewollt = true
app.quit()
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt.
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
if (art === 'kern-pong') {
smoke.pong = true
smokePruefen()
}
})
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
autostartSetzen(an === true)
statusSenden()
return autostartAktiv()
})
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
app.on('second-instance', () => {
fensterZeigen()
})
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
app.on('window-all-closed', () => {
if (beendenGewollt) app.quit()
})
app.on('before-quit', () => {
beendenGewollt = true
kernVerwaltung.beenden()
})
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!smokeModus) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
fensterVerdrahten()
})
let smokeAbgeschlossen = false
function smokePruefen(): void {
if (!smokeModus || smokeAbgeschlossen) return
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
if (fehlt.length > 0) return
smokeAbgeschlossen = true
void smokeAbschliessen()
}
async function smokeAbschliessen(): Promise<void> {
const bildPfad = process.env['RIPPY_SMOKE_BILD']
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
try {
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
// ein sofortiges capturePage lieferte hier »UnknownVizError«
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
// späte Kern-Antworten warten.
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
const bild = await fenster.webContents.capturePage()
await writeFile(bildPfad, bild.toPNG())
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
} catch (fehler) {
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
}
}
const kernInfo =
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
console.log(
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
)
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
kernVerwaltung.beenden()
setTimeout(() => app.exit(0), 100)
}
if (smokeModus) {
setTimeout(() => {
if (smokeAbgeschlossen) return
smokeAbgeschlossen = true
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
kernVerwaltung.beenden()
setTimeout(() => app.exit(2), 100)
}, 25_000)
}
}
+108
View File
@@ -0,0 +1,108 @@
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
import { app, MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
import { join } from 'node:path'
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
export interface KernZustand {
laeuft: boolean
pid?: number
neustarts: number
}
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
const MAX_NEUSTARTS_JE_MINUTE = 3
export class KernVerwaltung {
private kern: UtilityProcess | null = null
private beendenGewollt = false
private neustarts = 0
private neustartZeiten: number[] = []
constructor(
private readonly datenbankPfad: string,
private readonly beiNachricht: (nachricht: KernNachricht) => void,
private readonly beiZustand: (zustand: KernZustand) => void,
private readonly smokeModus = false,
) {}
starten(): void {
const kernDatei = join(__dirname, 'kern.js')
const args = [`--datenbank=${this.datenbankPfad}`]
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
// die Schlüsselkette dann aus.
if (this.smokeModus) args.push('--smoke-kern')
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
const umgebung: NodeJS.ProcessEnv = { ...process.env }
if (app.isPackaged) {
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
}
const kern = utilityProcess.fork(kernDatei, args, {
serviceName: 'Rippy-Kern',
stdio: 'inherit',
env: umgebung,
})
this.kern = kern
kern.on('message', (nachricht: unknown) => {
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
})
kern.on('spawn', () => {
this.beiZustand(this.zustand())
})
kern.on('exit', (code) => {
this.kern = null
if (this.beendenGewollt) return
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
const jetzt = Date.now()
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
this.beiZustand(this.zustand())
return
}
this.neustartZeiten.push(jetzt)
this.neustarts += 1
this.starten()
})
}
/**
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
* ins Fenster (§ 4.1).
*/
fensterVerbinden(webContents: WebContents): void {
if (this.kern === null) return
const { port1, port2 } = new MessageChannelMain()
const nachricht: HauptNachricht = { art: 'fenster-port' }
this.kern.postMessage(nachricht, [port1])
webContents.postMessage('kern-port', null, [port2])
}
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
senden(nachricht: HauptNachricht): void {
this.kern?.postMessage(nachricht)
}
zustand(): KernZustand {
return {
laeuft: this.kern !== null,
pid: this.kern?.pid,
neustarts: this.neustarts,
}
}
beenden(): void {
this.beendenGewollt = true
this.kern?.kill()
this.kern = null
}
}
+92
View File
@@ -0,0 +1,92 @@
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
//
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
//
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
import koffi from 'koffi'
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
// bei Offset 16.
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
let gesetzt: LeineStatus | null = null
/**
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
* VOR dem Start des Kerns Kinder erben die Mitgliedschaft nur, wenn sie
* nach dem Anlegen gestartet werden.
*
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
*/
export function leineSetzen(): LeineStatus {
if (gesetzt !== null) return gesetzt
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func(
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
)
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
)
const AssignProcessToJobObject = kernel32.func(
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
)
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const job = CreateJobObjectW(null, null)
if (koffi.address(job) === 0n) {
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
if (
!SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
info,
STRUKTUR_GROESSE,
)
) {
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
// Prozess-Tod IST der Mechanismus.
gesetzt = { ok: true }
return gesetzt
} catch (fehler) {
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
return gesetzt
}
}
+52
View File
@@ -0,0 +1,52 @@
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
// beendet wird über das Kontextmenü oder den Menüpunkt.
import { Menu, nativeImage, Tray } from 'electron'
import { join } from 'node:path'
let tray: Tray | null = null
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
try {
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
// per extraResources dorthin).
const kandidaten = [
join(__dirname, '..', '..', 'bau', 'tray.png'),
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
]
let bild = nativeImage.createEmpty()
for (const kandidat of kandidaten) {
const versuch = nativeImage.createFromPath(kandidat)
if (!versuch.isEmpty()) {
bild = versuch
break
}
}
tray = new Tray(bild)
tray.setToolTip('Rippy — bereit')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Rippy öffnen', click: oeffnen },
{ type: 'separator' },
{ label: 'Beenden', click: beenden },
]),
)
tray.on('click', oeffnen)
return tray
} catch (fehler) {
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
return null
}
}
export function trayHinweis(text: string): void {
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
}
export function trayWeg(): void {
tray?.destroy()
tray = null
}
+20
View File
@@ -0,0 +1,20 @@
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
// Tests sie ohne Electron laden können.
export const STANDARD_UPDATE_URL =
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1)
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
* akzeptiert. */
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
const sauber = url.trim()
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
return {
ok: false,
grund:
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
}
}
+83
View File
@@ -0,0 +1,83 @@
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
// 2. NIE während eines Rips — weder laden noch installieren.
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
// letzten erfolgreichen Prüfung steht im Status.
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
import { app } from 'electron'
import electronUpdater from 'electron-updater'
import { updateUrlPruefen } from './update-regeln'
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
const { autoUpdater } = electronUpdater
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
}
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
export function updateStand(): UpdateStand {
return { ...stand }
}
export function updateEinrichten(
eigeneUrl: string,
ripLaeuft: () => boolean,
melden: (text: string) => void,
): void {
if (!app.isPackaged) {
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
return
}
const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) {
stand.text = pruefung.grund
melden(pruefung.grund)
return
}
if (eigeneUrl.trim().length > 0) {
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
}
autoUpdater.autoDownload = false
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', () => {
if (ripLaeuft()) {
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
// tägliche Takt versucht es wieder.
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
return
}
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
})
})
autoUpdater.on('update-downloaded', (info) => {
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
melden(stand.text)
})
autoUpdater.on('update-not-available', () => {
stand.letzteErfolgreichePruefung = new Date().toISOString()
stand.text = 'Rippy ist aktuell.'
})
autoUpdater.on('error', (fehler) => {
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
})
const pruefen = (): void => {
if (ripLaeuft()) return
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
pruefen()
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
}
@@ -0,0 +1,46 @@
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
export async function bibliothekRefresh(
server: string,
url: string,
apiKey: string,
holen: typeof fetch = fetch,
): Promise<string> {
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
try {
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
method: 'POST',
headers: { 'X-Emby-Token': apiKey },
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) {
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
}
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
}
if (server === 'kodi') {
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey.includes(':')) {
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
}
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
method: 'POST',
headers: kopf,
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
}
} catch (fehler) {
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
}
return ''
}
+46
View File
@@ -0,0 +1,46 @@
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
export interface NfoMeta {
typ?: 'film' | 'serie'
beschreibung?: string
genres?: readonly string[]
laufzeitMinuten?: number
quelle?: string
}
function xml(wert: string): string {
return wert
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** Minimales NFO (pur, testbar). */
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
const zeilen = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
`<${wurzel}>`,
` <title>${xml(titel ?? '')}</title>`,
]
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
zeilen.push(` <year>${xml(String(jahr))}</year>`)
}
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
}
for (const genre of meta.genres ?? []) {
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
}
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
}
+30
View File
@@ -0,0 +1,30 @@
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
// Meldung sagt was passiert ist).
import { writeFileSync } from 'node:fs'
export function posterUrl(posterPfad: string): string {
if (posterPfad.length === 0) return ''
if (posterPfad.startsWith('http')) return posterPfad
return `https://image.tmdb.org/t/p/w500${posterPfad}`
}
export async function posterSpeichern(
posterPfad: string,
zielDatei: string,
holen: typeof fetch = fetch,
): Promise<string> {
const url = posterUrl(posterPfad)
if (url.length === 0) return ''
try {
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
const daten = Buffer.from(await antwort.arrayBuffer())
if (daten.length === 0) return 'Poster: leere Antwort'
writeFileSync(zielDatei, daten)
return 'poster.jpg gespeichert'
} catch (fehler) {
return `Poster fehlgeschlagen: ${String(fehler)}`
}
}
+137
View File
@@ -0,0 +1,137 @@
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
// #395 war genau das Ratespiel).
import { existsSync, readdirSync, renameSync } from 'node:fs'
// Plattform-Pfadregeln MIT ABSICHT: Diese Funktionen bauen auf
// LAUFZEIT-Wurzeln auf (die Ablage-Einstellung) — zur echten Laufzeit ist
// das immer Windows, in der Linux-CI testen sie mit POSIX-Ordnern und
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
// %ProgramFiles%) bleiben dagegen bei path.win32.
import { join } from 'node:path'
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
export function zielordner(
basis: string,
titel: string,
jahr: number | string | null,
jobId: string,
existiert: (p: string) => boolean = existsSync,
): string {
const name = sichererName(titel, jahr)
if (name.length === 0) return join(basis, jobId)
const ziel = join(basis, name)
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
return ziel
}
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
}
/** Ordnet Dateien Episoden zu über Laufzeiten, ordnungserhaltend.
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
*
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) 1:1 in
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
* passt eine Datei auf mehrere Episoden ähnlich gut null. */
export function matcheEpisoden(
dateiSekunden: readonly number[],
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
toleranzProzent = 8.0,
toleranzMinS = 180,
): number[] | null {
if (dateiSekunden.length === 0 || episoden.length === 0) return null
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
if (dateiSekunden.length === episoden.length) {
const passt = dateiSekunden.every((dauer, i) => {
const epDauer = episoden[i][1]
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
})
if (passt) return episoden.map(([nr]) => nr)
}
const zuordnung: number[] = []
let start = 0
for (const dauer of dateiSekunden) {
const kandidaten = episoden
.slice(start)
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
if (kandidaten.length === 0) return null
let gewaehlt = kandidaten[0]
if (kandidaten.length > 1) {
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
gewaehlt = sortiert[0]
}
zuordnung.push(gewaehlt[0])
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
}
return zuordnung
}
/** Benennt MKVs nach Jellyfin-Schema um: Serie S01E02.mkv". Reihenfolge =
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
export function episodenUmbenennen(
ordner: string,
serie: string,
staffel: number,
zuordnung: readonly number[],
fs: {
listen: (o: string) => string[]
existiert: (p: string) => boolean
umbenennen: (von: string, nach: string) => void
} = {
listen: (o) => readdirSync(o),
existiert: existsSync,
umbenennen: renameSync,
},
): string[] {
const meldungen: string[] = []
const dateien = fs
.listen(ordner)
.filter((f) => f.endsWith('.mkv'))
.sort()
if (dateien.length !== zuordnung.length) {
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
}
const serienName = sichererName(serie) || 'Serie'
for (let i = 0; i < dateien.length; i++) {
const alt = dateien[i]
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
if (alt === neu) continue
const ziel = join(ordner, neu)
if (fs.existiert(ziel)) {
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
continue
}
fs.umbenennen(join(ordner, alt), ziel)
meldungen.push(`${alt}${neu}`)
}
return meldungen
}
+525
View File
@@ -0,0 +1,525 @@
// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage.
// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
// hängen damit an der Prozess-Leine (R6).
//
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
// Einstellung im UI (W-5) eine Stimme.
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung'
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv'
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
interface LaufenderVorgang {
abbruch: AbortController
status: RipStatus
}
export interface PipelineUmgebung {
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
makemkvcon: () => string
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
handbrake: () => string
/** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */
flac: () => string
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
audioToc?: (id: string) => Spur[]
/** Rohe Audio-Sektoren lesen (W-7). */
audioSpurLesen?: (
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
) => number
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
ablage: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ
* greift nur, wenn der Nutzer nichts eingestellt hat. */
presetEmpfehlung?: (discTyp: string) => string
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
}
export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>()
constructor(private readonly umgebung: PipelineUmgebung) {}
laeuft(id: string): boolean {
return this.laufend.has(id)
}
private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status
this.umgebung.melden(status)
}
private fehler(id: string, text: string): RipStatus {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
}
/** Startet die Kette für die ganze Disc ('all'). */
async starten(id: string): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
return
}
const programm = this.umgebung.makemkvcon()
if (programm.length === 0) {
this.umgebung.melden(
this.fehler(
id,
'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)',
),
)
return
}
const geraet = this.umgebung.geraet(id)
if (geraet === null) {
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
return
}
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
// nie zu „es geht nicht" werden.
if (geraet.status === 'empty') {
this.umgebung.melden(
this.fehler(
id,
`Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`,
),
)
return
}
const abbruch = new AbortController()
const startZeit = this.umgebung.jetzt?.() ?? new Date()
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
const vorgangsName = `${geraet.typ}-${zeit}`
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
if (geraet.typ === 'cd') {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
this.melden(this.laufend.get(id)!.status)
await this.audioCdRippen(id, geraet, startZeit, abbruch)
return
}
if (geraet.typ === 'unknown') {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
this.melden(this.laufend.get(id)!.status)
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
return
}
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
const start: RipStatus = {
id,
phase: 'rippt',
prozent: -1,
text: `Rippe verlustfrei nach ${rohOrdner}`,
dateien: [],
lesefehler: false,
}
this.laufend.set(id, { abbruch, status: start })
this.melden(start)
const rip = await rippen({
programm,
geraetepfad: geraet.pfad,
zielOrdner: rohOrdner,
titel: 'all',
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, text })
},
})
if (rip.status === 'cancelled') {
this.laufend.delete(id)
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
return
}
if (rip.status === 'error') {
this.laufend.delete(id)
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
return
}
const lesefehlerHinweis = rip.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: ''
// ── Kompression (W-3) ────────────────────────────────────────────────
const einstellungen = this.umgebung.einstellungen()
const handbrake = this.umgebung.handbrake()
const soll = komprimierenFuer(geraet.typ, einstellungen)
if (!soll || handbrake.length === 0) {
const grund = !soll
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
this.laufend.delete(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text: `Fertig. ${grund}${lesefehlerHinweis}`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const fertigOrdner =
zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
mkdirSync(fertigOrdner, { recursive: true })
const fertigeDateien: string[] = []
for (let i = 0; i < rip.dateien.length; i++) {
const eingabe = rip.dateien[i]
const ausgabe = join(fertigOrdner, basename(eingabe))
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'komprimiert',
prozent: -1,
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
})
}
const encode = await komprimieren({
programm: handbrake,
eingabe,
ausgabe,
preset,
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const stand = this.laufend.get(id)?.status
if (stand !== undefined) {
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
}
},
})
if (encode.status === 'cancelled') {
this.laufend.delete(id)
this.melden({
id,
phase: 'abgebrochen',
prozent: -1,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
if (encode.status === 'error') {
this.laufend.delete(id)
this.melden({
id,
phase: 'fehler',
prozent: -1,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
fertigeDateien.push(encode.ausgabe)
}
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
}
try {
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of fertigeDateien) {
try {
groesse += statSync(datei).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
// am Eintrag selbst.
}
}
this.umgebung.bibliothekEintragen({
fingerprint,
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
jahr: zuordnung?.jahr ?? null,
typ: geraet.typ,
groesseBytes: groesse,
ablageort: fertigOrdner,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
}
this.laufend.delete(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text:
`Fertig — komprimiert nach ${fertigOrdner}.` +
(refresh.length > 0 ? ` ${refresh}` : '') +
lesefehlerHinweis,
dateien: fertigeDateien,
lesefehler: rip.lesefehler,
})
}
abbrechen(id: string): void {
this.laufend.get(id)?.abbruch.abort()
}
// ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ──
private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise<void> {
const fertigMachen = (status: RipStatus): void => {
this.laufend.delete(id)
this.melden(status)
}
const flac = this.umgebung.flac()
if (flac.length === 0) {
fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).'))
return
}
if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) {
fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.'))
return
}
let spuren: Spur[]
try {
spuren = this.umgebung.audioToc(id).filter((s) => s.audio)
} catch (fehler) {
fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`))
return
}
if (spuren.length === 0) {
fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?'))
return
}
// MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne
// Treffer heißen die Dateien „Track 01" — das ist keine Ausrede,
// sondern die Wahrheit über eine Disc, die niemand kennt.
const kennung = kennungAusSpuren(spuren)
const antwort = await abfragen(kennung)
const tags = tagsJeSpur(antwort)
const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit)
const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName)
mkdirSync(ziel, { recursive: true })
const dateien: string[] = []
for (let i = 0; i < spuren.length; i++) {
if (abbruch.signal.aborted) {
fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false })
return
}
const spur = spuren[i]
const spurTags = tags.get(spur.nr) ?? {}
const name = dateiname(spur.nr, spurTags['TITLE'] ?? '')
const zielDatei = join(ziel, `${name}.flac`)
const stand = this.laufend.get(id)?.status
if (stand !== undefined) {
this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) })
}
// Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang
// hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht.
const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-'))
try {
const wav = join(arbeit, 'spur.wav')
const strom = createWriteStream(wav)
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
const s = this.laufend.get(id)?.status
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
})
await new Promise<void>((fertig, kaputt) => {
strom.end(() => fertig())
strom.on('error', kaputt)
})
const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags))
if (flacErgebnis.code !== 0) {
fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`))
return
}
} catch (fehler) {
fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`))
return
} finally {
rmSync(arbeit, { recursive: true, force: true })
}
dateien.push(zielDatei)
}
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of dateien) {
try {
groesse += statSync(datei).size
} catch {
// Anzeige-Komfort — siehe oben.
}
}
this.umgebung.bibliothekEintragen({
fingerprint,
titel: ordnerName,
jahr: null,
typ: 'cd',
groesseBytes: groesse,
ablageort: ziel,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: '',
})
}
fertigMachen({
id,
phase: 'fertig',
prozent: 100,
text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''),
dateien,
lesefehler: false,
})
}
// ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ─────────────────
private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise<void> {
const ziel = join(this.umgebung.ablage(), 'ISO')
mkdirSync(ziel, { recursive: true })
const zielDatei = join(ziel, `${vorgangsName}.iso`)
const ergebnis = await isoSichern(
geraet.pfad,
zielDatei,
geraet.groesseBytes,
(prozent) => {
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, prozent })
},
abbruch.signal,
)
this.laufend.delete(id)
if (ergebnis.status === 'success') {
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null) {
this.umgebung.bibliothekEintragen?.({
fingerprint,
titel: vorgangsName,
jahr: null,
typ: 'iso',
groesseBytes: ergebnis.bytes,
ablageort: zielDatei,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: '',
})
}
this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false })
} else if (ergebnis.status === 'cancelled') {
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
} else {
this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false })
}
}
}
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
}
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
const stuecke: Buffer[] = []
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
})
}
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
copyFileSync(eingabe, ausgabe)
}
+630
View File
@@ -0,0 +1,630 @@
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import { join } from 'node:path'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type KernNachricht,
type MetaVorschlag,
} from '../gemeinsam/nachrichten'
import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes'
import {
audioSpurLesen,
audioToc,
auswerfenMitGrund,
geraeteInfo,
kennung,
listOptischeLaufwerke,
} from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
// nicht — er ist ein reiner Node-Prozess).
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
let db: Datenbank | null = null
let dbStatus: DbStatus
const pfad = datenbankPfadAusArgv(process.argv)
if (pfad === null) {
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
} else {
try {
db = new Datenbank(pfad)
// Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis.
db.einstellungSchreiben('letzter_start', new Date().toISOString())
if (db.einstellungLesen('letzter_start') === null) {
dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' }
} else {
dbStatus = { ok: true, pfad }
}
} catch (fehler) {
// R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt.
// Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu
// »es geht nicht« werden (§ 6.2, sinngemäß).
dbStatus = { ok: false, fehler: String(fehler) }
}
}
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
fensterPort?.postMessage(nachricht)
}
function anHauptUndFenster(nachricht: KernNachricht): void {
process.parentPort.postMessage(nachricht)
anFenster(nachricht)
}
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
let laufwerkApi: LaufwerkApi | null = null
let laufwerkFehler: string | null = null
let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
const discFingerprints = new Map<string, string>()
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
},
})
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
}
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
async function metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) {
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const zuordnung: Zuordnung = {
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta: {
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
backdropPfad: details?.backdrop_path ?? '',
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
},
})
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('./laufwerk/win32')
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
},
})
}
}
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
)
wache.starten()
} catch (fehler) {
laufwerkFehler = String(fehler)
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePreset',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
function einstellungenLaden(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
function geraetVon(id: string): string | null {
if (laufwerkApi === null) return null
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
}
const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
audioToc: (id) => {
const geraet = geraetVon(id)
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(laufwerkApi, geraet)
},
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
const geraet = geraetVon(id)
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
},
geraet: (id) => {
if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
},
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
}
},
})
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
const ERLAUBTE_EINSTELLUNGEN = new Set([
'ablage',
'transcodeEnabled',
'transcodePreset',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'makemkvAppKey',
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
])
function einstellungenSenden(): void {
const werte: Record<string, string> = { ablage: ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
// AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine
// Einstellungs-Quelle bleibt trotzdem der Kern.
anHauptUndFenster({ art: 'einstellungen', werte })
}
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
async function schluesselLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { schluesselKette } = await import('./werkzeuge/schluessel')
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
schluesselStand = await schluesselKette(
makemkvcon,
db?.einstellungLesen('makemkvAppKey') ?? '',
new Date().toISOString(),
)
anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand })
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
console.error(`[kern] Schlüsselkette: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` })
}
}
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
// Prozess-Leine und überlebt ein geschlossenes Rippy).
let beschaffungLaeuft = false
let installerAntwort: ((fehler: string | null) => void) | null = null
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
}
async function makemkvUpdateLauf(): Promise<void> {
if (beschaffungLaeuft) {
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return
}
beschaffungLaeuft = true
try {
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
'./werkzeuge/beschaffen'
)
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
beschaffungLaeuft = false
return
}
const installiert = schluesselStand?.version ?? ''
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
beschaffungLaeuft = false
return
}
// Merken, woran eine gelungene Installation zu erkennen ist: Die
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
// erstmals eine).
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
beschaffungMelden(text, anteil, true),
)
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const startFehler = await new Promise<string | null>((fertig) => {
installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
beschaffungLaeuft = false
return
}
beschaffungMelden(
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
null,
true,
)
// Warten, bis die Installation durch ist: Die Versions-Ressource der
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
let runden = 0
const zeiger = setInterval(() => {
runden += 1
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
clearInterval(zeiger)
beschaffungLaeuft = false
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
void schluesselLauf().then(() => werkzeugeSenden())
} else if (runden >= 80) {
clearInterval(zeiger)
beschaffungLaeuft = false
beschaffungMelden(
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
null,
false,
)
}
}, 15_000)
zeiger.unref()
} catch (fehler) {
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
beschaffungLaeuft = false
}
}
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
async function werkzeugeSenden(): Promise<void> {
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
const { encoderMessen } = await import('./komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
}
async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit
if (ripVerwaltung.laeuft(id)) {
anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (laufwerkApi === null) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
return
}
const geraete = listOptischeLaufwerke(laufwerkApi)
const geraet = geraete.find((g) => kennung(g) === id)
if (geraet === undefined) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
wache?.runde()
}
process.parentPort.on('message', (ereignis) => {
const nachricht: unknown = ereignis.data
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
installerAntwort?.(nachricht.fehler ?? '')
installerAntwort = null
return
}
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
// Fenster bringt einen neuen Port — der alte wird geschlossen.
fensterPort?.close()
fensterPort = ereignis.ports[0] ?? null
if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data
if (!istFensterNachricht(frage)) return
if (frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') {
void auswerfen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'metadaten-suchen') {
void metadatenSuchen(frage.id, frage.text)
} else if (frage.art === 'zuordnung-setzen') {
void zuordnungSetzen(frage.id, frage.vorschlag)
} else if (frage.art === 'einstellung-setzen') {
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
} else if (db === null) {
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
} else if (frage.art === 'werkzeuge-laden') {
void werkzeugeSenden()
} else if (frage.art === 'bibliothek-laden') {
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} else if (frage.art === 'makemkv-update') {
void makemkvUpdateLauf()
} else if (frage.art === 'schluessel-pruefen') {
void schluesselLauf().then(() => werkzeugeSenden())
}
})
fensterPort.start()
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
anFenster(bereitNachricht())
void laufwerkeBereit.then(() => {
if (laufwerkFehler !== null) {
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
}
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
if (schluesselStand !== null) {
anFenster({ art: 'schluessel-status', stand: schluesselStand })
}
})
}
})
function bereitNachricht(): KernNachricht {
return {
art: 'kern-bereit',
pid: process.pid,
nodeVersion: process.versions.node,
datenbank: dbStatus,
}
}
process.parentPort.postMessage(bereitNachricht())
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
einstellungenSenden()
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden()
}
@@ -0,0 +1,101 @@
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
//
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
// Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { spawn } from 'node:child_process'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
export function parseEncoderListe(hilfeText: string): string[] {
const encoder: string[] = []
let imBlock = false
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
const inhalt = zeile.trim()
if (!imBlock) {
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
continue
}
if (inhalt.length === 0) continue
if (inhalt.startsWith('-')) break
encoder.push(inhalt.split(/\s+/)[0])
}
return encoder
}
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
['nvenc', 'nvenc'],
['qsv', 'qsv'],
['vce', 'vce'],
['vaapi', 'vaapi'],
]
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
const gefunden: string[] = []
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
for (const [kennzeichen, backend] of HW_FAMILIEN) {
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
if (treffer.length === 0) continue
gefunden.push(backend)
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
}
return gefunden
}
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
export function parsePresetListe(text: string): string[] {
const presets: string[] = []
for (const zeile of (text ?? '').split(/\r?\n/)) {
if (zeile.trim().length === 0) continue
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
if (einrueckung === 4) presets.push(zeile.trim())
}
return presets
}
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig('')
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(textVon(Buffer.concat(stuecke)))
})
})
}
export interface EncoderAuskunft {
encoder: string[]
backends: string[]
presets: string[]
}
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
const [hilfe, presetListe] = await Promise.all([
lauf(programm, ['--help'], 30_000),
lauf(programm, ['--preset-list'], 30_000),
])
const encoder = parseEncoderListe(hilfe)
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
}
@@ -0,0 +1,271 @@
// HandBrakeCLI ansprechen — Kommandobau, Lauf und die Auswertung mit ALLEN
// bezahlten Fallen aus dem Docker-Zweig (ripping.py, jeweils am echten
// HandBrake gemessen):
//
// - `--aencoder`, NICHT `--audio-codec`: Den zweiten Schalter gibt es
// nicht, und ein unbekannter Schalter ist für HandBrake KEIN Fehler —
// Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip (30.08.2026).
// - `--format` aus der Endung erzwingen: Der Container kommt sonst aus
// dem PRESET — ein MP4-Preset schreibt title.mp4 neben das verlangte
// .mkv und meldet Erfolg (29.08.2026).
// - Fortschritt NUR aus der `Encoding: task N of M, x %`-Zeile — der
// Scan-Durchlauf läuft vorher bis 100 % und ließ die Anzeige bei 99 %
// kleben, während der Encode bei 1 % war (25.07.2026).
// - Die letzten 12 Ausgabezeilen aufheben: Bei Rückgabewert 0 sind sie
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path'
import { textVon } from '../rip/parser'
import { FORMATE } from './presets'
const { basename, dirname, extname, join } = pfad
export interface HandbrakeArgsOptionen {
eingabe: string
ausgabe: string
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
}
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
if (format !== undefined) args.push('--format', format)
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
args.push('--first-audio')
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles')
return args
}
// Formatstrings aus dem HandBrakeCLI-Binary gelesen (Regel D):
// Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
// Encoding: task %d of %d, %.2f %% → Encode
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
/** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss
* von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
export function progressAusZeile(zeile: string): number {
const treffer = HB_ENCODE.exec(zeile.trim())
if (treffer === null) return -1
const aktuell = Number(treffer[1])
const gesamt = Number(treffer[2])
const prozent = Number(treffer[3])
if (gesamt < 1 || aktuell < 1) return -1
return Math.floor(((aktuell - 1) * 100 + prozent) / gesamt)
}
/** „Invalid preset X" → X (Wortlaut aus dem echten Lauf, 26.07.2026). */
export function unbekanntesPreset(zeile: string): string {
const text = (zeile ?? '').trim()
const kopf = 'Invalid preset '
return text.startsWith(kopf) ? text.slice(kopf.length).trim() : ''
}
/** „unknown option (--x)" → --x. Rückgabewert bleibt dabei 0! */
export function unbekannterSchalter(zeile: string): string {
const treffer = /unknown option \(([^)]*)\)/.exec(zeile ?? '')
return treffer === null ? '' : treffer[1].trim()
}
export const HB_ZEILEN_PUFFER = 12
export const HB_ZEILEN_MELDUNG = 4
/** HandBrakes letzte Worte als Anhang für eine Fehlermeldung (pur). */
export function hbSchluss(zeilen: readonly string[]): string {
const sauber = zeilen.map((z) => z.trim()).filter((z) => z.length > 0)
if (sauber.length === 0) return ''
return ' — HandBrake sagte zuletzt: ' + sauber.slice(-HB_ZEILEN_MELDUNG).join(' | ')
}
/** Dieselbe Datei mit anderer Endung im selben Ordner — oder ''. */
export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean = existsSync): string {
const ordner = dirname(erwartet)
const stamm = basename(erwartet, extname(erwartet))
for (const endung of Object.keys(FORMATE)) {
const kandidat = join(ordner, stamm + endung)
if (kandidat !== erwartet && existiert(kandidat)) return kandidat
}
return ''
}
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung {
prozent = -1
falschesPreset = ''
falscherSchalter = ''
letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } {
if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
}
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) {
this.prozent = p
return { prozent: p }
}
return {}
}
/** Das Urteil NACH Prozessende in der bewiesenen Reihenfolge:
* falscher Schalter (kommt mit Code 0!) Erfolg Datei daneben
* Erfolg ohne Datei falsches Preset Code mit letzten Worten. */
urteil(
returncode: number | null,
ausgabe: string,
existiert: (p: string) => boolean = existsSync,
): { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'error'; fehler: string } {
if (this.falscherSchalter.length > 0) {
return {
status: 'error',
fehler:
`Rippy hat HandBrake den Schalter „${this.falscherSchalter}" übergeben, den diese ` +
'HandBrake-Fassung nicht kennt. Das ist ein Fehler in Rippy, keine Einstellung — bitte melden.' +
hbSchluss(this.letzteZeilen),
}
}
if (returncode === 0 && existiert(ausgabe)) {
return { status: 'success', ausgabe }
}
if (returncode === 0) {
const daneben = dateiDaneben(ausgabe, existiert)
if (daneben.length > 0) {
return {
status: 'success',
ausgabe: daneben,
hinweis: `HandBrake hat ${basename(daneben)} geschrieben statt ${basename(ausgabe)} (der Container kommt aus dem Preset).`,
}
}
return {
status: 'error',
fehler:
`HandBrake meldet Erfolg, aber es ist keine Datei entstanden (Zielordner: ${dirname(ausgabe)}).` +
hbSchluss(this.letzteZeilen),
}
}
if (this.falschesPreset.length > 0) {
return {
status: 'error',
fehler:
`Dieses HandBrake kennt das Preset „${this.falschesPreset}" nicht. Preset-Namen unterscheiden ` +
'sich zwischen HandBrake-Versionen — in den Einstellungen eines aus der Liste wählen, die von ' +
'diesem HandBrake selbst kommt.',
}
}
return {
status: 'error',
fehler: `HandBrake endete mit Code ${returncode ?? '?'}` + hbSchluss(this.letzteZeilen),
}
}
}
/** HandBrake-Ausgabe in Zeilen: CR UND LF trennen (HandBrake schreibt den
* Fortschritt mit \r). Je Zeile einzeln dekodiert HandBrake schreibt
* denselben Pfad mal UTF-8, mal CP850 in einen Strom; ein `ü` (CP850
* 0x81) tötete so die Python-Fassung. textVon ersetzt statt zu sterben. */
export class HbZeilenLeser {
private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = []
for (;;) {
let trenner = -1
for (let i = 0; i < this.rest.length; i++) {
if (this.rest[i] === 0x0a || this.rest[i] === 0x0d) {
trenner = i
break
}
}
if (trenner < 0) break
if (trenner > 0) zeilen.push(textVon(this.rest.subarray(0, trenner)))
// \r\n als EIN Trenner behandeln.
const naechstes = trenner + 1
const doppelt =
this.rest[trenner] === 0x0d && naechstes < this.rest.length && this.rest[naechstes] === 0x0a
this.rest = this.rest.subarray(doppelt ? naechstes + 1 : naechstes)
}
return zeilen
}
schluss(): string[] {
if (this.rest.length === 0) return []
const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : []
}
}
export type HbErgebnis =
| { status: 'success'; ausgabe: string; hinweis?: string }
| { status: 'cancelled' }
| { status: 'error'; fehler: string }
export interface HbLaufOptionen {
programm: string
eingabe: string
ausgabe: string
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void
abbruch?: AbortSignal
}
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler) })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
fertig(auswertung.urteil(code, optionen.ausgabe))
})
})
}
@@ -0,0 +1,73 @@
// Preset-Wahl je Disc-Typ (KONZEPT § 6.10) — Regeln aus ripping.py, dort
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
// 4K auf 1080p herunter und DVDs hinauf.
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
export { DEFAULT_HB_PRESET, presetEmpfehlung }
/** Einstellungs-Schlüssel je Disc-Typ. */
export const PRESET_SCHLUESSEL: Record<string, string> = {
dvd: 'transcodePresetDvd',
bluray: 'transcodePresetBluray',
uhd: 'transcodePresetUhd',
}
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
export const PRESET_KEINE = 'keine'
export type Einstellungen = Record<string, string | boolean | undefined>
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
return false
}
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const wert = String(einstellungen[schluessel] ?? '').trim()
if (wert === PRESET_KEINE) return false
}
return true
}
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
* Kette: Typ-Preset allgemeines transcodePreset EMPFEHLUNG (gemessen,
* Hardware zuerst) CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN diese
* Funktion darf nie keine" als Preset-Namen liefern, sonst bekäme
* HandBrake `--preset keine` und scheiterte. */
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const eigen = String(einstellungen[schluessel] ?? '').trim()
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
}
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
if (allgemein.length > 0) return allgemein
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
}
/** deu, eng , ,DEU" ["deu","eng"] HandBrake will ISO-639-2 klein;
* ein DEU" aus einem Eingabefeld soll die Auswahl nicht still lahmlegen. */
export function sprachliste(wert: string | readonly string[] | null | undefined): string[] {
if (wert === null || wert === undefined) return []
const teile = typeof wert === 'string' ? wert.split(',') : [...wert]
const sauber: string[] = []
for (const teil of teile) {
const code = String(teil).trim().toLowerCase()
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
}
return sauber
}
/** Endung HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
* gegengeprüft (`--help`, Abschnitt `-f, --format`). */
export const FORMATE: Record<string, string> = {
'.mkv': 'av_mkv',
'.mp4': 'av_mp4',
'.m4v': 'av_mp4',
'.mov': 'av_mov',
'.webm': 'av_webm',
}
+94
View File
@@ -0,0 +1,94 @@
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
// allein in win32.ts (Wächter R1).
//
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
// Microsoft-Dokumentation genannten Zahlen gegen.
//
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
// dort an echter Hardware bewiesen).
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
}
const FILE_DEVICE_CD_ROM = 0x02
const FILE_DEVICE_DISK = 0x07
const FILE_DEVICE_MASS_STORAGE = 0x2d
const METHOD_BUFFERED = 0
const METHOD_OUT_DIRECT = 2
const FILE_ANY_ACCESS = 0
const FILE_READ_ACCESS = 0x0001
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
export const CDROM_TOC_BYTES = 4 + 100 * 8
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
export const SDD_VENDOR_ID_OFFSET = 12
export const SDD_PRODUCT_ID_OFFSET = 16
export const SDD_PRODUCT_REVISION_OFFSET = 20
export const SDD_SERIAL_NUMBER_OFFSET = 24
export const CDROM_DISK_AUDIO_TRACK = 0x01
export const CDROM_DISK_DATA_TRACK = 0x02
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
export const ERROR_INVALID_FUNCTION = 1
export const ERROR_FILE_NOT_FOUND = 2
export const ERROR_ACCESS_DENIED = 5
export const ERROR_NOT_READY = 21
export const ERROR_NOT_SUPPORTED = 50
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
export const DRIVE_CDROM = 5
export const GENERIC_READ = 0x8000_0000
export const FILE_SHARE_READ = 0x01
export const FILE_SHARE_WRITE = 0x02
export const OPEN_EXISTING = 3
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
export class Win32Fehler extends Error {
constructor(
was: string,
readonly code: number,
) {
super(`${was} scheiterte (Win32-Fehler ${code})`)
this.name = 'Win32Fehler'
}
}
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
export interface LaufwerkApi {
laufwerksbuchstaben(): string[]
laufwerkstyp(buchstabe: string): number
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM
* bei gestörter Disc scheitert das zweite und das erste geht weiter
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
oeffnen(geraetepfad: string, zugriff: number): unknown
schliessen(handle: unknown): void
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
}
+344
View File
@@ -0,0 +1,344 @@
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
// (windows.py, an echter Hardware gemessen 28.08.2026):
//
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
// Auswurf mit Erfolg und tut nichts (R3).
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
import {
CDROM_DISK_AUDIO_TRACK,
CDROM_DISK_DATA_TRACK,
ERROR_ACCESS_DENIED,
ERROR_FILE_NOT_FOUND,
ERROR_INVALID_FUNCTION,
ERROR_NOT_READY,
ERROR_NOT_SUPPORTED,
ERROR_NO_MEDIA_IN_DRIVE,
CDROM_TOC_BYTES,
DRIVE_CDROM,
GENERIC_READ,
IOCTL_CDROM_DISK_TYPE,
IOCTL_CDROM_RAW_READ,
IOCTL_CDROM_READ_TOC,
IOCTL_DISK_GET_LENGTH_INFO,
IOCTL_STORAGE_CHECK_VERIFY2,
IOCTL_STORAGE_EJECT_MEDIA,
IOCTL_STORAGE_MEDIA_REMOVAL,
IOCTL_STORAGE_QUERY_PROPERTY,
SDD_PRODUCT_ID_OFFSET,
SDD_PRODUCT_REVISION_OFFSET,
SDD_SERIAL_NUMBER_OFFSET,
SDD_VENDOR_ID_OFFSET,
Win32Fehler,
type LaufwerkApi,
} from './codes'
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
export const UHD_MIN_BYTES = 55 * 1024 ** 3
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
if (spurFlags & CDROM_DISK_DATA_TRACK) {
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
}
return 'unknown'
}
/** D:" oder D" oder D:\" \\.\D: der Gerätepfad hat KEINEN
* abschließenden Backslash. Mit einem öffnet CreateFileW das
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
* ERROR_INVALID_FUNCTION das sieht aus wie kann das Laufwerk nicht". */
export function geraetepfad(buchstabe: string): string {
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
return '\\\\.\\' + kern[0].toUpperCase() + ':'
}
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
export function kennung(geraet: string): string {
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
}
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
return api
.laufwerksbuchstaben()
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
.map(geraetepfad)
.sort()
}
// ── Zustand ─────────────────────────────────────────────────────────────
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
try {
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
return true
} catch (fehler) {
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
return false
}
throw fehler
}
}
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
const handle = api.oeffnen(geraet, zugriff)
try {
return tun(handle)
} finally {
api.schliessen(handle)
}
}
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
export function spurFlags(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
})
}
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
})
}
// ── Verriegeln ──────────────────────────────────────────────────────────
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
mitHandle(api, geraet, GENERIC_READ, (h) => {
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
})
}
// ── Auswerfen ───────────────────────────────────────────────────────────
export type { AuswurfGrund }
const AUSWURF_NACHSEHEN_RUNDEN = 5
/** Entriegeln auswerfen NACHSEHEN (R3). Wirft NIE; gibt den Grund
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
* prüfbar ist. */
export async function auswerfenMitGrund(
api: LaufwerkApi,
geraet: string,
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
let handle: unknown
try {
handle = api.oeffnen(geraet, GENERIC_READ)
} catch (fehler) {
return { grund: 'kein-zugriff', fehler: String(fehler) }
}
try {
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
let entriegelnFehler: string | null = null
try {
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
} catch (fehler) {
entriegelnFehler = String(fehler)
}
try {
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
try {
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
await warten(1000)
}
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
} finally {
try {
api.schliessen(handle)
} catch {
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
}
}
}
// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ─────────────────────────────
/** Das Inhaltsverzeichnis einer Audio-CD leere Liste, wenn keine drin.
* Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */
export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES)
return tocZerlegen(roh)
})
}
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
* DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
* 2352 Bytes hat dokumentiert, sieht falsch aus, ist richtig. */
export function audioSpurLesen(
api: LaufwerkApi,
geraet: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const teile = bloecke(start, sektoren)
let gelesen = 0
for (let i = 0; i < teile.length; i++) {
const [von, anzahl] = teile[i]
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
schreiben(daten)
gelesen += daten.length
fortschritt?.((i + 1) / teile.length)
}
return gelesen
})
}
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
const ZUGRIFFS_GRUENDE: Record<number, string> = {
[ERROR_INVALID_FUNCTION]:
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
}
export function zugriffsGrund(fehler: unknown): string {
const code = fehler instanceof Win32Fehler ? fehler.code : null
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
if (code !== null) {
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
}
return 'Das Laufwerk antwortet nicht.'
}
// ── Geräteangaben ───────────────────────────────────────────────────────
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
* Offset 0 heißt gibt es nicht" (dokumentiertes Verhalten des
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
export function textBei(puffer: Buffer, offsetPos: number): string {
if (puffer.length < offsetPos + 4) return ''
const offset = puffer.readUInt32LE(offsetPos)
if (offset === 0 || offset >= puffer.length) return ''
let ende = puffer.indexOf(0, offset)
if (ende < 0) ende = puffer.length
return puffer.toString('latin1', offset, ende).trim()
}
export interface Geraeteangaben {
hersteller: string
modell: string
seriennummer: string
}
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht ein Laufwerk
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
* wenn das Medium gestört ist (rc11-Befund). */
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
try {
return mitHandle(api, geraet, 0, (h) => {
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
return {
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
.filter((t) => t.length > 0)
.join(' '),
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
}
})
} catch {
return leer
}
}
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
// rippt (Muster aus windows.py).
const angabenSpeicher = new Map<string, Geraeteangaben>()
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
let angaben = angabenSpeicher.get(geraet)
if (angaben === undefined) {
angaben = geraeteangaben(api, geraet)
angabenSpeicher.set(geraet, angaben)
}
return angaben
}
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
export function angabenSpeicherLeeren(): void {
angabenSpeicher.clear()
}
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
let status: LaufwerkStatus = 'unknown'
let typ: DiscTyp = 'unknown'
let groesse = 0
let grund = ''
try {
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
if (da) {
status = 'ready'
try {
groesse = discGroesseBytes(api, geraet)
typ = classify(spurFlags(api, geraet), groesse)
} catch (fehler) {
typ = 'unknown'
grund = zugriffsGrund(fehler)
}
} else {
status = 'empty'
}
} catch (fehler) {
status = 'unknown'
grund = zugriffsGrund(fehler)
}
const buchstabe = kennung(geraet)
const angaben = angabenGemerkt(api, geraet)
return {
id: buchstabe,
name: `Laufwerk ${buchstabe}:`,
typ,
pfad: geraet,
status,
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
seriennummer: angaben.seriennummer,
groesseBytes: groesse,
grund,
}
}
+107
View File
@@ -0,0 +1,107 @@
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
// bleibt dieser Takt als Rückfallebene bestehen.
//
// Regeln, die hier verdrahtet sind:
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
export type { WacheEreignis }
/** Pure Funktion: Zustandsvergleich Ereignisse (Muster aus
* bus/waechter.py `laufwerks_unterschiede`). */
export function laufwerksUnterschiede(
vorher: ReadonlyMap<string, GeraeteInfo>,
jetzt: ReadonlyMap<string, GeraeteInfo>,
): WacheEreignis[] {
const ereignisse: WacheEreignis[] = []
for (const [id, stand] of jetzt) {
const alt = vorher.get(id)
if (alt === undefined) {
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
continue
}
const vorherBereit = alt.status === 'ready'
const jetztBereit = stand.status === 'ready'
if (!vorherBereit && jetztBereit) {
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
// Disc (R2); der Grund steht im Geräte-Eintrag.
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
}
}
for (const id of vorher.keys()) {
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
}
return ereignisse
}
export const WACHE_TAKT_MS = 3000
export interface WacheMeldung {
laufwerke: GeraeteInfo[]
ereignisse: WacheEreignis[]
}
export class DiscWache {
private stand = new Map<string, GeraeteInfo>()
private zeiger: NodeJS.Timeout | null = null
private laeuft = false
constructor(
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
private readonly lesen: () => GeraeteInfo[],
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
private readonly melden: (meldung: WacheMeldung) => void,
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
private readonly fehlerMelden: (fehler: unknown) => void,
private readonly taktMs: number = WACHE_TAKT_MS,
) {}
starten(): void {
if (this.laeuft) return
this.laeuft = true
this.runde()
}
stoppen(): void {
this.laeuft = false
if (this.zeiger !== null) {
clearTimeout(this.zeiger)
this.zeiger = null
}
}
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
runde(): void {
if (!this.laeuft) return
try {
const liste = this.lesen()
const jetzt = new Map(liste.map((g) => [g.id, g]))
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
const geaendert =
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
this.stand = jetzt
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
} catch (fehler) {
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
this.fehlerMelden(fehler)
}
if (this.laeuft) {
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
// Der Takt darf einen Programm-Exit nicht aufhalten.
this.zeiger.unref?.()
}
}
aktuellerStand(): GeraeteInfo[] {
return [...this.stand.values()]
}
}
+88
View File
@@ -0,0 +1,88 @@
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
// beweise/laufwerk.js, 30.08.2026).
import koffi from 'koffi'
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
export type { LaufwerkApi }
let echteApi: LaufwerkApi | null = null
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
export function win32Api(): LaufwerkApi {
if (echteApi !== null) return echteApi
const kernel32 = koffi.load('kernel32.dll')
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()')
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)')
const CreateFileW = kernel32.func(
'void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)',
)
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const DeviceIoControl = kernel32.func(
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
)
echteApi = {
laufwerksbuchstaben(): string[] {
const maske = GetLogicalDrives() as number
const buchstaben: string[] = []
for (let i = 0; i < 26; i++) {
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
}
return buchstaben
},
laufwerkstyp(buchstabe: string): number {
return GetDriveTypeW(`${buchstabe}:\\`) as number
},
oeffnen(geraetepfad: string, zugriff: number): unknown {
const handle = CreateFileW(
geraetepfad,
zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE,
null,
OPEN_EXISTING,
0,
null,
)
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
throw new Win32Fehler('CreateFileW', GetLastError() as number)
}
return handle
},
schliessen(handle: unknown): void {
CloseHandle(handle)
},
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
const zurueck = [0]
const erfolg = DeviceIoControl(
handle,
code,
eingabe ?? null,
eingabe?.length ?? 0,
aus,
ausgabeBytes,
zurueck,
null,
)
if (!erfolg) {
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
}
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
},
}
return echteApi
}
@@ -0,0 +1,187 @@
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
// aus prescan.py, dort an echten Discs gemessen:
//
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
//
// KEIN makemkvcon hier: Der Zweig kostete 20120 s je eingelegter Disc
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
// sehr sehr lange").
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
// Plattform-Pfade: titelAusBdmt läuft auf einer LAUFZEIT-Wurzel (G:\ im
// Betrieb, ein tmp-Ordner im Linux-CI-Test) — dieselbe Regel wie in
// struktur.ts, am 30.08.2026 zum dritten Mal gelernt.
import { join } from 'node:path'
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
export function normalizeDiscLabel(label: string): string {
if (label.length === 0) return ''
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
if (bereinigt === bereinigt.toUpperCase()) {
return bereinigt
.toLowerCase()
.split(' ')
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join(' ')
}
return bereinigt
}
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
export function parseUdfDstring(daten: Buffer): string {
if (daten.length === 0) return ''
const laenge = daten[daten.length - 1]
if (laenge < 2 || laenge > daten.length - 1) return ''
const compId = daten[0]
const text = daten.subarray(1, laenge)
if (compId === 8) return text.toString('latin1').trim()
if (compId === 16) {
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
const getauscht = Buffer.alloc(text.length - (text.length % 2))
for (let i = 0; i + 1 < text.length; i += 2) {
getauscht[i] = text[i + 1]
getauscht[i + 1] = text[i]
}
return getauscht.toString('utf16le').trim()
}
return ''
}
const SEKTOR = 2048
function lesenBei(fd: number, position: number, laenge: number): Buffer {
const puffer = Buffer.alloc(laenge)
const gelesen = readSync(fd, puffer, 0, laenge, position)
return puffer.subarray(0, gelesen)
}
/** ISO-9660-Volume-Label: Sektor 16, Bytes 4071 (DVDs). '' wenn keins. */
export function isoVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
return pvd.subarray(40, 72).toString('latin1').trim()
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256
* Main VDS PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
export function udfVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
const vdsOrt = avdp.readUInt32LE(20)
for (let i = 0; i < 32; i++) {
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
if (block.length < 56) return ''
const tagId = block.readUInt16LE(0)
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
if (tagId === 8) return '' // Terminating Descriptor
}
return ''
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml bdmt_eng bevorzugt,
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
export function titelAusBdmt(wurzel: string): string {
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
if (!existsSync(metaOrdner)) return ''
let kandidaten: string[]
try {
kandidaten = readdirSync(metaOrdner).sort()
} catch {
return ''
}
const bdmt =
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmt === undefined) return ''
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
return ''
}
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
return treffer === null ? '' : treffer[1].trim()
}
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
export function discWurzel(geraetepfad: string): string {
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
const wurzel = buchstabe + ':\\'
return existsSync(wurzel) ? wurzel : ''
}
export interface DiscTitel {
titel: string
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
quelle: 'bdmt' | 'label' | ''
}
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
* BDMT-Titel wird NICHT normalisiert (2.22" würde zu „2 22"). */
export function discTitel(geraetepfad: string): DiscTitel {
const wurzel = discWurzel(geraetepfad)
if (wurzel.length > 0) {
const bdmt = titelAusBdmt(wurzel)
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
}
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
return { titel: '', quelle: '' }
}
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der
* Duplikat-Erkennung in der Bibliothek. */
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
return `${label}|${groesseBytes}`
}
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
* Klammern, dann hintere Worte strippen. */
export function titelKandidaten(titel: string): string[] {
const kandidaten: string[] = []
const merke = (t: string): void => {
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
}
merke(titel)
if (titel.includes(':')) {
merke(titel.split(':', 1)[0])
merke(titel.replaceAll(':', ''))
}
merke(titel.replace(/\([^)]*\)/g, ''))
let worte = titel.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < 6) {
worte = worte.slice(0, -1)
merke(worte.join(' '))
}
return kandidaten
}
@@ -0,0 +1,123 @@
// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3,
// Vorlage musicbrainz_cd.py — Rechnung laut
// musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte
// AC/DC-„Back in Black"-Antwort bewiesen).
//
// DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das
// Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die
// MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann
// einfach „unbekannte Disc".
import { createHash } from 'node:crypto'
import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd'
const DIENST = 'https://musicbrainz.org/ws/2/discid/'
// MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie
// wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie).
const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' }
/** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */
export const PLAETZE = 99
/** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */
export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string {
if (spurVersaetze.length === 0) return ''
const letzteSpur = letzte ?? erste + spurVersaetze.length - 1
const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0')
const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0')
const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)]
for (let platz = 0; platz < PLAETZE; platz++) {
teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0))
}
const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64')
// MusicBrainz' eigenes Alphabet: + / = werden zu . _ -
return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-')
}
/** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */
export function kennungAusSpuren(spuren: readonly Spur[]): string {
if (spuren.length === 0) return ''
const geordnet = [...spuren].sort((a, b) => a.nr - b.nr)
const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES)
const letzte = geordnet[geordnet.length - 1]
const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES
return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr)
}
export interface MusicBrainzAntwort {
releases?: Array<{
title?: string
date?: string
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
media?: Array<{
tracks?: Array<{
position?: number | string
title?: string
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
}>
}>
}>
}
/** Nachschlagen null bei unbekannter Disc, keinem Netz oder Fremdem:
* alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */
export async function abfragen(
kennung: string,
holen: typeof fetch = fetch,
): Promise<MusicBrainzAntwort | null> {
if (kennung.length === 0) return null
try {
const antwort = await holen(
`${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`,
{ headers: KOPF, signal: AbortSignal.timeout(15_000) },
)
if (antwort.status >= 300) return null
return (await antwort.json()) as MusicBrainzAntwort
} catch {
return null
}
}
function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string {
// „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " +
// „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte.
return (eintrag ?? [])
.map((s) => (s.name ?? '') + (s.joinphrase ?? ''))
.join('')
.trim()
}
/** Antwort {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc
* bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */
export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map<number, Record<string, string>> {
const tags = new Map<number, Record<string, string>>()
const release = antwort?.releases?.[0]
if (release === undefined) return tags
const album = release.title ?? ''
const jahr = (release.date ?? '').slice(0, 4)
const albumKuenstler = kuenstler(release['artist-credit'])
for (const medium of release.media ?? []) {
for (const spur of medium.tracks ?? []) {
const nr = Number(spur.position ?? 0)
if (!Number.isInteger(nr) || nr <= 0) continue
tags.set(nr, {
TITLE: spur.title ?? '',
ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler,
ALBUM: album,
ALBUMARTIST: albumKuenstler,
DATE: jahr,
TRACKNUMBER: String(nr),
})
}
}
return tags
}
/** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */
export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string {
const tags = tagsJeSpur(antwort)
if (tags.size === 0) return ''
const erste = tags.get(Math.min(...tags.keys()))!
const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || ''
const album = erste['ALBUM'] || ''
const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer
return saubern(roh)
}
+59
View File
@@ -0,0 +1,59 @@
// OMDb-Client — eigene Datenbasis, findet oft, was TMDb nicht exakt
// trifft. Nur Englisch; der deutsche Datensatz wird danach über
// TMDb /find (IMDb-ID) nachgeladen (zuordnung.ts). Ohne Key überspringt
// er sich selbst — kein Fehler, keine Behauptung.
export interface OmdbTreffer {
type: 'movie' | 'tv'
id: string
title: string
year: number | null
overview: string
poster_path: string
runtime: number
genres: string[]
source: 'omdb'
}
export class OmdbClient {
constructor(
private readonly apiKey: string,
private readonly holen: typeof fetch = fetch,
) {}
get verfuegbar(): boolean {
return this.apiKey.length > 0
}
async lookup(titel: string): Promise<OmdbTreffer | null> {
if (!this.verfuegbar) return null
try {
const url = new URL('https://www.omdbapi.com/')
url.searchParams.set('apikey', this.apiKey)
url.searchParams.set('t', titel)
url.searchParams.set('r', 'json')
const antwort = await this.holen(url.toString(), { signal: AbortSignal.timeout(15_000) })
if (antwort.status >= 300) return null
const daten = (await antwort.json()) as Record<string, string>
if (daten['Response'] !== 'True') return null
const jahr = /(\d{4})/.exec(daten['Year'] ?? '')
const laufzeit = /(\d+)/.exec(daten['Runtime'] ?? '')
return {
type: daten['Type'] === 'series' ? 'tv' : 'movie',
id: daten['imdbID'] ?? '',
title: daten['Title'] ?? titel,
year: jahr === null ? null : Number(jahr[1]),
overview: daten['Plot'] ?? '',
poster_path: daten['Poster'] !== 'N/A' ? (daten['Poster'] ?? '') : '',
runtime: laufzeit === null ? 0 : Number(laufzeit[1]),
genres: (daten['Genre'] ?? '')
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0),
source: 'omdb',
}
} catch {
return null
}
}
}
+130
View File
@@ -0,0 +1,130 @@
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
export interface FilmTreffer {
id: number
title?: string
name?: string
poster_path?: string
release_date?: string
first_air_date?: string
}
export interface FilmDetails {
id: number
title?: string
name?: string
release_date?: string
first_air_date?: string
overview?: string
poster_path?: string
backdrop_path?: string
runtime?: number
genres?: Array<{ name: string }>
}
export function istV4Token(key: string): boolean {
return key.startsWith('eyJ')
}
const BASIS = 'https://api.themoviedb.org/3'
export class TmdbClient {
constructor(
private readonly apiKey: string,
private readonly holen: typeof fetch = fetch,
) {}
get verfuegbar(): boolean {
return this.apiKey.length > 0
}
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
if (!this.verfuegbar) return null
const url = new URL(`${BASIS}/${weg}`)
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
const kopf: Record<string, string> = { Accept: 'application/json' }
if (istV4Token(this.apiKey)) {
kopf['Authorization'] = `Bearer ${this.apiKey}`
} else {
url.searchParams.set('api_key', this.apiKey)
}
try {
const antwort = await this.holen(url.toString(), {
headers: kopf,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) return null
return await antwort.json()
} catch {
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
return null
}
}
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/movie', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/tv', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
}
async tvDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
episodes?: Array<{ episode_number: number; runtime?: number }>
} | null
}
/** IMDb-ID deutscher Datensatz (für OMDb-Treffer, die nur Englisch
* können Commander-Wunsch 24.07.). */
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
const daten = (await this.anfrage(`find/${imdbId}`, {
external_source: 'imdb_id',
language: 'de-DE',
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
const film = daten?.movie_results?.[0]
if (film !== undefined) {
return {
title: film.title,
overview: film.overview,
poster_path: film.poster_path,
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
type: 'movie',
}
}
const serie = daten?.tv_results?.[0]
if (serie !== undefined) {
return {
title: serie.name,
overview: serie.overview,
poster_path: serie.poster_path,
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
type: 'tv',
}
}
return null
}
}
@@ -0,0 +1,231 @@
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
// prescan.py mit ihren gemessenen Begründungen:
//
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
// 0,90 wörtlicher TMDb-Serientreffer
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
// 0,30 unbekannt
//
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
import { titelKandidaten } from './discmerkmale'
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
export const WENIGE_TREFFER = 3
export interface MetaTreffer {
typ: 'movie' | 'tv' | 'unknown'
id: number | string | null
titel: string
jahr: number | null
beschreibung: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
}
export interface Zuordnung {
titel: string
jahr: number | null
confidence: number
meta: MetaTreffer
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
return {
typ,
id,
titel: (typ === 'movie' ? details.title : details.name) ?? '',
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: details.runtime ?? 0,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
}
}
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
return {
typ: treffer.type,
id: treffer.id,
titel: treffer.title,
jahr: treffer.year,
beschreibung: treffer.overview,
posterPfad: treffer.poster_path,
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
backdropPfad: '',
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
}
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: {
typ: 'unknown',
id: null,
titel,
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
},
})
/** Wort-Überlappung zweier Titel (Jaccard, 01) das Gate gegen
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
* aus dem Label Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur BD"). */
export function wortUeberlappung(a: string, b: string): number {
const worte = (t: string): Set<string> =>
new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
const wa = worte(a)
const wb = worte(b)
if (wa.size === 0 || wb.size === 0) return 0
let gemeinsam = 0
for (const w of wa) if (wb.has(w)) gemeinsam += 1
return gemeinsam / (wa.size + wb.size - gemeinsam)
}
/** Die Kaskade Clients injiziert, damit sie ohne Netz prüfbar ist.
*
* Reihenfolge JE KANDIDAT: wörtlich Film wörtlich Serie (nur beim
* vollen Titel) spezifisch Film spezifisch Serie. Erst dann der nächste,
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
* Beim Disc-Titel Spartacus Gods Of The Arena" erschlug der stark
* gekürzte Kandidat Spartacus" mit einem wörtlichen Treffer (Film von
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
let ersteTreffer: FilmTreffer[] = []
for (let i = 0; i < kandidaten.length; i++) {
const kandidat = kandidaten[i]
const filme = (await tmdb.searchMovie(kandidat)) ?? []
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
const details = await tmdb.movieDetails(film.id)
if (details !== null) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat)) ?? []
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
}
}
}
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
// jemand, der weiß, was er sucht (der Evangelion-Fund).
if (i === 0) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
const meta = ausDetails('movie', filme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
const details = await tmdb.tvDetails(serien[0].id)
if (details !== null) {
const meta = ausDetails('tv', serien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
}
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) {
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat)
if (treffer !== null) {
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id)
if (deutsch !== null) {
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
meta.beschreibung = deutsch['overview']
}
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
meta.posterPfad = deutsch['poster_path']
}
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
if (deutsch['type'] === 'tv') meta.typ = 'tv'
}
}
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
if (ersteTreffer.length > 0) {
const details = await tmdb.movieDetails(ersteTreffer[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteTreffer[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
}
}
return UNBEKANNT(titel)
}
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle
}
+144
View File
@@ -0,0 +1,144 @@
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
// liegt roh in 2352-Byte-Sektoren.
//
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
export const SEKTOR_BYTES = 2352
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
export const OFFSET_EINHEIT = 2048
export const FRAMES_JE_SEKUNDE = 75
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
export const VORLAUF_FRAMES = 150
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
export const BLOCK_SEKTOREN = 27
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
export const CDDA = 2
export interface Spur {
nr: number
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
start: number
sektoren: number
audio: boolean
dauer: string
}
/** MSF-Adresse logische Sektornummer (ECMA-130). Wer die 150 vergisst,
* liest jeden Track zwei Sekunden zu früh. */
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
}
export function dauerText(sektoren: number): string {
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
}
/** Die Bytes von IOCTL_CDROM_READ_TOC Spurliste. CDROM_TOC (ntddcdrm.h):
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke
* ohne ihn fehlt der letzten Spur die Länge). */
export function tocZerlegen(rohdaten: Buffer): Spur[] {
if (rohdaten.length < 12) return []
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
const adrControl = rohdaten[stelle + 1]
const nummer = rohdaten[stelle + 2]
const m = rohdaten[stelle + 5]
const s = rohdaten[stelle + 6]
const f = rohdaten[stelle + 7]
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
}
const spuren: Spur[] = []
for (let i = 0; i < eintraege.length; i++) {
const eintrag = eintraege[i]
if (eintrag.nr === 0xaa) continue
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
const laenge = eintraege[i + 1].lba - eintrag.lba
if (laenge <= 0) continue
spuren.push({
nr: eintrag.nr,
start: eintrag.lba,
sektoren: laenge,
audio: eintrag.audio,
dauer: dauerText(laenge),
})
}
return spuren
}
/** Einen Track in Lese-Blöcke zerlegen am Stück scheitert an der
* 64-KB-Grenze vieler Treiber. */
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
if (sektoren <= 0 || jeBlock <= 0) return []
const teile: Array<[number, number]> = []
let gelesen = 0
while (gelesen < sektoren) {
const anzahl = Math.min(jeBlock, sektoren - gelesen)
teile.push([start + gelesen, anzahl])
gelesen += anzahl
}
return teile
}
/** Der 44-Byte-WAV-Kopf CD-Audio IST schon PCM im WAV-Format, es fehlt
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
const byteRate = (rate * kanaele * bits) / 8
const block = (kanaele * bits) / 8
const kopf = Buffer.alloc(44)
kopf.write('RIFF', 0, 'ascii')
kopf.writeUInt32LE(36 + datenBytes, 4)
kopf.write('WAVE', 8, 'ascii')
kopf.write('fmt ', 12, 'ascii')
kopf.writeUInt32LE(16, 16)
kopf.writeUInt16LE(1, 20)
kopf.writeUInt16LE(kanaele, 22)
kopf.writeUInt32LE(rate, 24)
kopf.writeUInt32LE(byteRate, 28)
kopf.writeUInt16LE(block, 32)
kopf.writeUInt16LE(bits, 34)
kopf.write('data', 36, 'ascii')
kopf.writeUInt32LE(datenBytes, 40)
return kopf
}
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
const eingabe = Buffer.alloc(16)
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
eingabe.writeUInt32LE(anzahl, 8)
eingabe.writeUInt32LE(CDDA, 12)
return eingabe
}
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS
* AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
export function sauberName(text: string): string {
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
}
export function dateiname(nr: number, titel = ''): string {
const sauber = sauberName(titel)
const nummer = String(nr).padStart(2, '0')
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
}
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
* den Fortschritt selbst), Tags einzeln. */
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
const befehl = ['-8', '--totally-silent', '-o', ziel]
for (const [name, wert] of Object.entries(tags)) {
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
}
befehl.push(wav)
return befehl
}
+80
View File
@@ -0,0 +1,80 @@
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — die einzige echte Lücke
// gegenüber ARM: Wird eine Disc weder als Film noch als Musik erkannt,
// wird sie 1:1 gesichert statt abgelehnt. Sequenziell aus \\.\G: lesen;
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
// Bytes). Kein Fremdwerkzeug nötig.
//
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
// sequenziell ab 0.
import { open } from 'node:fs/promises'
const BLOCK_BYTES = 1024 * 1024
export type IsoErgebnis =
| { status: 'success'; bytes: number }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; bytes: number }
export async function isoSichern(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number) => void,
abbruch?: AbortSignal,
): Promise<IsoErgebnis> {
if (erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
}
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
}
const puffer = Buffer.alloc(BLOCK_BYTES)
let gelesen = 0
let letzterProzent = -1
try {
while (gelesen < erwarteteBytes) {
if (abbruch?.aborted === true) return { status: 'cancelled' }
const rest = erwarteteBytes - gelesen
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
if (bytesRead <= 0) break
await ziel.write(puffer, 0, bytesRead)
gelesen += bytesRead
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
if (prozent !== letzterProzent) {
letzterProzent = prozent
aufFortschritt?.(prozent)
}
}
} catch (fehler) {
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
} finally {
await quelle.close()
await ziel.close()
}
if (gelesen !== erwarteteBytes) {
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
return {
status: 'error',
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
bytes: gelesen,
}
}
return { status: 'success', bytes: gelesen }
}
+225
View File
@@ -0,0 +1,225 @@
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
//
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
// hing bzw. stürzte — Befund 23.07.)
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
// Kodierungen; „Das Öffnen" war die Folge von text=True.
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor.
import { spawn } from 'node:child_process'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
KRITISCHE_CODES,
KRITISCHE_TEXTE,
LESEFEHLER_MARKE,
parseMsg,
parseStreamInfo,
parseTitelInfo,
progressAusPrgv,
quelle,
sprachenZusammenfassen,
textVon,
type SprachEintrag,
type TitelInfo,
type TitelStreams,
} from './parser'
export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
}
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
}
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung {
prozent = -1
letzteMeldung = ''
lesefehler = false
kritischeMeldungen: string[] = []
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
const p = progressAusPrgv(zeile)
if (p >= 0) {
if (p === this.prozent) return {}
this.prozent = p
return { prozent: p }
}
const meldung = parseMsg(zeile)
if (meldung === null) return {}
this.letzteMeldung = meldung.text
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
if (meldung.code in KRITISCHE_CODES) {
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
this.kritischeMeldungen.push(meldung.text)
}
return { meldung }
}
fehlerText(returncode: number | null, dateienDa: boolean): string {
return (
`makemkvcon endete mit Code ${returncode ?? '?'}` +
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
)
}
}
/** Buffer-Ströme in Zeilen zerlegen binär, die letzte halbe Zeile bleibt
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
export class ZeilenLeser {
private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = []
for (;;) {
const umbruch = this.rest.indexOf(0x0a)
if (umbruch < 0) break
let ende = umbruch
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
zeilen.push(textVon(this.rest.subarray(0, ende)))
this.rest = this.rest.subarray(umbruch + 1)
}
return zeilen
}
schluss(): string[] {
if (this.rest.length === 0) return []
const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : []
}
}
export interface DiscInfo {
titel: TitelInfo[]
streams: Map<number, TitelStreams>
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
}
/** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s,
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
export function titelInfoLesen(
programm: string,
geraetepfad: string,
timeoutMs = 300_000,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
const stuecke: Buffer[] = []
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
const zeiger = setTimeout(() => {
kind.kill()
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
}, timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
scheitert(fehler)
})
kind.on('close', () => {
clearTimeout(zeiger)
const ausgabe = textVon(Buffer.concat(stuecke))
const titel = parseTitelInfo(ausgabe)
const streams = parseStreamInfo(ausgabe)
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
})
})
}
export type RipErgebnis =
| { status: 'success'; dateien: string[]; lesefehler: boolean }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; lesefehler: boolean }
export interface RipLaufOptionen {
programm: string
geraetepfad: string
zielOrdner: string
/** 'all' oder eine Titel-Nummer als Text. */
titel: string
aufFortschritt?: (prozent: number) => void
aufMeldung?: (code: number, text: string) => void
abbruch?: AbortSignal
}
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf das Ergebnis trägt
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = spawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
)
const auswertung = new RipAuswertung()
const leser = new ZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.meldung !== undefined) {
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
}
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
let dateien: string[] = []
try {
dateien = readdirSync(optionen.zielOrdner)
.filter((name) => name.endsWith('.mkv'))
.sort()
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
return
}
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
return
}
fertig({
status: 'error',
fehler: auswertung.fehlerText(code, dateien.length > 0),
lesefehler: auswertung.lesefehler,
})
})
})
}
+279
View File
@@ -0,0 +1,279 @@
// makemkvcon-Ausgabe lesen — reine Textverarbeitung, vollständig testbar
// ohne Laufwerk (KONZEPT § 6.2). Format laut
// https://www.makemkv.com/developers/usage.txt (Robot-Mode -r); jede
// Eigenheit hier ist im Docker-Zweig an echten Discs GEMESSEN worden
// (ripping.py / makemkv_aufruf.py) und wandert mit ihren Testfällen mit.
// ── Quelle: Wie MakeMKV DIESES Laufwerk nennt ───────────────────────────
// Der 29.08.-Befund: `dev:\\.\G:` → MSG 2024 „Unknown device";
// `dev:G:` → 68 TINFO-Zeilen. CreateFileW braucht den Geräte-Namensraum,
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string {
let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
if (pfad.startsWith(vorsatz)) {
pfad = pfad.slice(vorsatz.length)
break
}
}
// „G:\" → „G:" — ohne Trenner ist es das, was MakeMKV selbst ausgibt.
if (pfad.length === 3 && pfad[1] === ':' && (pfad[2] === '\\' || pfad[2] === '/')) {
pfad = pfad.slice(0, 2)
}
return `dev:${pfad}`
}
// ── Kodierung: BINÄR lesen, dann bestimmen statt behaupten ──────────────
// makemkvcon mischt Kodierungen je nach Konsole (UTF-8 oder cp1252): Beim
// Commander wurde aus „Öffnen" ein „Öffnen". UTF-8 STRENG versuchen,
// sonst cp1252 — mit errors=replace ginge jeder Umlaut still unter.
export function textVon(roh: Buffer | string): string {
if (typeof roh === 'string') return roh
if (roh.length === 0) return ''
try {
return new TextDecoder('utf-8', { fatal: true }).decode(roh)
} catch {
// cp1252 kann jedes Byte abbilden — hier kann nichts mehr scheitern.
return new TextDecoder('windows-1252').decode(roh)
}
}
// ── MSG-Zeilen ──────────────────────────────────────────────────────────
// MSG:code,flags,count,"message",… — Feld 4 ist der fertige Klartext.
// Ein naives split(",") schnitt jede Meldung mit Komma ab (25.07.2026) —
// und MakeMKV schreibt solche laufend.
const MSG_ZEILE = /^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"/
export function parseMsg(zeile: string): { code: number; text: string } | null {
const treffer = MSG_ZEILE.exec(zeile.trim())
if (treffer === null) return null
return { code: Number(treffer[1]), text: treffer[2].replaceAll('\\"', '"') }
}
// ── PRGV: Gesamtfortschritt ─────────────────────────────────────────────
// PRGV:current,total,max — total/max ist der Gesamtfortschritt.
// -1 heißt „keine Angabe": Ein echtes 0 % muss von „nichts" unterscheidbar
// bleiben, sonst friert der Anfang jedes Rips optisch ein.
export function progressAusPrgv(zeile: string): number {
const treffer = /^PRGV:(\d+),(\d+),(\d+)/.exec(zeile.trim())
if (treffer === null) return -1
const gesamt = Number(treffer[2])
const maximum = Number(treffer[3])
if (maximum <= 0) return -1
return Math.min(100, Math.floor((gesamt * 100) / maximum))
}
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
// ("H:MM:SS"), 11 = DiskSizeBytes.
export interface TitelInfo {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
}
const TINFO_ZEILE = /^TINFO:(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseTitelInfo(ausgabe: string): TitelInfo[] {
const titel = new Map<number, TitelInfo>()
for (const roh of ausgabe.split(/\r?\n/)) {
const treffer = TINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const nr = Number(treffer[1])
const attr = Number(treffer[2])
const wert = treffer[3]
let eintrag = titel.get(nr)
if (eintrag === undefined) {
eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 }
titel.set(nr, eintrag)
}
if (attr === 9) {
const teile = wert.split(':')
if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) {
eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2])
}
} else if (attr === 11 && /^\d+$/.test(wert)) {
eintrag.groesseBytes = Number(wert)
} else if (attr === 8 && /^\d+$/.test(wert)) {
eintrag.kapitel = Number(wert)
}
}
return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!)
}
// ── SINFO: Tonspuren und Untertitel ─────────────────────────────────────
// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen:
// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname,
// 6 = Codec, 14 = Kanäle, 30 = Beschreibung.
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
// jede Disc für englisch.
export interface StreamInfo {
nr: number
typ: string
lang: string
sprache: string
codec: string
kanaele: string
beschreibung: string
}
export interface TitelStreams {
audio: StreamInfo[]
untertitel: StreamInfo[]
}
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const titel = new Map<number, Map<number, StreamInfo>>()
for (const roh of (ausgabe ?? '').split(/\r?\n/)) {
const treffer = SINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const tNr = Number(treffer[1])
const sNr = Number(treffer[2])
const attr = Number(treffer[3])
const wert = treffer[4]
let streams = titel.get(tNr)
if (streams === undefined) {
streams = new Map()
titel.set(tNr, streams)
}
let eintrag = streams.get(sNr)
if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
streams.set(sNr, eintrag)
}
if (attr === 1) eintrag.typ = wert
else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert
else if (attr === 30) eintrag.beschreibung = wert
}
const ergebnis = new Map<number, TitelStreams>()
for (const [tNr, streams] of titel) {
const audio: StreamInfo[] = []
const untertitel: StreamInfo[] = []
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
const s = streams.get(sNr)!
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
if (s.typ === 'Audio') audio.push(s)
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
}
ergebnis.set(tNr, { audio, untertitel })
}
return ergebnis
}
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => {
const gefunden = new Map<string, SprachEintrag>()
for (const streams of streamsJeTitel.values()) {
for (const s of streams[schluessel]) {
if (s.lang.length === 0) continue
let eintrag = gefunden.get(s.lang)
if (eintrag === undefined) {
eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 }
gefunden.set(s.lang, eintrag)
}
eintrag.spuren += 1
}
}
return [...gefunden.values()]
}
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) das
* vermeidet Play All"-Titel, die länger als der Film sind. */
export function laengsterTitel(dauern: Map<number, number>, sollMinuten = 0): number | null {
if (dauern.size === 0) return null
const zielS = sollMinuten * 60
if (zielS > 0) {
let bester: number | null = null
let abstand = Number.POSITIVE_INFINITY
for (const [nr, dauer] of dauern) {
const d = Math.abs(dauer - zielS)
if (d < abstand) {
abstand = d
bester = nr
}
}
if (bester !== null && abstand <= 20 * 60) return bester
}
let laengster: number | null = null
let max = -1
for (const [nr, dauer] of dauern) {
if (dauer > max) {
max = dauer
laengster = nr
}
}
return laengster
}
/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der
* bekannten Episoden-Laufzeiten (Minuten) liegt. */
export function episodenTitel(dauern: Map<number, number>, episodenMinuten: number[]): number[] {
if (dauern.size === 0 || episodenMinuten.length === 0) return []
const laufzeitenS = episodenMinuten.map((m) => m * 60)
const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length
const gefunden: number[] = []
for (const [nr, dauer] of dauern) {
if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr)
}
return gefunden.sort((a, b) => a - b)
}
// ── Fehler-Erkennung ────────────────────────────────────────────────────
// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht
// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
export const KRITISCHE_CODES: Record<number, string> = {
2024: 'MakeMKV kennt dieses Laufwerk nicht',
5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus',
5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen',
5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren',
// Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt
// gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere
// DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2,
// GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt,
// und diese Ebene blieb hängen.
5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten',
5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)',
}
/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */
export const KEY_ABGELEHNT = [5020, 5021]
// Text-Rückfall für englische Installationen — die NUMMERN sind die
// verlässliche Quelle (beim Commander meldet MakeMKV deutsch).
export const KRITISCHE_TEXTE = [
'volume key is unknown',
'evaluation period has expired',
'registration key',
'Too old version',
]
+135
View File
@@ -0,0 +1,135 @@
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
// einer Konfigurationsdatei, die niemand schrieb.
//
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
export const SCHEMA_VERSION = 3
export class Datenbank {
readonly pfad: string
private db: DatabaseSync
/**
* Öffnet (oder erzeugt) die Datenbank und legt das Schema an.
* Wirft bei nicht anlegbarem Pfad der Aufrufer meldet den Fehler
* LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten.
*/
constructor(pfad: string) {
this.pfad = pfad
if (pfad !== ':memory:') {
mkdirSync(dirname(pfad), { recursive: true })
}
this.db = new DatabaseSync(pfad)
this.db.exec(
`CREATE TABLE IF NOT EXISTS meta (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bibliothek (
fingerprint TEXT PRIMARY KEY,
titel TEXT NOT NULL,
jahr INTEGER,
typ TEXT NOT NULL,
groesse_bytes INTEGER NOT NULL,
ablageort TEXT NOT NULL,
datum TEXT NOT NULL,
dauer_s INTEGER NOT NULL,
poster_pfad TEXT NOT NULL DEFAULT ''
);`,
)
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
// Poster-Spalte — nachrüsten, ohne Einträge anzufassen.
const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }>
if (!spalten.some((s) => s.name === 'poster_pfad')) {
this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`)
}
this.db
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
.run(String(SCHEMA_VERSION))
}
/** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */
einstellungLesen(schluessel: string): string | null {
const zeile = this.db
.prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`)
.get(schluessel) as { wert: string } | undefined
return zeile === undefined ? null : zeile.wert
}
einstellungSchreiben(schluessel: string, wert: string): void {
this.db
.prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`)
.run(schluessel, wert)
}
schemaVersion(): number {
const zeile = this.db
.prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`)
.get() as { wert: string } | undefined
return zeile === undefined ? 0 : Number(zeile.wert)
}
// ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am
// Disc-Fingerabdruck ────────────────────────────────────────────────
bibliothekEintragen(eintrag: BibliothekEintrag): void {
this.db
.prepare(
`INSERT OR REPLACE INTO bibliothek
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
eintrag.fingerprint,
eintrag.titel,
eintrag.jahr,
eintrag.typ,
eintrag.groesseBytes,
eintrag.ablageort,
eintrag.datum,
eintrag.dauerS,
eintrag.posterPfad,
)
}
bibliothekListe(): BibliothekEintrag[] {
const zeilen = this.db
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
.all() as Array<Record<string, unknown>>
return zeilen.map((z) => ({
fingerprint: String(z.fingerprint),
titel: String(z.titel),
jahr: z.jahr === null ? null : Number(z.jahr),
typ: String(z.typ),
groesseBytes: Number(z.groesse_bytes),
ablageort: String(z.ablageort),
datum: String(z.datum),
dauerS: Number(z.dauer_s),
posterPfad: String(z.poster_pfad ?? ''),
}))
}
/** Datum des früheren Rips dieser Disc — oder null (neu). */
bibliothekSchonGerippt(fingerprint: string): string | null {
const zeile = this.db
.prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`)
.get(fingerprint) as { datum: string } | undefined
return zeile === undefined ? null : zeile.datum
}
schliessen(): void {
this.db.close()
}
}
@@ -0,0 +1,330 @@
// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage
// tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am
// 31.08.2026 nachgemessen):
//
// Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht)
// Hersteller-Forum HTTP 200, 0,2 s → 1.18.4
// Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2
//
// Daraus folgen die drei Regeln dieser Datei:
// 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich.
// Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die
// HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus,
// würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste
// Fassung" verkauft.
// 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht
// (Get-AuthenticodeSignature → NotSigned, an der echten Datei
// gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft
// inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026
// an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!).
// Das ersetzt keine Signatur, fängt aber ab, was wirklich droht:
// Fehlerseite, halber Download, falsche Version.
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
// `.neu`, geprüft, erst dann getauscht.
import { spawnSync } from 'node:child_process'
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { win32 as pfad } from 'node:path'
import { werkzeugOrdner } from './katalog'
export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/'
export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3'
// Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das
// Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py).
const ARCHIV_STAND = '2025'
const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe'
const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g
// Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und
// begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine
// Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin.
const VERSIONS_TIMEOUT_MS = 8_000
const DOWNLOAD_TIMEOUT_MS = 600_000
const VERSUCHE = 2
const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000
// Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern
// eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde.
const MINDESTGROESSE_BYTES = 1024 * 1024
const MAKEMKV_FIRMA = 'GuinpinSoft'
const MAKEMKV_PRODUKT = 'MakeMKV'
export class BeschaffungsFehler extends Error {}
/** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH
* verglichen als Zeichenkette wäre 1.9.2" größer als „1.18.4". */
export function hoechsteVersion(text: string): string {
const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1])
if (funde.length === 0) return ''
return funde.reduce((a, b) => (istNeuer(b, a) ? b : a))
}
/** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */
export function istNeuer(neu: string, alt: string): boolean {
const teile = (v: string): number[] =>
(v ?? '')
.split('.')
.filter((t) => /^\d+$/.test(t))
.map(Number)
const a = teile(neu)
const b = teile(alt)
if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0
const y = b[i] ?? 0
if (x !== y) return x > y
}
return false
}
export interface VersionsQuelle {
name: string
url: string
/** Diese Quelle IST die Wahrheit wer sie erreicht, sucht nicht weiter.
* Gilt nur für den Hersteller selbst. */
massgeblich: boolean
}
export function versionsquellen(): VersionsQuelle[] {
return [
{ name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true },
{ name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false },
{
name: 'Archiv der Hersteller-Seite',
url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`,
massgeblich: false,
},
]
}
async function seiteLaden(url: string, holen: typeof fetch): Promise<string> {
let letzter: unknown = null
for (let versuch = 0; versuch < VERSUCHE; versuch++) {
try {
const antwort = await holen(url, {
signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
})
if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`)
return await antwort.text()
} catch (fehler) {
letzter = fehler
if (versuch + 1 < VERSUCHE) {
await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS))
}
}
}
throw letzter
}
/** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */
export async function makemkvNeuesteVersion(
holen: typeof fetch = fetch,
quellen: VersionsQuelle[] = versionsquellen(),
): Promise<string> {
const gefunden: string[] = []
for (const quelle of quellen) {
let text: string
try {
text = await seiteLaden(quelle.url, holen)
} catch {
continue
}
const version = hoechsteVersion(text)
if (version.length === 0) continue
if (quelle.massgeblich) return version
gefunden.push(version)
}
if (gefunden.length === 0) return ''
return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a))
}
/** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis
* (wer sie einträgt, hat sich etwas gedacht) Hersteller Archiv. */
export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> {
const herstellerUrl = MAKEMKV_DATEI.replace('%s', version)
const quellen: Array<[string, string]> = []
if (basis.trim().length > 0) {
quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`])
}
quellen.push(['Hersteller', herstellerUrl])
// `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen).
quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`])
return quellen
}
export interface Versionsangaben {
firma: string
produkt: string
version: string
}
/** Die Versions-Ressource einer EXE über PowerShells FileVersionInfo,
* damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */
export function versionsangaben(dateiPfad: string): Versionsangaben {
const leer = { firma: '', produkt: '', version: '' }
if (process.platform !== 'win32') return leer
// ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein
// wörtliches '…'-Literal nicht.
const skript =
`$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` +
'@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress'
const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], {
windowsHide: true,
timeout: 20_000,
encoding: 'utf8',
})
if (lauf.status !== 0) return leer
try {
const roh: unknown = JSON.parse(lauf.stdout ?? '')
if (typeof roh !== 'object' || roh === null) return leer
const angaben = roh as Record<string, unknown>
return {
firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '',
produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '',
version: typeof angaben['version'] === 'string' ? angaben['version'] : '',
}
} catch {
return leer
}
}
/** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */
export function dateiPruefen(
dateiPfad: string,
version: string,
angaben: (p: string) => Versionsangaben = versionsangaben,
plattform: NodeJS.Platform = process.platform,
): { ok: boolean; grund: string } {
// Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte
// Fehlerseite tut das nicht.
const kopf = Buffer.alloc(2)
try {
const datei = openSync(dateiPfad, 'r')
try {
readSync(datei, kopf, 0, 2, 0)
} finally {
closeSync(datei)
}
} catch (fehler) {
return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` }
}
if (kopf.toString('latin1') !== 'MZ') {
return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' }
}
if (plattform !== 'win32') {
// Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen,
// mehr ist dort nicht zu holen (CI läuft auf Linux).
return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' }
}
const info = angaben(dateiPfad)
if (!info.firma.includes(MAKEMKV_FIRMA)) {
return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` }
}
if (!info.produkt.includes(MAKEMKV_PRODUKT)) {
return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` }
}
if (version.length > 0 && !info.version.includes(version)) {
return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` }
}
return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` }
}
async function dateiLaden(
url: string,
ziel: string,
melden: (text: string, anteil: number | null) => void,
holen: typeof fetch,
): Promise<void> {
const antwort = await holen(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
})
if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`)
if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt')
const erwartet = Number(antwort.headers.get('content-length') ?? '0')
const schreiber = createWriteStream(ziel)
const leser = antwort.body.getReader()
let geladen = 0
let letzteStufe = -1
try {
for (;;) {
const { done, value } = await leser.read()
if (done) break
geladen += value.byteLength
await new Promise<void>((weiter, kaputt) => {
schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter()))
})
if (erwartet > 0) {
const anteil = Math.min(1, geladen / erwartet)
// Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen
// wären sonst hundert Zeilen.
const stufe = Math.floor(anteil * 10)
if (stufe !== letzteStufe) {
letzteStufe = stufe
melden(
`${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`,
anteil * 0.85,
)
}
}
}
} finally {
await new Promise<void>((weiter) => schreiber.end(weiter))
}
const groesse = statSync(ziel).size
if (groesse < MINDESTGROESSE_BYTES) {
throw new BeschaffungsFehler(
`Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`,
)
}
}
export interface BeschaffungErgebnis {
pfad: string
quelle: string
version: string
}
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
* Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** es lädt die
* offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */
export async function makemkvInstallerHolen(
version: string,
melden: (text: string, anteil: number | null) => void,
basis = '',
holen: typeof fetch = fetch,
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
pruefen: typeof dateiPruefen = dateiPruefen,
): Promise<BeschaffungErgebnis> {
mkdirSync(ordner, { recursive: true })
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
const vorlaeufig = `${ziel}.neu`
const fehlschlaege: string[] = []
for (const [name, url] of makemkvQuellen(version, basis)) {
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
try {
await dateiLaden(url, vorlaeufig, melden, holen)
const pruefung = pruefen(vorlaeufig, version)
if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund)
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
// Datei nicht ersetzen.
renameSync(vorlaeufig, ziel)
return { pfad: ziel, quelle: name, version }
} catch (fehler) {
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
melden(`${name} hat nicht geliefert.`, null)
try {
rmSync(vorlaeufig, { force: true })
} catch {
// Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin.
}
}
}
throw new BeschaffungsFehler(
`Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
)
}
+203
View File
@@ -0,0 +1,203 @@
// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9).
// Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am
// Commander-PC gemessen:
//
// 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den.
// 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) —
// VOR dem System, damit eine gepflegte Fassung eine alte Installation
// schlägt.
// 3. PATH — der „selbst eingetragen"-Fall.
// 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall.
// 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER
// (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den
// Programmordner.
//
// ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern
// PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte
// Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen
// (katalog.py, 28.08.2026).
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
// Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade,
// und die CI-Tests laufen auf Linux — dort würde das plattformabhängige
// join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226).
import { win32 as pfad } from 'node:path'
const { delimiter, join } = pfad
export interface Werkzeug {
titel: string
zweck: string
programme: readonly string[]
orte: readonly string[]
/** Teilstring des DisplayName im Uninstall-Zweig der Registry. */
anzeige: string
pflicht: boolean
}
export const WERKZEUGE: Record<string, Werkzeug> = {
makemkv: {
titel: 'MakeMKV',
zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.',
programme: ['makemkvcon64.exe', 'makemkvcon.exe'],
orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'],
anzeige: 'MakeMKV',
pflicht: true,
},
handbrake: {
titel: 'HandBrakeCLI',
zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.',
programme: ['HandBrakeCLI.exe'],
orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'],
anzeige: 'HandBrake',
pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression
},
flac: {
titel: 'FLAC',
zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.',
programme: ['flac.exe'],
orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'],
anzeige: 'FLAC',
pflicht: false, // ohne FLAC läuft alles außer Audio-CDs
},
}
/** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */
export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string {
const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.'
return join(wurzel, 'Rippy', 'tools')
}
/** %ProgramFiles%\X C:\Program Files\X Groß/Klein wird IGNORIERT.
* Bleibt ein % stehen, ist der Kandidat unbrauchbar ''. */
export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string {
let ergebnis = pfad
for (const [name, wert] of Object.entries(umgebung)) {
if (wert === undefined) continue
const marke = `%${name}%`.toUpperCase()
for (;;) {
const stelle = ergebnis.toUpperCase().indexOf(marke)
if (stelle < 0) break
ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length)
}
}
return ergebnis.includes('%') ? '' : ergebnis
}
/** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */
export function kandidaten(
name: string,
eigenerOrdner: string,
umgebung: NodeJS.ProcessEnv,
): string[] {
const werkzeug = WERKZEUGE[name]
const pfade: string[] = []
// Das MITGELIEFERTE Werkzeug (§ 9) — im installierten Rippy zeigt
// RIPPY_VENDOR auf resources\vendor (der Haupt setzt es beim Kern-Start).
const vendor = umgebung['RIPPY_VENDOR'] ?? ''
if (vendor.length > 0) {
for (const programm of werkzeug.programme) {
pfade.push(join(vendor, name, programm))
}
}
for (const programm of werkzeug.programme) {
pfade.push(join(eigenerOrdner, name, programm))
pfade.push(join(eigenerOrdner, programm))
}
for (const ort of werkzeug.orte) {
const entfaltet = entfalten(ort, umgebung)
if (entfaltet.length === 0) continue
for (const programm of werkzeug.programme) {
pfade.push(join(entfaltet, programm))
}
}
return pfade
}
function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string {
const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? ''
for (const ordner of pfadVar.split(delimiter)) {
if (ordner.length === 0) continue
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
return ''
}
/** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist
* InstallLocation leer DisplayIcon zeigt auf eine Datei im
* Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit
* kein zweiter koffi-Ort entsteht (R1). */
export function ausUninstallRegistry(
anzeige: string,
programme: readonly string[],
ausfuehren: (wurzel: string) => string = regQuery,
): string {
const wurzeln = [
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
]
for (const wurzel of wurzeln) {
const ausgabe = ausfuehren(wurzel)
// reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT.
let passt = false
for (const zeile of ausgabe.split(/\r?\n/)) {
if (zeile.startsWith('HK')) {
passt = false
continue
}
const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile)
if (wert === null) continue
if (wert[1] === 'DisplayName') {
passt = wert[2].includes(anzeige)
} else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) {
const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '')
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
}
}
return ''
}
function regQuery(wurzel: string): string {
const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], {
encoding: 'utf8',
windowsHide: true,
timeout: 15_000,
maxBuffer: 32 * 1024 * 1024,
})
return ergebnis.stdout ?? ''
}
/** Der Pfad zum Werkzeug oder '' wenn es nicht da ist. Prüf-Funktionen
* einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */
export function finden(
name: string,
eingestellt = '',
eigenerOrdner: string = werkzeugOrdner(),
umgebung: NodeJS.ProcessEnv = process.env,
existiert: (pfad: string) => boolean = existsSync,
registry: typeof ausUninstallRegistry = ausUninstallRegistry,
): string {
// 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird
// NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will
// genau den, und ein Tippfehler soll als Fehler sichtbar werden.
if (eingestellt.trim().length > 0) {
return existiert(eingestellt.trim()) ? eingestellt.trim() : ''
}
// 2 + 4. Eigener Ordner, dann bekannte Orte.
for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) {
if (existiert(kandidat)) return kandidat
}
// 3. PATH.
const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung)
if (imPfad.length > 0) return imPfad
// 5. Registry-Rückfall.
return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme)
}
@@ -0,0 +1,238 @@
// Die MakeMKV-Schlüsselkette (KONZEPT § 9.1) — Vorlagen makemkv_key.py
// (Forum-Abruf) und makemkv_aufruf.py (Registry-Ablage + Prüfung, beides
// am Commander-PC gemessen):
//
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
import { spawn, spawnSync } from 'node:child_process'
import { textVon } from '../rip/parser'
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
const KEY_NAME = 'app_Key'
/** Den Beta-Key aus dem Forum-HTML ziehen (pur). */
export function extractKey(html: string): string {
const treffer = KEY_MUSTER.exec(html ?? '')
return treffer === null ? '' : treffer[0]
}
/** valid until/through <Datum>" aus dem Thread best effort, Rohtext.
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
export function extractGueltigBis(html: string): string {
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
html ?? '',
)
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
}
export interface ForumKey {
key: string
gueltigBis: string
}
/** Der aktuelle Beta-Key aus dem Forum 8-s-Zeitgrenze je Versuch: Das
* Forum fiel bei der Messung in zwei von drei Abrufen aus (§ 9). */
export async function betaKeyHolen(holen: typeof fetch = fetch): Promise<ForumKey | null> {
for (let versuch = 0; versuch < 3; versuch++) {
try {
const antwort = await holen(FORUM_URL, {
signal: AbortSignal.timeout(8_000),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' },
})
if (antwort.status >= 300) continue
const html = await antwort.text()
const key = extractKey(html)
if (key.length > 0) return { key, gueltigBis: extractGueltigBis(html) }
} catch {
// nächster Versuch — der Fehlschlag wird am Ende als null sichtbar
}
}
return null
}
// ── Registry-Ablage über reg.exe (kein zweiter koffi-Ort, R1) ───────────
// In dieser Registry steckt die Arbeit des Commanders (Laufwerksprofile,
// Fensterlage). Geschrieben wird GENAU app_Key und app_UpdateEnable —
// gelöscht wird nur app_Key, und nur nach Ablehnung.
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
}
export function keyAblegen(key: string): boolean {
if (key.trim().length === 0) return false
return reg(['add', REGISTRY_PFAD, '/v', KEY_NAME, '/t', 'REG_SZ', '/d', key.trim(), '/f']).ok
}
export function keyZuruecknehmen(): boolean {
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
}
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt wer den Web-Kontakt
* bewusst abgeschaltet hat, behält seine Entscheidung. */
export function updateKanalSicherstellen(): boolean {
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', 'app_UpdateEnable'])
if (vorhanden.ok) return false
return reg(['add', REGISTRY_PFAD, '/v', 'app_UpdateEnable', '/t', 'REG_DWORD', '/d', '1', '/f']).ok
}
export interface KeyUrteil {
ok: boolean
code: number | null
grund: string
/** Die MakeMKV-Version aus MSG 1005 (MakeMKV v1.18.4 ") '' wenn
* nicht erkennbar. */
version: string
}
/** Nimmt MakeMKV den hinterlegten Schlüssel an? es selbst gefragt:
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
*
* ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
* 5021 zusammen (30.08.2026 live gemessen) 5020 allein klingt nach
* Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
* den falschen. */
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
return
}
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
})
})
}
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
const codes: number[] = []
let version = ''
for (const zeile of ausgabe.split(/\r?\n/)) {
if (!zeile.startsWith('MSG:')) continue
const code = Number(zeile.slice(4).split(',', 1)[0])
if (!Number.isInteger(code)) continue
if (code === 1005) {
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
if (treffer !== null) version = treffer[1]
}
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
}
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
const code = codes.includes(5021) ? 5021 : codes[0]
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
}
export interface SchluesselStand {
/** 'dauerlizenz' | 'forum' | 'keiner' */
quelle: string
angenommen: boolean | null
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja das ist eine
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — fürs Update-Urteil. */
version: string
}
/** Die ganze Kette: Dauerlizenz Forum-Key ablegen PRÜFEN
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
export async function schluesselKette(
makemkvcon: string,
dauerlizenz: string,
jetztIso: string,
holen: typeof fetch = fetch,
): Promise<SchluesselStand> {
updateKanalSicherstellen()
if (dauerlizenz.trim().length > 0) {
keyAblegen(dauerlizenz)
const urteil = await keyPruefen(makemkvcon)
return {
quelle: 'dauerlizenz',
angenommen: urteil.ok,
weiterBetrieb: urteil.ok,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
version: urteil.version,
}
}
const forum = await betaKeyHolen(holen)
if (forum === null) {
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
// wird geprüft, damit der Stand ehrlich ist.
const urteil = await keyPruefen(makemkvcon)
return {
quelle: 'keiner',
angenommen: urteil.ok,
weiterBetrieb: true,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
version: urteil.version,
}
}
keyAblegen(forum.key)
const urteil = await keyPruefen(makemkvcon)
if (!urteil.ok) {
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
keyZuruecknehmen()
}
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
return {
quelle: 'forum',
angenommen: urteil.ok,
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
weiterBetrieb: true,
gueltigBis: forum.gueltigBis,
geholtAm: jetztIso,
text: urteil.ok
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
: urteil.code === 5021
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
version: urteil.version,
}
}
+174
View File
@@ -0,0 +1,174 @@
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { baueNfo } from '../src/kern/ablage/nfo'
import {
episodenUmbenennen,
matcheEpisoden,
serienOrdner,
sichererName,
zielordner,
} from '../src/kern/ablage/struktur'
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
describe('sichererName', () => {
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
'Der Herr der Ringe Die Gefährten (2001)',
)
})
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
})
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
expect(sichererName('...')).toBe('')
expect(sichererName('Film.', null)).toBe('Film')
})
})
describe('zielordner', () => {
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
// Erwartungen über join() — die Funktionen arbeiten mit den
// Pfadregeln der LAUFZEIT-Plattform (Linux-CI testet mit POSIX).
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
join('Filme', 'Akira (1988)'),
)
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
join('Filme', 'Akira (1988) [job-1234]'),
)
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
})
})
describe('serienOrdner', () => {
it('Jellyfin-Schema Serie/Season NN', () => {
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
})
})
describe('matcheEpisoden', () => {
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
expect(
matcheEpisoden(
[1420, 1425, 1418],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toEqual([1, 2, 3])
})
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
expect(
matcheEpisoden(
[1200, 2400],
[
[1, 1210],
[2, 2390],
[3, 3000],
],
),
).toEqual([1, 2])
})
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
expect(
matcheEpisoden(
[1420, 1421],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toBeNull()
})
it('keine Daten heißt null', () => {
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
expect(matcheEpisoden([100], [])).toBeNull()
})
})
describe('episodenUmbenennen', () => {
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
const umbenannt: Array<[string, string]> = []
const meldungen = episodenUmbenennen('S', 'Akte X', 1, [3, 4], {
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
existiert: () => false,
umbenennen: (von, nach) => umbenannt.push([von, nach]),
})
expect(umbenannt).toEqual([
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
])
expect(meldungen).toHaveLength(2)
})
it('Zahl passt nicht → gar nichts anfassen', () => {
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
listen: () => ['a.mkv', 'b.mkv'],
existiert: () => false,
umbenennen: () => {
throw new Error('darf nicht')
},
})
expect(meldungen[0]).toContain('übersprungen')
})
})
describe('baueNfo', () => {
it('movie.nfo mit XML-Escaping', () => {
const nfo = baueNfo(
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
'Akira <Director\'s Cut>',
1988,
)
expect(nfo).toContain('<movie>')
expect(nfo).toContain('<title>Akira &lt;Director\'s Cut&gt;</title>')
expect(nfo).toContain('<plot>Roboter &amp; Menschen</plot>')
expect(nfo).toContain('<year>1988</year>')
expect(nfo).toContain('<runtime>124</runtime>')
expect(nfo).toContain('Source: Rippy')
})
it('Serie bekommt tvshow als Wurzel', () => {
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
})
})
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
// HandBrake gemessen (25./26.07.2026).
describe('Encoder- und Preset-Listen', () => {
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
const hilfe = [
' -e, --encoder <string> Select video encoder:',
' svt_av1',
' x264',
' x265',
' nvenc_h264',
' --encoder-preset <string>',
' ignored',
].join('\n')
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
})
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
'cpu-x264',
'cpu-x265',
'cpu-av1',
'vce',
'vce-av1',
])
expect(leiteBackendsAb([])).toEqual([])
})
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
const liste = [
'General/',
' Very Fast 2160p60 4K AV1',
' AV1 video (up to 2160p60) and AAC stereo audio.',
'Hardware/',
' H.265 VCN 2160p 4K',
'[10:26:05] qsv: not available on this system',
].join('\n')
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
})
})

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