101 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
HitonabiandClaude Opus 5 6881a63fb7 docs(savepoint): v4.0-rc4 — Cover, Arbeitsverzeichnis, aufgeraeumte Temp-Ordner
Ampel / ampel (push) Successful in 55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:45:52 +02:00
HitonabiandClaude Opus 5 35370fd552 fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner
Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.

## 1. „Was ist mit dem Cover auf Windows Rippy?"

Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:

    if movie.get("title", "").lower() == kandidat.lower():

An seiner Disc gemessen:

    'Evangelion 2.22'    1 Treffer   Evangelion: 2.0 You Can (Not) Advance
    'Evangelion'        20 Treffer   irgendein Evangelion

Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster — und wurde verworfen. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:

    Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text

## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
##     Arbeitsverzeichnis zu aendern? momentan geht das nicht."

Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:

* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
  hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.

Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.

## 3. „Failed to remove temporary directory: …_MEI0000b0882"

GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.

Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.

794 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:43:41 +02:00
HitonabiandClaude Opus 5 11001a443e docs: SAVEPOINT v4.0-rc3 — Windows testbereit
Ampel / ampel (push) Successful in 55s
Stand vor dem Test des Commanders. Zwei Dinge stehen bewusst gross drin:
die seit V2-1 tote Metadaten-Suche (betrifft die VM genauso) und die
bekannte Luecke bei Audio-CDs unter Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:09:24 +02:00
HitonabiandClaude Opus 5 be3fac53af fix(prescan): makemkvcon ueber den Katalog suchen, nicht ueber shutil.which
Ampel / ampel (push) Successful in 55s
Auf die Frage des Commanders: "du hast jetzt nur mein laufwerk analysiert
oder? wenn ich rippy weitergebe wie laeuft es dort?"

Berechtigt -- und beim Nachsehen fiel derselbe Fehler auf wie heute Vormittag
im Werkzeug-Katalog:

    shutil.which("makemkvcon")   ->  None, auch bei installiertem MakeMKV

`which` sucht nur im PATH. Windows-Programme liegen in "Programme". Der
Zweig, der die Titelliste von makemkvcon holt, lief unter Windows also NIE --
unabhaengig vom Laufwerk, auf jedem Rechner. Jetzt ueber
`katalog.finden("makemkv")`, das genau dafuer da ist.

Dazu vier Tests fuer Disc-Arten, die ICH NICHT HABE, damit die Zusage nicht
an einer einzigen Disc haengt:

    Blu-ray ohne BDMV/META   -> None (sehr haeufig; Rueckfall aufs Label)
    DVD (nur VIDEO_TS)       -> None
    bdmt_deu.xml vorhanden   -> Titel wird gelesen
    deu + eng vorhanden      -> eng gewinnt (APIs suchen damit besser)

Und ein Waechter gegen shutil.which in prescan.py. Der erste Anlauf davon
fiel ueber den eigenen Erklaertext, in dem der alte Aufruf zitiert wird --
er prueft jetzt nur Code, keine Kommentare.

Ampel lokal: 774 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:05:25 +02:00
HitonabiandClaude Opus 5 878b24c43b fix: die Metadaten-Suche war seit V2-1 tot — auf Windows UND auf der VM
Ampel / ampel (push) Successful in 54s
Der Commander: "TMDB und OMDB Key sind hinterlegt, das laufwerk wird aber
auch nicht korrekt ausgelesen. Eigentlich sollte er direkt bei TMDB oder OMDB
oder JIKAN anfragen nach metadaten."

Er hat das Symptom gemeldet. Darunter lagen VIER Fehler.

## 1. Ein Phantom-Modul (der schwerste)

    clients/tmdb.py:27     from db import get_settings
    clients/omdb.py:36     from db import get_settings
    clients/thetvdb.py:16  from db import get_settings

`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr; main.py schreibt seitdem `from rippy import store as db`. Diese drei
Module haben es nie mitbekommen.

Damit starb JEDE Metadaten-Abfrage schon beim Erzeugen des Clients mit
ModuleNotFoundError -- und `_auto_prescan` verschluckte das in seinem
`except Exception`. Die Disc blieb namenlos, und niemand sah warum.

**Das betrifft die VM genauso.** Sie steht auf 05ab655, also NACH dd1d0b7 --
dort laeuft seit V2-1 dieselbe tote Metadaten-Suche.

## 2. Redis war Pflicht statt Beschleunigung

`cache/cache.py` sprach `redis:6379` an -- den Dienstnamen aus
docker-compose.yml. Auf einem Windows-PC gibt es kein Redis:

    redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379

Das riss den Pre-Scan mit. Ein Zwischenspeicher ist eine Beschleunigung,
keine Voraussetzung: Jetzt faengt jeder Zugriff den Verbindungsfehler ab und
meldet "nicht vorhanden" -- was der Wahrheit entspricht. Gemeldet wird der
Ausfall EINMAL, nicht bei jeder Anfrage.

## 3. Der Klartext-Titel war unter Windows nicht erreichbar

`read_disc_title_via_mount` machte fest `mount -t udf` -- also Linux. Unter
Windows haengt Windows die Disc selbst ein. An der echten Disc gemessen:

    Volume-Label (UDF)          BD_EVG_D2      <- damit findet keine API etwas
    BDMV/META/DL/bdmt_deu.xml   Evangelion 2.22

`disc_wurzel()` liefert jetzt je Plattform einen lesbaren Einstieg, und
`titel_aus_bdmt()` ist davon getrennt (und damit ohne Disc pruefbar).

## 4. Modell und Seriennummer fehlten

Im UI stand "Modell: unbekannt, Seriennummer: -". Der Linux-Treiber liest
beides aus /sys; der Windows-Treiber lieferte leere Felder. Die Entsprechung
ist IOCTL_STORAGE_QUERY_PROPERTY. An echter Hardware gemessen:

    hersteller     'HL-DT-ST'
    modell         'BD-RE BU40N'
    fassung        '1.03'
    seriennummer   '0025114C0149'

Gemerkt statt jedes Mal abgefragt: Die Disc-Wache ruft device_info alle drei
Sekunden, und die Angaben eines Laufwerks aendern sich nicht.

## Ergebnis, an der eingelegten Disc gemessen

    title        'Neon Genesis Evangelion'
    year         1995
    disc_type    'Blu-ray'
    confidence   0.8
    fingerprint  'BD_EVG_D2|48149364736'

(Jikan antwortete waehrend der Messung mit 504 -- deren Ausfall, nicht
unserer. Der Treffer kam von TMDB.)

Zwei Waechter, beide beim Zurueckdrehen rot gesehen: Die Clients muessen sich
OHNE Datenbank und OHNE Redis bauen lassen, und ein `from db import` in
clients/ faellt mechanisch auf.

Ampel lokal: 769 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:59:47 +02:00
HitonabiandClaude Opus 5 ea8a043643 feat(setup): Fortschrittsbalken, MakeMKV-Installer mit Rechteabfrage, Updates
Ampel / ampel (push) Successful in 55s
Drei Punkte des Commanders vom 28.08.2026.

## 1. "bei der installation eine progress bar waere gut"

Die Daten dafuer waren schon da -- der Fortschritts-Rueckruf traegt seit
jeher einen Anteil, die Bruecke warf ihn nur weg.

Jetzt rechnet `Fortschritt` Abschnitt + Teil-Anteil in einen Gesamtstand um.
Die Gewichte sind gemessen: Dateien ablegen 0-8 % (rund drei Sekunden),
Ablage 8-10 %, Werkzeuge 10-97 % (sechs Sekunden bis mehrere Minuten). Ein
Balken, der bei 90 % minutenlang steht, ist schlimmer als gar keiner.

Zwei Regeln, beide mit Test:

* Er laeuft NIE zurueck. Der Werkzeug-Abschnitt kann mehrere Downloads
  enthalten, jeder mit eigenem 0-bis-1 -- ohne die Regel spraenge der Balken
  bei jedem neuen Werkzeug an den Abschnittsanfang. Zusaetzlich rechnet
  `sicherstellen` den Anteil eines Werkzeugs auf die ganze Strecke um.
* Eine Textmeldung ohne Zahl bewegt ihn nicht. Sie ist keine Aussage ueber
  den Fortschritt.

Am laufenden Fenster nachgewiesen: "Werkzeuge werden geprueft -- 36 %",
Balken bernsteinfarben, am Ende gruen bei 100 %.

## 2. "make MKV laesst sich nicht installieren weil der vorgang erhoehte
##     rechte benoetigt"

Der Kern, und der Fehler lag bei uns: `subprocess.Popen` benutzt
`CreateProcess`, und das ZEIGT KEINE UAC-Abfrage -- es bricht mit
ERROR_ELEVATION_REQUIRED (740) ab. MakeMKV installiert nach Program Files und
fordert im Manifest Administratorrechte an.

`ShellExecuteW` ist der dokumentierte Weg, der die Abfrage ausloest. Damit
braucht NUR dieser eine Aufruf die Erhoehung.

Und deshalb laeuft das Setup NICHT dauerhaft erhoeht (dazu die Antwort im
Chat): Ein erhoehter Prozess sieht die eingebundenen Netzlaufwerke nicht --
auf diesem Rechner gemessen, X:, Y:, Z: ohne Erhoehung sichtbar,
EnableLinkedConnections nicht gesetzt. Genau das hatte der Commander vorher
selbst als Fehler gemeldet. Wer trotzdem erhoeht starten will, bekommt einen
Knopf -- aber nur, wenn das Schreibrecht am gewaehlten Ziel wirklich fehlt.

## 3. "wenn MakeMKV oder Handbrake schon installiert sind ein update
##     verfuegbar ist. Das kann ja direkt im setup abgehandelt werden."

`veraltete()` vergleicht nach ZAHLEN (ein Zeichenvergleich hielte 1.9.2 fuer
neuer als 1.11.2 -- und HandBrake ist genau dort). `sicherstellen` holt sie
auf Wunsch mit, aber IMMER nach den fehlenden: Ein Update ist Komfort, ein
fehlendes Pflichtwerkzeug ist ein Hindernis.

Der Assistent zeigt es als eigenen Schalter mit Klartext ("MakeMKV 1.18.2 ->
1.18.4"). Der Update-Check laeuft als GETRENNTER Aufruf nach der
Voraussetzungs-Pruefung: Er kostet Netz, und makemkv.com braucht dafuer im
schlimmsten Fall gut zwanzig Sekunden -- in der Pruefung bliebe das Fenster
so lange leer. Sind die Quellen nicht erreichbar, schaltet sich der Haken ab
und sagt warum; ein Haken, der nichts tun kann, ist eine Falle.

Ampel lokal: 758 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:38:51 +02:00
HitonabiandClaude Opus 5 95beb91364 feat(tools): Ausweichquellen fuer MakeMKV — recherchiert und gemessen
Ampel / ampel (push) Successful in 1m1s
Commander: "bitte baue fuer MakeMKV fallback seiten ein."

Anlass war ein echter Ausfall. Am 28.08.2026 gemessen:

    https://www.makemkv.com/download/   HTTP 525   (dauerhaft)
    https://makemkv.com/download/       HTTP 525
    http://www.makemkv.com/download/    HTTP 403
    https://forum.makemkv.com/          200 / 522 / Zeitablauf (wechselnd)
    web.archive.org/web/2025id_/...exe  200, 16.432.607 Bytes

525/522 heissen: Cloudflare erreicht den Ursprungsserver nicht. Eine Stoerung
beim Hersteller -- dieselbe, die im Projekt schon einmal jeden Worker-Build
lahmgelegt hat.

## Die Kette

Version:  Hersteller-Seite (massgeblich) -> Forum-Ankuendigungen -> Archiv
Datei:    eigene Quelle -> Hersteller -> Internet Archive

Der Hersteller zuerst, immer. Das Archiv ist Rueckfallebene, und Rippy nennt
hinterher die Quelle, aus der die Datei kam.

Zwei Feinheiten, beide aus Messungen statt aus dem Kopf:

* Die HOECHSTE Nummer gewinnt, nicht die erste. Antwortet das Forum nicht,
  meldet das Archiv einen aelteren Stand (1.18.2 statt 1.18.4) -- "neueste
  Fassung" waere dann eine falsche Aussage.
* Ein zweiter Anlauf, mit knapper Zeitgrenze. Drei Abrufe am Forum:
  TimeoutError, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in
  zwei von drei Faellen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste
  Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt acht
  Sekunden je Versuch, schlimmstenfalls gut zwanzig.

## Warum eine fremde Quelle trotzdem sicher ist

Der naheliegende Schutz geht NICHT: MakeMKV signiert seinen Installer nicht
(Get-AuthenticodeSignature -> NotSigned, an der echten Datei gemessen). Eine
Signaturpruefung waere eine, die immer fehlschlaegt -- schlimmer als keine,
weil sie Sicherheit vortaeuscht.

Geprueft wird die Versions-Ressource, ebenfalls gemessen:

    CompanyName      GuinpinSoft inc
    FileDescription  MakeMKV installer
    FileVersion      v1.18.4

Das ersetzt keine Signatur, faengt aber ab, was hier wirklich droht: eine
Fehlerseite mit .exe-Namen, ein abgebrochener Download, eine falsche Fassung.

Ende zu Ende nachgewiesen, ohne etwas zu installieren:

    Version laut Kette: 1.18.4
    Hersteller:         HTTP 525 -> uebersprungen
    Internet Archive:   15,7 MB in 6,1s
    Pruefung:           BESTANDEN (MakeMKV v1.18.4)

Die Grenze aus KONZEPT.md 6 bleibt: Rippy liefert MakeMKV weiterhin NICHT
mit. Es holt die Datei des Herstellers -- im Rueckfall aus einem Archiv, das
genau diese Datei aufbewahrt.

Ampel lokal: 733 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:19:46 +02:00
HitonabiandClaude Opus 5 480b658294 fix: die Laufwerks-Pruefung muss selbst einspritzbar sein
Ampel / ampel (push) Successful in 54s
Ampel-Lauf zu 16df61e rot, und diesmal am Gegenteil:

    assert 'Administratorrechte' in 'Statt des Laufwerksbuchstabens den
    UNC-Pfad eintragen (\Server\Freigabe).'

`pfade.laufwerk_von` zerlegte den Windows-Pfad jetzt richtig -- aber
`os.path.isdir("C:\")` ist auf Linux immer False, also galt JEDES Laufwerk
als fehlend und der falsche Zweig griff.

Die Frage "existiert Laufwerk Q:" laesst sich auf dem Linux-Runner
ueberhaupt nicht beantworten, dort gibt es keine Laufwerksbuchstaben. Also
gehoert sie in eine eigene, einspritzbare Funktion (`laufwerk_fehlt`) --
ohne die waere der Zweig nur auf einem Windows-Rechner mit genau diesem
fehlenden Laufwerk pruefbar, also nirgends.

Dieselbe Lehre wie bei pruefe_windows heute Vormittag: **Ein
Plattform-Vergleich mitten in einer Funktion macht jeden Einspritzpunkt
davor wertlos.**

Vor dem Push den Linux-Fall lokal nachgestellt (os.name auf "posix"
gesetzt): Program Files, Programme und ein fehlendes Q: landen jetzt alle
im richtigen Zweig.

Ampel lokal: 715 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:56:10 +02:00
HitonabiandClaude Opus 5 16df61ea3a fix: pruefe_schreibrecht nahm os.path statt pfade — zum vierten Mal dieselbe Falle
Ampel / ampel (push) Failing after 54s
Ampel-Lauf zu 3d5f82b rot:

    assert 'nicht vorhanden' in 'Q:\Rippy (Pfad nicht gefunden)'

`os.path.splitdrive` kennt auf Linux kein "Q:" und gab einen leeren String
zurueck, also fiel die Pruefung in den allgemeinen Zweig. Genau dafuer gibt
es seit einer Stunde `rippy/pfade.py` -- und ich habe es hier nicht benutzt.

Das ist das vierte Vorkommen desselben Musters an einem Tag (katalog,
verknuepfungen, betrieb, jetzt einrichtung). Die Regel steht im Kopf von
pfade.py und gilt ohne Ausnahme: **Der Pfad entscheidet, nicht der Rechner.**

Gegengeprueft, dass es keine fuenfte Stelle gibt: kein os.path.splitdrive und
kein direkter ntpath-Aufruf mehr ausserhalb von pfade.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:53:26 +02:00
HitonabiandClaude Opus 5 3d5f82b786 fix(windows): der Deinstallierer beendet jetzt auch den laufenden Dienst
Ampel / ampel (push) Failing after 54s
Beim Deinstallieren im laufenden Betrieb blieben liegen:

    rippy.db, rippy.db-shm, rippy.db-wal
    (Der Prozess kann nicht auf die Datei zugreifen, da sie von einem
     anderen Prozess verwendet wird)

Der Dienst lief weiter und hielt die Datenbank offen. Damit waere der Fehler
von vorhin durch eine andere Tuer zurueckgekommen: Die naechste Installation
haette wieder den alten Zustand samt setup.done geerbt, und der
Ersteinrichtungs-Assistent waere wieder verschwunden.

Aufgefallen ist es nur, weil der Deinstallierer seit heute NENNT, was er
nicht wegbekommen hat. Vorher stand dort ein `except OSError: pass` und die
Meldung "Rippy wurde entfernt".

Gefiltert wird ueber den Installationsordner, nicht ueber den Namen:
`Rippy.exe` heisst auch die Datei, die den Auftrag gerade ausfuehrt. Die
eigene Prozesskennung und die des PyInstaller-Starters sind ausgenommen.

Nachgewiesen am laufenden Prozess: beendet, Datenbankdateien danach
loeschbar. In der Entwicklungsumgebung schlaegt der Pfadvergleich uebrigens
immer fehl -- die Werkzeuge laufen in einem App-Container, der
%LOCALAPPDATA% umleitet, waehrend die Registry den echten Pfad meldet. Das
steht jetzt als Hinweis im Docstring, damit es niemand ein zweites Mal sucht.

Ampel lokal: 713 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:50:50 +02:00
HitonabiandClaude Opus 5 8e65411189 fix(windows): die Datenbank lag AUSSERHALB der Installation — daher fehlte
Ampel / ampel (push) Failing after 54s
der Ersteinrichtungs-Assistent

Der Befund des Commanders: "Ausserdem fehlt der 1st run wizzard wenn man es
installiert." Meine erste Reparatur (App.tsx las einen gescheiterten Abruf
als "erledigt") war richtig, aber nicht die Ursache. Nachgemessen:

    Installation      %LOCALAPPDATA%\Rippy      wird deinstalliert
    Datenbank         %ProgramData%\Rippy       BLEIBT LIEGEN

Eine "frische" Installation erbte damit die alte Datenbank samt
`setup.done = true` -- der Assistent erschien nie wieder. Gegengeprueft:
nach dem Aufraeumen meldet /api/setup wieder {"done": false}, und das
Fenster zeigt "Willkommen bei Rippy".

Der Grund fuer den alten Ort stand im Docstring: "Ein Programmordner ist
unter Windows fuer einen DIENST nicht zuverlaessig beschreibbar." Das stimmte
-- fuer den Windows-Dienst, den es nie gab. Rippy laeuft als der angemeldete
Benutzer (Autostart unter HKCU). Jetzt liegt alles, was Rippy gehoert, in
EINEM Ordner: Programm, UI, Werkzeuge, Protokoll, WebView2-Zwischenspeicher
und die Datenbank.

Dazu:

* `datenbank_umziehen()` holt eine vorhandene Datenbank vom alten Ort ab --
  wer Rippy schon benutzt hat, behaelt Jobs und Einstellungen. Gibt es am
  neuen Ort schon eine, bleibt sie unangetastet: Die BENUTZTE gewinnt.
* `deinstallieren()` raeumt den alten Ort mit weg, und `alte_orte()` fasst
  nur einen Ordner an, der wirklich "Rippy" heisst.
* Der Ersteinrichtungs-Assistent spricht nicht mehr von "Encoding-Worker",
  wenn es keine gibt -- dort steht jetzt "Kompression". Gegengeprueft: kein
  "docker", kein "Container", kein "Worker" mehr im Text.

Ampel lokal: 709 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:46:02 +02:00
HitonabiandClaude Opus 5 082e8b8b55 fix(windows): der Knopf "Rippy oeffnen" oeffnete Rippy nicht
Ampel / ampel (push) Failing after 55s
Nach einer erfolgreichen Einrichtung schloss er nur das Fenster, und danach
passierte nichts mehr: Der Dienst lief nicht, kein Fenster ging auf. Der
Commander hat Rippy anschliessend selbst gestartet ("das tool selbst laesst
sich starten") -- was den Fehler verdeckt hat.

Ein Knopf, der etwas anderes tut als seine Beschriftung sagt, ist schlimmer
als keiner. Jetzt startet main() nach einem geglueckten Assistenten das
installierte Programm.

Dazu der zweite Teil des Netzlaufwerk-Befunds: Auch Netz-ANMELDUNGEN haengen
am Token der Sitzung, nicht nur die Laufwerksbuchstaben. Ein erhoehter Prozess
kennt die Zugangsdaten zur Freigabe nicht und bekommt "Zugriff verweigert" --
also dieselbe Meldung wie bei einem echten Rechteproblem. Ein UNC-Pfad mit
Administratorrechten wird jetzt als solcher erkannt und erklaert.

Ampel lokal: 705 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:38:17 +02:00
HitonabiandClaude Opus 5 6dfff5c99d fix(windows): vier Befunde aus dem ersten echten Installationslauf
Alle vom Commander gemeldet, alle nachgestellt.

## 1. Der Installer brach ab: No module named 'db'

    Fehlgeschlagen: ModuleNotFoundError: No module named 'db'

Zu Recht: **docker/api/db.py gibt es nicht.** Seit der Zusammenlegung in V2-1
ist der Store `rippy.store`; main.py schreibt seitdem
`from rippy import store as db`. Ich habe mir aus einem ALIAS ein Modul
zusammengereimt und importiert -- genau die erfundene Schnittstelle, vor der
AGENTS.md Regel D warnt, nur diesmal im eigenen Code. Der Store wird jetzt
direkt benutzt; der Umweg ueber den API-Suchpfad war ohnehin ueberfluessig.

## 2. Der Ersteinrichtungs-Assistent fehlte nach der Installation

In App.tsx stand:

    .catch(() => setSetupDone(true))  // API/Setup nicht erreichbar -> nicht blockieren

Das Fenster geht unmittelbar nach dem Setup auf, der Dienst braucht ein bis
zwei Sekunden bis zur ersten Antwort -- der erste Abruf scheitert also fast
immer. Aus "ich konnte nicht nachsehen" wurde "ist schon erledigt", und der
Assistent war fuer immer weg.

Dasselbe Prinzip steht nebenan in useEventStream.tsx: Ein Verbindungsabriss
ist KEINE Aussage ueber die Welt. Jetzt wird nachgefragt, bis der Server
ANTWORTET (zwei Minuten lang alle zwei Sekunden), und solange steht
"Rippy startet ..." im Fenster statt einer leeren Flaeche.

## 3. + 4. Als Administrator keine Netzlaufwerke, Netzlaufwerk = "keine Rechte"

Beides Windows-Verhalten, kein Rippy-Fehler -- aber Rippy hat ihn
hineinlaufen lassen. Auf dem Rechner nachgemessen:

    X:, Y:, Z:                 Netzlaufwerke
    EnableLinkedConnections    nicht gesetzt (Standard)
    C:\Program Files (x86)     ohne Administrator nicht beschreibbar

Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene
Netzlaufwerke haengen am Token der Sitzung und existieren dort schlicht
nicht. Daraus wird ein Teufelskreis:

    Program Files    verlangt Administrator
    Administrator    versteckt die Netzlaufwerke
    Netzlaufwerk     wirkt dann wie "keine Schreibrechte"

Rippy braucht ueberhaupt keine Administratorrechte (Vorgabeordner unter
%LOCALAPPDATA%, Autostart unter HKCU). Der Assistent sagt das jetzt:

* eine eigene Pruefung "Rechte", die bei erhoehtem Start warnt und die
  unsichtbaren Laufwerke beim Namen nennt
* ein fehlender Laufwerksbuchstabe wird als solcher gemeldet, nicht als
  fehlendes Schreibrecht ("Laufwerk Q: ist hier nicht vorhanden")
* ein Ziel unter "Programme" erklaert den Kreis und verweist auf den
  Vorgabeordner

## Dazu: zwei Schreibfallen in AGENTS.md

Deutsche Anfuehrungszeichen in doppelt gequoteten Zeichenketten (vier Mal an
einem Tag) und Bash-Heredocs, die Backslashes halbieren (fuenf Mal, zuletzt
beim Schreiben genau dieses Absatzes). Ein Test dafuer waere Rauschen gewesen
-- Python faengt beides bereits beim Import, nur mit verwirrender Meldung.
Die Regel gehoert in die Konventionen, nicht in eine Pruefung.

Ampel lokal: 703 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:36:56 +02:00
HitonabiandClaude Opus 5 8eca982e68 docs: SAVEPOINT v4.0-rc2 — Windows ist ein eigenes Produkt
Ampel / ampel (push) Successful in 55s
Die drei Befunde des Commanders und was dahinter steckte, plus der teuerste
Fund des Tages: %ProgramFiles(x86)% wurde auf einer echten Maschine NIE
aufgeloest, weil der Test genau die Schreibweise einspritzte, die im Muster
stand. Er war gruener als die Wirklichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:03:04 +02:00
HitonabiandClaude Opus 5 151376aee3 fix: Pfad-Regeln an EINE Stelle — dreimal dieselbe Falle war zweimal zu viel
Ampel / ampel (push) Successful in 54s
Ampel-Lauf zu b47d215 rot, drei Fehlschlaege, alle mein eigener Code:

    platz_orte             os.path.splitdrive kannte auf Linux kein "D:"
                           -> zwei Laufwerke galten als eines
    naechster_vorhandener  os.path.dirname zerlegte C:\Users\Test\... nicht
    pruefe_windows         die Plattform-Pruefung stand VOR der eingespritzten
                           Version -> pruefe_windows(22631) gab auf Linux FEHLER

`os.path` richtet sich nach der Maschine, auf der es laeuft. Das ist fast
immer richtig -- und an jeder Stelle falsch, an der ueber Pfade einer ANDEREN
Maschine gerechnet wird. Genau das war heute schon zweimal da:

    tools/katalog.py         C:\Program Files (x86)\MakeMKV/makemkvcon64.exe
    platform/verknuepfungen  dirname() einer .lnk gab einen leeren String

Zweimal einzeln repariert, beim dritten Mal gehoert es an EINE Stelle:
`rippy/pfade.py` mit `ist_windows_pfad`, `verbinden`, `ordner_von`,
`laufwerk_von`, `gleiches_laufwerk`, `naechster_vorhandener`. Die Regel steht
dort im Kopf: **Der Pfad entscheidet, nicht der Rechner.** katalog,
verknuepfungen, betrieb und einrichtung benutzen jetzt alle dasselbe.

Und `pruefe_windows` beachtet einen eingespritzten Wert wieder. Ein
Einspritzpunkt, der ignoriert wird, ist keiner -- dann sind die Tests
daneben, die ihn benutzen.

Sieben neue Tests in test_pfade.py, die BEIDE Zweige pruefen und deshalb
ueberall laufen. Das war der eigentliche Mangel: Jede dieser drei Fallen war
nur auf der jeweils anderen Plattform sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:00:34 +02:00
HitonabiandClaude Opus 5 b47d21510d docs: Entscheid 6 — Faehigkeiten statt Modus-Name, und ein Setup das fragt
Ampel / ampel (push) Failing after 54s
Beide Befunde des Commanders vom 28.08.2026 festgehalten, mitsamt der
Begruendung, warum ein 'if (windows)' an dreissig Stellen die falsche
Reparatur gewesen waere: Die Oberflaeche haette weiterhin nichts ueber ihren
Betrieb gewusst, nur eine zweite Sorte Vermutung gehabt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:56:41 +02:00
HitonabiandClaude Opus 5 a4a045f303 feat(windows): ein echtes Setup — Voraussetzungs-Pruefung, Zielordner, Ablage
Commander: "Dann haette ich beim 'Setup' auch erwartet das nen echtes setup
passiert - wo will ich das hinspeichern, nen pre requirement check usw. Da
kommt garnichts Rippy geht einfach auf."

Er hatte recht. Ein Doppelklick installierte stillschweigend nach
%LOCALAPPDATA%\Rippy und oeffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts -- auch nicht, ob sein Rechner ueberhaupt kann, was
Rippy braucht.

Jetzt: ein Assistent mit acht Pruefungen, zwei Ordner-Wahlen und drei
Schaltern. Auf diesem Rechner nachgemessen:

    OK  Betriebssystem      Windows 11 (Build 26200)
    OK  Fenster (WebView2)  151.0.4129.107
    OK  Optisches Laufwerk  \.\G:
    OK  HandBrakeCLI        1.11.2
     !  MakeMKV             nicht gefunden
                            -> wird von makemkv.com geholt
    OK  Speicherplatz       59.1 GB frei
    OK  Schreibrecht        C:\Users\...\AppData\Local\Rippy
    OK  Netzwerk-Port       7788 ist frei

Drei Entwurfsentscheidungen, die dazugehoeren:

* Nur ein FEHLER blockiert, eine Warnung nicht. Eine Warnung, die den Knopf
  sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle.
  Kein Laufwerk = Warnung (reine Komprimier-Maschine ist ein vorgesehener
  Betriebsfall). Kein Schreibrecht = Fehler.
* Jeder Befund sagt, was zu tun ist. Ein Befund ohne Abhilfe laesst den
  Nutzer ratlos zurueck -- ein Test haelt das fuer jede Pruefung fest.
* Die Pruefungen stehen in einrichtung.py, komplett ohne Fenster pruefbar.
  Im Fenstermodul steht nur Aufbau und Bruecke.

WebView2 statt Win32-Dialog: Es ist ohnehin da (Entscheid 4), der Assistent
kostet damit NULL zusaetzliche Bytes. Die Seite laedt nichts aus dem Netz --
sie muss auf einem Rechner ohne Internet aufgehen, dort wird sie am
dringendsten gebraucht. Ein Test haelt das fest.

--still und --ohne-assistent gehen weiterhin den stummen Weg. Geht das
Fenster nicht auf, wird mit den Vorgaben installiert und gesagt warum -- ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, waere schlimmer
als eines, das nicht fragt.

fix(tools): %ProgramFiles(x86)% wurde auf echten Rechnern NIE aufgeloest

Beim Bauen der Pruefung aufgefallen, und es ist ein Lehrstueck:

    Testumgebung   {"ProgramFiles(x86)": ...}  ->  C:\Program Files (x86)\...
    echtes Windows {"PROGRAMFILES(X86)": ...}  ->  ''

Windows behandelt Umgebungsvariablen ohne Ruecksicht auf die Schreibweise und
legt sie in os.environ GROSS ab. Der Vergleich in _entfalten war exakt, fand
nie eine Uebereinstimmung, und die bekannten Installationsorte fielen aus der
Kandidatenliste. MakeMKV wurde also NUR ueber die Registry-Rueckfallebene
gefunden -- wo die fehlt oder anders aussieht, fand Rippy nichts.

Aufgefallen ist es nur, weil hier zum ersten Mal eine ECHTE Umgebung benutzt
wurde. Der Test spritzte genau die Schreibweise ein, die im Muster stand: Er
war gruener als die Wirklichkeit. Beide Tests nehmen jetzt die echte
Schreibweise, und einer prueft direkt gegen os.environ. Beim Zurueckdrehen
des Fehlers rot gesehen (5 Fehlschlaege).

Ampel lokal: 691 gruen, ruff sauber. Assistent im Bildschirmfoto belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:54:59 +02:00
HitonabiandClaude Opus 5 66642b8d93 feat(ui): die Oberflaeche weiss jetzt, worauf sie laeuft
Commander-Befund 28.08.2026, zum Windows-Fenster:

    Worker erreichbar:   0 von 1
    Kein Worker antwortet — Pruefen: docker compose ps
    Container-Platte:    unbekannt
    Freigaben:           keine eingehaengt

Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Sein Urteil: "Du hast
ja quasi nur rippy genommen und die docker installation fuer Windows gebaut.
Das gilt fuer die ganze standalone version fuer Windows, auch fuer die
settings und die Anleitung usw."

## Die Ursache war nicht die Anzeige

Die naheliegende Reparatur waere ein `if (windows)` an dreissig Stellen
gewesen -- dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung.

Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es laeuft. config.py
kennt das Profil seit V2-2, weitergegeben wurde es nie. Also hat das UI
angenommen.

Neu: GET /betrieb meldet FAEHIGKEITEN, keinen Modus-Namen.

    externe_worker        Gibt es andere Maschinen, die Jobs uebernehmen?
    freigaben_einhaengen  Kann Rippy Netzwerk-Freigaben selbst einhaengen?
    container_pfade       Sind Pfade wie /app/media ueberhaupt gemeint?
    werkzeuge_verwalten   Kann Rippy MakeMKV/HandBrake selbst beschaffen?

Ein Modus-Name wuerde das UI zwingen, aus einem Namen auf Verhalten zu
schliessen -- und das bricht beim naechsten Betriebsfall: Ein
Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker.

## Was sich sichtbar aendert (auf Windows nachgemessen)

    Server-Status  "Rippy arbeitet: auf diesem Rechner" statt Worker-Zaehler
                   "Platz fuer Rippy: 59.2 von 232 GB frei" statt "unbekannt"
                   Freigaben-Block faellt weg
    Einstellungen  kein Reiter "Worker"; Speicherziele BLEIBT (dort steht die
                   Ablage -- "wo will ich das hinspeichern" war die Frage),
                   aber mit Pfadfeld statt Container-Auswahl und ohne die
                   Maske zum Einhaengen
    Anleitung      kein docker, kein Encoding-Worker-Abschnitt, stattdessen
                   der echte Ordner (C:\Users\...\Videos\Rippy)

## Zwei Fehler, die dabei aufgefallen sind

* MEDIA_ROOT = "/app/media" war in main.py fest verdrahtet. shutil.disk_usage
  warf unter Windows, die Liste blieb leer -- daher "unbekannt", obwohl auf
  dem Laufwerk 59 GB frei waren. Eine Nichtauskunft, die wie eine Auskunft
  aussieht. platz_orte() liefert die Orte jetzt je Betrieb, und ein noch
  nicht angelegter Ordner faellt auf das naechste vorhandene Elternteil
  zurueck.
* Der SSE-Schnappschuss enthielt den Server-Zustand NICHT, und der Waechter
  schickt ihn nur alle 15 Sekunden. Nach jedem Neuladen stand deshalb bis zu
  eine Viertelminute "unbekannt" da. Jetzt ist er im Schnappschuss, und das
  UI uebernimmt ihn auch von dort.

Dazu: der Windows-Skip in test_api_smoke.py ist weg. Er stammte aus der Zeit
vor V2-4, als main.py fcntl brauchte; seit der Treiberwahl ueber den Port
laedt es auf beiden Plattformen (57 Routen, gemessen). Damit laufen 20 Tests
mehr auch lokal statt nur auf der Ampel.

Ampel lokal: 654 gruen, ruff sauber. Docker-Zweig durch Unit-Tests gedeckt,
am echten Container noch nicht gegengeprueft -- das kommt beim Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:45:00 +02:00
HitonabiandClaude Opus 5 a5e64f8e56 fix(windows): der Deinstallierer liess 148 Dateien liegen und meldete Erfolg
Ampel / ampel (push) Successful in 54s
Beim ersten echten Deinstallieren auf dem Rechner des Commanders gemessen:

    Uninstall-Eintrag   weg
    Autostart           weg
    Verknuepfungen      weg
    Dateien             149 NOCH DA  (davon 148 in fenster\EBWebView\)

Gemeldet wurde: "Rippy wurde entfernt."

Zwei Ursachen, beide behoben:

## 1. WebView2 ueberlebt Rippy

WebView2 startet eigene Prozesse (msedgewebview2.exe: Renderer, GPU, Netz,
Crashpad). Stirbt Rippy, laufen sie als Waisen weiter und halten den
Zwischenspeicher offen -- acht davon liefen noch, als ich nachsah. shutil.rmtree
scheiterte an jeder gesperrten Datei.

fenster.helfer_beenden() beendet sie jetzt VOR dem Loeschen. Gefiltert wird
ueber den --user-data-dir in der Befehlszeile: msedgewebview2.exe benutzen
auch andere Programme, und die alle zu beenden waere ein Uebergriff -- der
Nutzer verloere die Fenster fremder Anwendungen. Ein Test haelt fest, dass
erst gefiltert und dann beendet wird.

## 2. Der Fehler wurde verschluckt

`except OSError: pass` in der Loeschschleife. Ein "Rippy wurde entfernt" ueber
einem halb geleerten Ordner ist eine Falschaussage -- genau die Sorte stiller
Fehlschlag, vor der AGENTS.md warnt. Jetzt wird gesammelt, was nicht ging, und
mit dem Grund genannt.

Dazu im Aufraeum-Skript: `rmdir /s /q` statt `rmdir`. Ein blosses rmdir
scheitert an JEDER verbliebenen Datei und laesst den ganzen Ordner stehen --
selbst wenn spaeter nur noch eine Sperrdatei uebrig ist.

Ampel lokal: 612 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:58 +02:00
HitonabiandClaude Opus 5 ce4e7e6b8a feat(windows): das Setup richtet MakeMKV und HandBrake wirklich ein
Ampel / ampel (push) Successful in 54s
Commander: "handbrake und makemkv MUESSEN mitgeliefert werden oder waehrend
des Setups separat installiert werden! Ohne das ist das tool NICHT
einsatzfaehig."

Er hat recht, und die Luecke war real: katalog.py konnte die Werkzeuge
finden, beschaffen.py konnte sie holen -- das Setup rief beides nie auf. Wer
Rippy auf einem frischen Rechner installierte, bekam eine Oberflaeche, die
ihm sagte was fehlt, und keinen Weg, es zu aendern.

Die beiden gehen unterschiedliche Wege, und der Grund ist die LIZENZ:

* HandBrakeCLI ist GPL-2 -> mitgeliefert. Weitergabe ausdruecklich erlaubt,
  solange Lizenztext und Quellverweis dabei sind (LIZENZ-HandBrake.txt liegt
  daneben). Damit komprimiert Rippy auch ohne Internet.
* MakeMKV ist proprietaer -> beim Einrichten vom Hersteller geholt und mit
  DESSEN Installer installiert. Weitergabe durch Dritte ist nicht erlaubt.
  Das ist dieselbe Black-Box-Trennung wie in KONZEPT.md 6 und deckt sich mit
  KONZEPT-V2.md 4.1 ("MakeMKV wird NICHT mitgeliefert").

Drei Regeln, die dazugehoeren:

1. Ein Setup meldet NIE Erfolg, waehrend ein Pflichtwerkzeug fehlt.
   sicherstellen() liest den Bestand VOR und NACH dem Versuch und gibt
   zurueck, was danach wirklich da ist -- nicht, dass es versucht wurde.
2. Ein nicht erreichbarer Download bricht die Installation nicht ab. Am
   28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem
   installiert und sagt im Klartext, was fehlt und wie man es beschafft.
3. Ohne HandBrake ist Rippy einsatzbereit (Rip laeuft, nur ohne Kompression),
   ohne MakeMKV nicht. Nur Pflichtwerkzeuge entscheiden ueber "bereit".

Nachgemessen am fertigen Setup: HandBrake geloescht, installiert, nach DREI
Sekunden wieder da (74 MB, also aus dem Paket und nicht geladen), Meldung
"Rippy ist einsatzbereit. HandBrakeCLI 1.11.2 / MakeMKV 1.18.4".
RippySetup.exe waechst von 32,0 auf 56,4 MB.

fix(tools): der Fortschritts-Rueckruf bekommt immer einen Text

_datei_laden schickte `None` als Text, wenn sich nur der Fortschritt geaendert
hatte. Der erste echte Aufrufer starb daran:

    can only concatenate str (not "NoneType") to str

Ein Rueckruf, den man nur mit einer nirgends dokumentierten Sonderbehandlung
benutzen kann, ist eine Falle. Jetzt kommt immer ein Satz, gedrosselt auf
jedes zehnte Prozent -- 24 MB in 256-KB-Haeppchen waeren sonst hundert Zeilen
im Protokoll. Zwei Tests halten beides fest.

Nicht im Repo: Die 70,9 MB HandBrakeCLI holt der BAU nach
dist/windows/vendor/ (nicht versioniert) -- dasselbe Muster wie der
vendor/-Ordner fuer MakeMKV auf der VM.

Ampel lokal: 607 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:09:01 +02:00
HitonabiandClaude Opus 5 60c0f93237 fix(windows): leere Symbole und der verschwundene Eintrag in "Installierte Apps"
Ampel / ampel (push) Successful in 54s
Beides vom Commander gemeldet, beides gemessen statt vermutet.

## 1. Weisses leeres Blatt statt Symbol

In deploy/worker-windows/rippy.ico steckte GENAU EIN Bild:

    Bilder = 1
      256x256   32 bit   5657 Bytes   PNG

Windows holt sich fuer jede Stelle die passende Groesse -- 16x16 fuer die
Taskleiste, 32/48 fuer Desktop und Startmenue. Fehlt sie, skaliert die Shell
nicht zuverlaessig selbst, sondern zeigt das leere Blatt. Bei einer Datei mit
nur einem PNG-komprimierten 256er passiert das regelmaessig.

packaging/windows/icon.py baut die Datei jetzt mit neun Groessen
(16/20/24/32/40/48/64/128/256, LANCZOS beim Verkleinern). Nachgemessen an
der neuen EXE: 16x16 mit 138 Farben, 32x32 mit 264 -- kein leeres Blatt mehr.
build.py laesst ein unvollstaendiges Symbol gar nicht mehr durch, und
test_icon.py haelt die Pflichtgroessen fest (mit dem alten Stand rot gesehen).

## 2. Rippy stand nicht in "Installierte Apps"

Die Ursache war MEINE Testsuite. Gemessen:

    Rippy.exe --nicht-starten   ->  Eintrag: DA  (Rippy 2.0.0)
    pytest -q                   ->  Eintrag: WEG

test_installation_legt_die_dateien_an_... schrieb in den ECHTEN
Uninstall-Schluessel und loeschte ihn im finally wieder -- also auch den des
Commanders. Der Autostart-Eintrag ging denselben Weg.

Der Schluesselname stand als VORGABEWERT in jeder Signatur, und Vorgabewerte
wertet Python zur Definitionszeit aus: Ein Test konnte ihn gar nicht umbiegen.
Jetzt steht dort None, aufgeloest zur Laufzeit -- damit wirkt ein
monkeypatch.setattr(reg, "SCHLUESSEL", ...) ueberall.

Dazu ein Waechter (test_die_testsuite_ruehrt_den_ECHTEN_eintrag_nicht_an):
Er merkt sich den echten Zustand vorher, laesst eine vollstaendige
Test-Installation samt Autostart laufen und prueft danach, dass sich am
echten Eintrag nichts geaendert hat.

Das ist derselbe Fehler wie vorhin bei den Verknuepfungen, nur an anderer
Stelle. Die Regel gehoert in den Code, nicht in meinen Kopf: **Ein Test
raeumt nur weg, was er selbst angelegt hat.**

Ampel lokal: 590 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:57:50 +02:00
HitonabiandClaude Opus 5 e1984dc543 docs: SAVEPOINT — Ampel wieder gruen, und warum sie fuenf Laeufe rot war
Ampel / ampel (push) Successful in 54s
Nachgetragen: die 28 Linux-Fehler, der schwerste davon (der API-Container
waere auf der VM gar nicht hochgekommen), und die Lehre daraus — ein Test,
der nur auf einer Plattform greift, ist eine halbe Zusage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:46:22 +02:00
HitonabiandClaude Opus 5 ca212ff835 fix(test): die detection-Attrappe muss auch das PAKET-Attribut ersetzen
Ampel / ampel (push) Successful in 54s
Ampel-Lauf 186: Die 28 echten Linux-Fehler waren weg, uebrig blieben fuenf
— alle in meinem neuen Test:

    TypeError: drive_status() missing 1 required positional argument

`from rippy.drives import detection` sieht zuerst im PAKET nach
(`_handle_fromlist` prueft `hasattr`) und greift erst dann auf sys.modules
zurueck. Auf Linux ist das echte Modul dort laengst gesetzt, weil es
importierbar ist — die Attrappe blieb wirkungslos, und der Test lief in die
echten Funktionen.

Unter Windows fiel das nicht auf: Dort gibt es das Attribut mangels fcntl
gar nicht, also griff sys.modules. Derselbe Test, zwei Plattformen, zwei
Ergebnisse — genau die Sorte Luecke, die dieser Test eigentlich schliessen
soll.

Jetzt werden beide ersetzt. Gegengeprueft: mit kaputter Durchreiche fuenf
Fehlschlaege, danach wieder gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:44:05 +02:00
HitonabiandClaude Opus 5 bfc0cada62 fix(windows): kein CMD-Fenster mehr — die EXE ist ein Fenster-Programm
Ampel / ampel (push) Failing after 55s
Commander: "Unterbinde das CMD Fenster was sich beim oeffnen mitoeffnet."

Mein erster Anlauf wollte die Konsole VERSTECKEN und war falsch, aus zwei
Gruenden:

* Eine Onefile-EXE ist ZWEI Prozesse (gemessen: PID 33172 mit 8 MB, PID
  33580 mit 103 MB, beide Rippy.exe). Die Pruefung "haengt genau EIN Prozess
  an der Konsole" war damit nie wahr, das Fenster blieb einfach stehen.
* Selbst wenn sie ginge, waere das Fenster erst nach ein bis zwei Sekunden
  weg. Ein Aufblitzen ist kein "unterbunden".

Jetzt wird die Konsole gar nicht erst erzeugt: --windowed statt --console.
Nachgemessen am fertigen Bau -- PE-Subsystem 2 (GUI) statt 3 (Konsole), und
beim Klick auf die Desktop-Verknuepfung ist genau EIN sichtbares Fenster da:
die WinForms-Oberflaeche. Kein ConsoleWindowClass-Fenster von Rippy, weder
sichtbar noch unsichtbar.

Was dazugehoert, damit daraus kein stiller Fehlschlag wird:

* Ohne Konsole ist sys.stdout None. uvicorn schreibt auf stderr und waere
  mitten im Start gestorben, ohne dass irgendwo etwas stuende. einstieg.py
  haengt sich deshalb an die Konsole des Aufrufers (--status in PowerShell
  schreibt weiter dorthin) oder leitet nach %LOCALAPPDATA%\Rippy\rippy.log um.
* Der Installer meldet sein Ergebnis in einem Fenster, wenn keine Konsole da
  ist -- sonst saehe ein gescheiterter Doppelklick aus wie einer, der nichts
  tut. Bei --still und mit Konsole bleibt es bei der Textzeile.

Gegengeprueft am laufenden Programm: /api/health, / und /api/capabilities
antworten mit 200, und rippy.log faengt auf, was vorher ins Leere ging.

fix(drives): linux.py reicht die detection-Funktionen wieder durch

Die Ampel war seit Lauf 181 rot -- vier Commits lang, und ich hatte es
uebersehen. Ursache:

    docker/api/main.py:54
    AttributeError: module 'rippy.drives.linux' has no attribute 'drive_status'

main.py holt sich beim Import device_discovery.drive_status. Der
Windows-Treiber hat die Funktion; linux.py hatte sie nicht mehr, seit
detection (und damit fcntl) bewusst nicht mehr oben importiert wird -- sonst
waere main.py unter Windows nicht ladbar. Unter Windows lief also alles, auf
Linux waere der API-Container gar nicht hochgekommen.

Behoben mit PEP 562 (__getattr__), demselben Kniff wie in store/__init__.py:
Der Import bleibt ohne fcntl moeglich, wer die Funktionen BENUTZT bekommt sie.

Dazu drei weitere Fehler, die nur auf Linux auftraten:

* katalog.py baute Windows-Pfade mit os.path.join. Auf dem Linux-Runner wurde
  daraus C:\Program Files (x86)\MakeMKV/makemkvcon64.exe. Jetzt entscheidet
  der Pfad selbst ueber den Trenner, nicht die rechnende Maschine.
* verknuepfungen.py nahm os.path.dirname fuer den Arbeitsordner einer .lnk.
  Eine .lnk zeigt IMMER auf einen Windows-Pfad -- also ntpath.
* waechter.py las 0.0 als Zeitpunkt statt als "noch nie geprueft". Auf einer
  frisch gestarteten Maschine ist time.monotonic() klein, also fiel die erste
  Laufwerks-Abfrage aus. None statt 0.0.

Alle vier haben jetzt Tests, die auf JEDER Plattform laufen -- der
Treiber-Test schiebt eine detection-Attrappe unter, der Waechter-Test stellt
eine Uhr bei 0,5 s nach, die Pfad-Tests pruefen beide Zweige. Beim
Wegnehmen der Durchreiche rot gesehen (5 Fehlschlaege), danach wieder gruen.
Damit faengt die Ampel so etwas kuenftig, ohne dass es eine Linux-Umgebung
auf dem Entwicklungsrechner braucht.

Ampel lokal: 582 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:41:10 +02:00
HitonabiandClaude Opus 5 e1577a4f47 docs: SAVEPOINT v4.0-rc — Windows ist ein echtes Programm
Ampel / ampel (push) Failing after 55s
Der SAVEPOINT stand seit V2-3 still, waehrend elf Commits dazukamen.
Nachgetragen mit den gemessenen Zahlen: 562 gruen, RippySetup.exe 31,7 MB,
WebView2 151.0.4129.107, 5 Encoder auf diesem PC.

Zwei Dinge stehen bewusst gross drin, weil sie sonst verloren gehen:

* Der Dienst, der /api/health mit 200 beantwortete und / mit 404 -- und
  warum ein %TEMP%-Verzeichnis kein Ort fuer eine Oberflaeche ist.
* Der Test, der den PC des Commanders getroffen hat. Ein Test, der Spuren
  ausserhalb von tmp_path hinterlaesst, ist kein Test, sondern ein Eingriff.

Und was NICHT bewiesen ist, steht als solches da: Ein echter Rip unter
Windows ist nie gelaufen, die VM haengt elf Commits zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:15:29 +02:00
HitonabiandClaude Opus 5 c01ef1081c feat(windows): echtes Fenster statt Browser-Tab (WebView2 statt Electron)
Auf die Frage des Commanders: "Warum nutzen wir fuer Windows weiterhin
einen Browser? Warum nutzen wir kein Electron oder sowas und machen daraus
einen echten Client."

Die erste Haelfte trifft zu und ist umgesetzt. Die zweite ist abgelehnt --
gemessen, nicht geschaetzt:

    pywebview + WebView2     8,0 MB in der Bau-Umgebung, 2,7 MB in der EXE
    Electron               150-210 MB, dazu eine zweite Laufzeitumgebung

Windows 11 bringt die WebView2-Laufzeit mit (hier 151.0.4129.107).
Nachgemessen statt angenommen, was sie rendert:

    navigator.userAgent  ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
    fetch ja   EventSource ja   CSS Grid ja   Pfeilfunktionen ja

Dasselbe Chromium, das auch in Electron steckt -- nur ohne es ein zweites
Mal mitzuschleppen. RippySetup.exe waechst von 29,0 auf 31,7 MB.

Drei Dinge gehoeren dazu, nicht als Beiwerk:

* Kein stiller Rueckfall auf MSHTML. Der alte IE-Renderer stellt die
  React-Oberflaeche nicht dar. Fehlt WebView2, oeffnet Rippy den Browser
  und SAGT warum -- ein leeres Fenster waere schlimmer als ein Tab.
* Fenster und Tray sind getrennte Prozesse. pystray belegt unter Windows
  den Haupt-Thread, das Fenster braucht ihn genauso.
* Die eigene Konsole wird versteckt, eine geerbte nie. GetConsoleProcessList
  unterscheidet beides: genau ein Prozess an der Konsole heisst, sie
  gehoert uns. Ohne diese Unterscheidung haette "Rippy.exe --status" im
  Terminal das Terminal des Nutzers verschwinden lassen.

fix(windows): Oberflaeche neben das Programm legen statt aus %TEMP% bedienen

Beim Nachsehen im laufenden Betrieb gefunden: Ein Dienst lief eine Stunde,
/api/health gab HTTP 200, / gab 404. Im Fenster stand {"detail":"Not Found"}.

Der Server war in Ordnung. Sein Entpack-Verzeichnis war es nicht:

    _MEI000074b02   31 Eintraege,  5 Ordner   -- kein ui, kein api
    _MEI000082e82   44 Eintraege, 16 Ordner   -- vollstaendig

Eine PyInstaller-Onefile-EXE liest bei JEDER Anfrage aus %TEMP%\_MEIxxxxx.
Ein Temp-Verzeichnis ist kein Ort fuer etwas, das eine Woche liegen bleibt.
Und der Ausfall ist der schlimmstmoegliche: Die API antwortet weiter, der
Dienst gilt als gesund, nur die Oberflaeche ist weg.

Genau davor warnte ROADMAP.md beim Bau-Verfahren ("one-dir statt one-file").
Die Abweichung bleibt, die Luecke wird geschlossen: Der Installer legt die
Oberflaeche neben das Programm, daemon._ui_pfad() nimmt diese Kopie zuerst.
Zeigt sich derselbe Ausfall an den API-Modulen, ist one-dir die Antwort.

Ampel: 562 gruen, ruff sauber. Fenster mit der echten Oberflaeche im
Bildschirmfoto nachgewiesen, nicht nur der Titel geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:14:15 +02:00
HitonabiandClaude Opus 5 069fc46f44 fix(test): der Installations-Test hat die ECHTEN Verknuepfungen ueberschrieben
Ampel / ampel (push) Failing after 47s
WAS: `test_installation_legt_die_dateien_an_...` installiert in ein
tmp_path — legt aber seit dem Verknuepfungs-Einbau auch Symbole auf dem
ECHTEN Desktop und im ECHTEN Startmenue an. Die kennen kein tmp_path.

DIE FOLGE, vom Commander gemeldet: Er klickte auf das Desktop-Symbol und
bekam von Windows "Diese App kann auf dem PC nicht ausgefuehrt werden".
Nachgemessen zeigte die Verknuepfung auf

  ...\Temp\pytest-of-TobisPC\pytest-142\test_installation_..._0\installiert\Rippy.exe

— und dort liegt die 2-KB-ATTRAPPE aus dem Test, kein Programm. Windows
hatte voellig recht.

Der `finally`-Block raeumte nur Registry und Autostart auf; die
Verknuepfungen nicht, weil es sie beim Schreiben des Tests noch nicht gab.
Als sie dazukamen, wuchs der Test stillschweigend ueber sein tmp_path
hinaus. Ein Test, der Spuren ausserhalb seines Temp-Ordners hinterlaesst,
ist kein Test, sondern ein Eingriff.

BEHOBEN mit ZWEI Sicherungen statt einer:
  1. verknuepfen=False — die Funktion legt gar keine an.
  2. vk.alle_anlegen ist trotzdem ersetzt. Wer den Schalter spaeter
     umdreht oder die Vorgabe aendert, kann damit trotzdem nichts am
     echten System anrichten.

Dazu ein Waechter: test_installation_ruehrt_die_echten_verknuepfungen_
nicht_an prueft, dass die Anlege-Funktionen bei verknuepfen=False GAR
NICHT gerufen werden. Waere er frueher dagewesen, haette der Commander
keine kaputte Verknuepfung bekommen.

DER RECHNER IST REPARIERT: neu installiert, beide Verknuepfungen zeigen
wieder auf C:\Users\...\AppData\Local\Rippy\Rippy.exe (29 MB, existiert),
Programme+Features-Eintrag ist zurueck. Start ueber die Verknuepfung:
HTTP 200 nach 2 s, Worker TobisNicerPC mit cpu-x264/x265/av1 + vce/vce-av1,
MakeMKV 1.18.4 und HandBrake 1.11.2 gefunden.

Nebenbei ausgeschlossen, bevor die Ursache klar war: Die EXE selbst ist in
Ordnung (gueltiges x64-PE, MZ/PE-Kopf, 29 MB, Hash identisch mit dem
Installat), kein Mark-of-the-Web, kein Defender-Fund, Smart App Control
aus. Der Fehler lag nicht an der Datei, sondern daran, WORAUF gezeigt wurde.

GEMESSEN: ruff sauber, 522 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:47:11 +02:00
HitonabiandClaude Opus 5 99c05865af feat(windows): Desktop-Symbol und Startmenue-Eintrag — plus --oeffnen
Ampel / ampel (push) Failing after 47s
WAS: Das Setup legt jetzt eine Verknuepfung auf dem Desktop UND im
Startmenue an, die Deinstallation nimmt beide zurueck, und `--status`
zeigt, ob sie da sind. Abwaehlbar mit --keine-verknuepfungen.

WARUM (Commander-Meldung): "es gibt kein icon aufm desktop und keinen
startmenü eintrag". Zu Recht — ein Hintergrundprogramm ohne Symbol ist
praktisch unauffindbar: Es laeuft, aber es gibt keinen Weg hinein ausser
der Adresse im Kopf.

DIE VERKNUEPFUNG ZEIGT AUF --oeffnen, NICHT AUF --dienst. Ein Doppelklick
soll Rippy OEFFNEN; ob der Dienst laeuft, ist dem Nutzer egal und er kann
es auch nicht wissen. Zeigte das Symbol auf --dienst, startete jeder Klick
einen ZWEITEN Server, der am belegten Port scheitert — das Fenster blitzt
auf, im Browser passiert nichts. Ein Symbol, das beim zweiten Klick nichts
tut, ist schlimmer als keins.

`--oeffnen` sieht deshalb erst NACH, ob jemand antwortet, startet nur wenn
noetig, und wartet dann, bis der Dienst wirklich da ist — statt sofort
einen Browser auf eine tote Adresse zu schicken. Gemessen: erster Aufruf
2 s bis HTTP 200, zweiter Aufruf keine zusaetzlichen Prozesse.

DIE ORDNER KOMMEN AUS DER REGISTRY, nicht aus %USERPROFILE%. Wer OneDrive
benutzt oder seine Ordner verschoben hat, dessen Desktop liegt woanders —
die Verknuepfung landete dann in einem Ordner, den niemand sieht, und der
Nutzer suchte ein Symbol, das es nirgends gibt.

UEBER POWERSHELL statt pywin32: Eine .lnk ist ein binaeres Shell-Objekt.
pywin32 koennte es, waere aber 20 MB fuer vier Zeilen. Denselben Weg geht
der alte Worker-Installer schon ("nativ via PowerShell").

ZWEI FEHLER, DIE DIE TESTS GEFUNDEN HABEN:
- os.makedirs stand AUSSERHALB des try. Auf einem Laufwerk, das es nicht
  gibt, wirft es — aus "Verknuepfung konnte nicht angelegt werden" waere
  ein Abbruch der ganzen Installation geworden.
- Der Test dafuer nahm fest Z: als "gibt es nicht". Auf dem Commander-PC
  ist Z: ein NETZLAUFWERK (X, Y, Z sind belegt). Ein fest verdrahteter
  Laufwerksbuchstabe ist eine Annahme ueber eine fremde Maschine; jetzt
  wird zur Laufzeit ein freier gesucht.

Und der Apostroph: In PowerShell wird er innerhalb einfacher
Anfuehrungszeichen durch VERDOPPELN maskiert. Ohne das braeche ein Pfad wie
C:\Users\O'Brien\... das Skript entzwei, und der Fehler saehe aus wie ein
Syntaxfehler in Rippy.

GEMESSEN, nach echter Installation nach %LOCALAPPDATA%\Rippy:
  Desktop-Symbol   -> Rippy.exe --oeffnen, mit Symbol und Beschreibung
  Startmenue       -> ebenso
  Programme+Feat.  -> Rippy 2.0.0, 100 MB, Deinstallieren funktioniert
  Autostart        -> "...\Rippy.exe" --dienst
  Start ueber das Symbol: HTTP 200 nach 2 s
  Worker: TobisNicerPC, Encoder cpu-x264/x265/av1, vce, vce-av1

GEMESSEN: ruff sauber, 521 Tests gruen + 15 uebersprungen (vorher 512).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:39 +02:00
HitonabiandClaude Opus 5 f4a8d77598 feat(windows): Rippy arbeitet eigenstaendig — Rippen, Werkzeuge, Fähigkeiten
Ampel / ampel (push) Failing after 48s
WAS: Der Ablauf ist aus tasks.py heraus (ablauf.py, ohne Celery), die
LocalQueue wird bedient, ein Laeufer arbeitet Auftraege im selben Prozess
ab, und Rippy meldet sich mit gemessenen Faehigkeiten selbst als Arbeiter.

WARUM: "Rippy fuer Windows soll standalone funktionieren" (Commander). Bis
hierher konnte die Windows-App alles ANZEIGEN und nichts TUN — ein Rip waere
eingereiht worden und fuer immer liegengeblieben, weil niemand ihn holt.

DIE TRENNUNG: rip_disc hing an GENAU DREI Celery-Stellen in 234 Zeilen —
self.update_state, _transcode_queue, transcode_files.apply_async. Alle drei
sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt Rueckrufe:
tasks.py reicht die Celery-Fassung herein, standalone.py die lokale. OHNE
Rueckruf komprimiert derselbe Prozess weiter — genau das, was ein
Ein-Prozess-Rippy braucht. Der Ablauf selbst ist Zeile fuer Zeile derselbe;
der Docker-Betrieb merkt vom Umbau nichts (Task-Namen, Argumente, Queues
unveraendert).

EINE ZUSTELL-STELLE statt drei: celery_client.abschicken() bedient alle
Auftragsarten. Vorher rief jede Stelle send_task selbst auf — der
Standalone-Betrieb haette an drei Stellen umgebogen werden muessen, beim
naechsten Auftragstyp an einer vierten.

WEITERER BLOCKER GEFUNDEN: ablauf.py holte detect_disc_type fest aus dem
LINUX-Treiber, in einem try/except. Unter Windows waere es damit IMMER None
gewesen und Rippen "hart verriegelt" — Rippy haette alles angezeigt und
nichts gerippt, ohne dass irgendwo ein Fehler stuende. Jetzt fragt es den
Treiber-Port.

WERKZEUGE: ripping.py und caps.py suchten nur im PATH. Auf dem Commander-PC
gemessen, vorher/nachher:

  vorher   check_makemkv_installed()  -> False (obwohl installiert)
           erkenne_encoder()          -> nur CPU
  nachher  MakeMKV   1.18.4  C:\Program Files (x86)\MakeMKV\makemkvcon64.exe
           HandBrake 1.11.2  ueber die API geholt, in 2,7 s
           Encoder   cpu-x264, cpu-x265, cpu-av1, VCE, VCE-AV1
           107 Presets, Ryzen 7 9700X, 16 Kerne, avx512f

VCE ist die Hardwarebeschleunigung der Radeon — die hat Rippy auf diesem
Rechner vorher nie gesehen, weil es HandBrake gar nicht fand.

NEUE ROUTEN: GET /system/werkzeuge (was liegt wo, in welcher Fassung, gibt
es Neueres) und POST /system/werkzeuge/{name}/holen. HandBrake kommt
vollautomatisch von GitHub. MakeMKV wird NICHT mitgeliefert — Rippy laedt
die offizielle Datei und startet sie (Black-Box-Trennung, KONZEPT.md § 6).
makemkv.com antwortete beim Bauen mit HTTP 525; das wird im Klartext
gemeldet, und eine selbst geholte Datei bleibt moeglich.

HERZSCHLAG: /capabilities las die workers-Tabelle, die bisher nur der
Celery-Herzschlag fuellte. Im Standalone-Betrieb stand dort "0 Worker" und
die Encoder-Auswahl im UI blieb LEER — auf einem Rechner, der alles kann.
Jetzt meldet sich der Prozess selbst, mit dem, was caps.py MISST.

GEMESSEN, aus der fertigen EXE (29,0 MB):
  bereit nach 1 s, keine Fehler im Log
  Werkzeuge: beide gefunden, mit Version und Pfad
  Worker: 1 (TobisNicerPC), 5 Encoder, 107 Presets

Die ganze Kette ist als Test festgehalten (test_kette.py): zustellen ->
einreihen -> Laeufer -> ablauf -> Job endet in einem EHRLICHEN Zustand.
Ohne Laufwerk geprueft, und das ist der wichtigere Fall: Ein Rip auf ein
totes Geraet muss zuegig scheitern, nicht auf "pending" haengenbleiben.

GEMESSEN: ruff sauber, 512 Tests gruen + 15 uebersprungen (vorher 489).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:27:54 +02:00
HitonabiandClaude Opus 5 288f9eeb8d feat(tools): Werkzeuge finden, holen und aktuell halten — Grundlage fuer Windows
Ampel / ampel (push) Failing after 47s
WAS: rippy/tools/ mit katalog.py (finden + Version) und beschaffen.py
(herunterladen, installieren, Update-Stand). ripping.py und caps.py fragen
den Katalog statt shutil.which.

DER BEFUND, DER DAS NOETIG MACHT: Der Bestand suchte AUSSCHLIESSLICH mit
shutil.which(). Im Container stimmt das — dort liegen beide Werkzeuge in
/usr/local/bin. Unter Windows findet es NICHTS, auch wenn alles installiert
ist: Windows-Programme liegen in Program Files, nicht im PATH. Am
28.08.2026 auf dem Commander-PC nachgesehen — MakeMKV lag unter
"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe", which sah es nicht.

Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das der
Unterschied zwischen "laeuft" und "laeuft nicht". Vorher/nachher gemessen:

  vorher   check_makemkv_installed() -> False   (obwohl installiert)
  nachher  check_makemkv_installed() -> True
           build_makemkv_cmd()[0]    -> C:\Program Files (x86)\MakeMKV\makemkvcon64.exe

SUCHREIHENFOLGE, mit Begruendung im Modul: eingestellt > Rippys eigener
Werkzeug-Ordner > PATH > bekannte Installationsorte > Uninstall-Zweig der
Registry. Rippys eigener Ordner steht VOR dem System, damit eine selbst
gepflegte Fassung eine alte Systeminstallation schlaegt.

VERSION: HandBrakeCLI kann --version. makemkvcon KANN DAS NICHT (usage.txt
kennt keinen solchen Schalter) — die Version kommt aus dem Uninstall-Zweig.
Dort steht bei MakeMKV zwar eine Version, aber InstallLocation ist LEER
(nachgesehen); deshalb wird der Ordner ersatzweise aus DisplayIcon bzw.
UninstallString abgeleitet. Der Test dafuer hat sofort einen Fehler
gefunden: '"C:\...\uninstall.exe" /S' liess sich mit einem blossen
strip('"') nicht aufloesen.

BESCHAFFUNG, an echter Hardware gemessen:
  HandBrake  GitHub-Release-API -> 1.11.2, HandBrakeCLI-1.11.2-win-x86_64.zip
             geladen, entpackt, gestartet: meldet sich als 1.11.2. 2,8 s.
  MakeMKV    makemkv.com antwortete mit HTTP 525 (Cloudflare) — auch mit
             Browser-Kennung. Dieselbe Sperre, die im Projekt schon den
             Docker-Bau lahmlegt. Deshalb: Abruf wird versucht, ein
             Fehlschlag wird im Klartext gemeldet, und eine selbst geholte
             Datei laesst sich weiterhin verwenden. Ein Update-Server, der
             heute antwortet, darf keine Startbedingung fuer morgen sein.

DREI FALLEN, GEGEN DIE ES TESTS GIBT:
- Die .sig-Datei liegt im Release direkt neben dem ZIP und ist 566 Bytes
  gross. Wer nur nach "win" filtert, laedt sie.
- "1.9.2" ist als Zeichenkette GROESSER als "1.11.2". Ein Update-Hinweis, der
  ab Version zehn dauerhaft in die Irre fuehrt — und genau dort ist
  HandBrake gerade.
- Eine Fehlerseite kommt oft mit HTTP 200. Sie darf keine funktionierende
  Installation ersetzen: erst laden, pruefen, dann tauschen.

Und: Ist die neueste Version unbekannt (Quelle tot), steht NICHT "Update
verfuegbar" da. Eine Nichtauskunft ist keine Aussage.

GEMESSEN: ruff sauber, 489 Tests gruen + 15 uebersprungen (vorher 456).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:12:21 +02:00
HitonabiandClaude Opus 5 059183c651 feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s
WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.

DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:

1. EINTRAG IM TASKMANAGER
     ProcessName   Rippy
     Beschreibung  Rippy — automatisches Ripping
     Produkt       Rippy
     Version       2.0.0
   Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
   die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
   Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
   sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.

2. EINTRAG IN PROGRAMME UND FEATURES
   Aus der Registry zurueckgelesen, nicht behauptet:
     DisplayName      Rippy
     DisplayVersion   2.0.0
     Publisher        Rippy
     InstallLocation  <Zielordner>
     UninstallString  "<pfad>\Rippy.exe" --deinstallieren
     EstimatedSize    29241  (KiB)
     NoModify/NoRepair 1
   HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
   Installation laeuft aber ohne UAC-Abfrage durch.

VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.

VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:

1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
   Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
   Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
   grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
   schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
   store.verbinden() kaeme immer zu spaet.

2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
   `from celery_client import celery_client` loeste es sogar dann aus, wenn
   nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
   Ansage statt eines Importfehlers.

3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
   Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
   Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
   Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
   scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
   Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
   Prozesse; der Starter haelt die Datei nach dem Ende noch offen).

4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
   Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
   geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
   steht in der Registry (InstallLocation) und wird jetzt dort gelesen.

WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.

GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:39 +02:00
HitonabiandClaude Opus 5 e5254c23f0 feat(daemon): V2-4 (Teil 3) — rippyd laeuft unter Windows, ohne Docker
Ampel / ampel (push) Failing after 46s
WAS: `rippyd` startet Rippy als EINEN Prozess — SQLite statt Postgres,
LocalQueue statt Celery/Redis, In-Process-Bus statt Redis Pub/Sub, UI von
FastAPI statt von nginx. Dieselbe API, dasselbe UI, derselbe Rip-Code.

GEMESSEN (Windows 11, frische Python-3.12-Umgebung, ohne Docker/Postgres/Redis):

  bereit nach            1 s
  /api/health            200      /api/settings          200
  /api/devices           200      /api/logs              200
  /api/jobs              200      /api/capabilities      200
  /api/health/vorraete   200      /  (Weboberflaeche)    200

  Ereignis-Waechter      gesund: true, alter_sekunden: 0.5
  SSE-Strom              liefert `event: snapshot` mit vollem Zustand
  SQLite                 angelegt, WAL aktiv, alle Tabellen da

ZWEI FEHLER, DIE NUR DER ECHTE LAUF ZEIGT:

1. STARLETTE REICHT LIFESPAN NICHT AN MOUNT-UNTERANWENDUNGEN WEITER.
   Das UI ruft /api/..., im Docker-Betrieb entfernt der nginx das Praefix.
   Ohne nginx haengt die API als Unteranwendung unter /api — und ihr
   startup_event lief nie. Folge: db.init_db() nicht ausgefuehrt,
   "no such table: settings", HTTP 500 auf /jobs, /settings, /logs,
   /capabilities, und der SSE-Strom blieb stumm. /api/health antwortete
   trotzdem mit 200, weil die Route keine Datenbank braucht — der
   Fehlschlag sah also aus wie "laeuft". Behoben ueber einen eigenen
   lifespan, der den der Unteranwendung mit ausloest.

2. EIN print() HAT DEN GANZEN START UMGEBRACHT.
   UnicodeEncodeError: 'charmap' codec can't encode characters
   -> ERROR: Application startup failed. Exiting.
   Windows-Konsolen arbeiten mit cp1252; Rippys Meldungen sind deutsch und
   voller Umlaute und Warnzeichen. Das Bittere: Es war nicht die WARNUNG,
   die den Start verhindert hat, sondern der VERSUCH, sie auszugeben.
   Behoben mit encoding="utf-8" UND errors="replace" — eine Ausgabe darf
   unter keinen Umstaenden etwas abbrechen koennen.

BEFUND ZUR HARDWARE (kein Codefehler): Nach dem Auswurf-Test hat sich das
USB-Laufwerk vom Bus getrennt. Windows kennt es nur noch als Karteileiche
(Get-PnpDevice -Class CDROM -> Status "Unknown", Win32_CDROMDrive leer),
weder ein Laufwerksbuchstabe noch \.\CdRom0..4 sind da. Die leere
Geraeteliste ist damit die RICHTIGE Antwort. Bei bus-versorgten
USB-Laufwerken ist das bekannt — der Auswurf zieht kurz mehr Strom.

GEMESSEN: ruff sauber, 440 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:34:51 +02:00
HitonabiandClaude Opus 5 9d95c95d79 feat(drives): Windows-Treiber an echter Disc BEWIESEN
Ampel / ampel (push) Failing after 46s
WAS: Die Hardware-Schicht des Windows-Treibers ist nicht mehr nur gebaut,
sondern gemessen. Messwerte stehen im Modul-Docstring, der gemessene
BD-50-Wert als Testfall in test_cdrom.py.

GEMESSEN (28.08.2026, LG BU40N extern, Laufwerk G:, echte BD-50):

  drive_status()                 -> 4 (CDS_DISC_OK), sofort
  IOCTL_CDROM_DISK_TYPE          -> DATA_TRACK
  IOCTL_DISK_GET_LENGTH_INFO     -> 48 149 364 736 Bytes = 44,84 GiB
  disc_status()                  -> 101 (CDS_DATA_1)
  detect_disc_type()             -> 'bluray'
  device_info()['status']        -> 'ready'
  device_info()['type']          -> 'bluray'

  auswerfen_versuchen()          -> True, 2,41 s
  drive_status() davor / danach  -> 4 / 1

DIE LETZTE ZEILE IST DER EIGENTLICHE BEWEIS. Der Auswurf haengt nicht am
Rueckgabewert des Steuercodes, sondern am ZUSTAND davor und danach: Die
Disc war drin (4) und ist danach draussen (1). Genau das fehlte in v1
monatelang — dort quittierte das ioctl Erfolg, die Schublade blieb zu, und
im Log stand "Disc ausgeworfen".

Die 44,84 GiB pruefen die Schwelle gleich mit: Eine BD-50 liegt knapp unter
den 55 GiB, ab denen classify() auf 'uhd' geht, und wird korrekt als
'bluray' eingeordnet. Wer die Schwelle senkt, macht aus jeder BD-50 eine
UHD — und Rippy waehlte dann das falsche Kompressions-Preset. Der Testfall
haelt den gemessenen Wert fest.

Offen bleibt allein die Ereignis-Erkennung (WM_DEVICECHANGE) — die wird
erst gebaut.

Nebenbei: Beim Eintragen der Messwerte sind drei echte NUL-Bytes in die
Datei geraten (eine Escape-Ebene im Schreib-Skript verschluckt). Python
lehnt so eine Datei rundheraus ab ("source code string cannot contain null
bytes") — im Binaermodus repariert und gegengeprueft.

GEMESSEN: ruff sauber, 430 Tests gruen + 14 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:04:29 +02:00
HitonabiandClaude Opus 5 3d7f3b1680 feat(drives): V2-4 (Teil 2) — main.py laedt unter Windows, Treiber gemessen
Ampel / ampel (push) Failing after 46s
WAS: rippy/drives/__init__.py waehlt den Treiber fuer DIESE Maschine.
main.py und prescan.py fragen ihn, statt fest den Linux-Treiber zu
importieren. Damit ist der Blocker fuer den nativen Windows-Betrieb weg.

GEMESSEN, vorher (frische Python-3.12-Umgebung unter Windows):
  >>> import main
  ModuleNotFoundError: No module named 'fcntl'

GEMESSEN, nachher:
  main.py laedt unter Windows: JA
    Treiber:   rippy.drives.windows
    Routen:    61
    Laufwerke: ['\\.\G:']

Der Import stand in rippy/drives/linux.py -> detection.py -> fcntl. Genau
dafuer gibt es den Port: Der Aufrufer sagt WAS, nicht WIE. test_treiberwahl.py
haelt ausserdem fest, dass BEIDE Treiber denselben Satz Funktionen anbieten —
sonst faellt eine fehlende erst im Betrieb als AttributeError auf, und zwar
auf der anderen Plattform.

DER TREIBER AN ECHTER HARDWARE (LG BU40N extern, Laufwerk G:, LEER):
  GetDriveTypeW("G:\\")              -> 5 (DRIVE_CDROM)
  list_optical_devices()             -> ['\\.\G:']
  CreateFileW(r"\.\G:")             -> Handle 380
  CHECK_VERIFY2 bei leerem Laufwerk  -> ERROR_NOT_READY (21)
  drive_status()                     -> 1 (CDS_NO_DISC)
  detect_disc_type()                 -> 'no_disc'
  device_info()['status']            -> 'empty'
  MEDIA_REMOVAL (entriegeln)         -> Erfolg,   0,1 ms
  EJECT_MEDIA                        -> Erfolg, 664,8 ms

Die 664,8 ms sind der Beleg, dass wirklich etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurueck.

BEFUND, DER EIN PLACEBO VERHINDERT HAT: IOCTL_STORAGE_LOAD_MEDIA (Schublade
einziehen) antwortet auf diesem Laufwerk mit ERROR_INVALID_FUNCTION. Bei
flachen und externen Laufwerken ist das die Regel. Deshalb gibt es bewusst
KEINE einziehen()-Funktion — ein Knopf "Schublade schliessen", der auf der
Haelfte aller Laufwerke stillschweigend nichts tut, waere genau die Sorte
Placebo, die in Etappe 19 aufgeraeumt wurde.

Dazu eine DeprecationWarning behoben: '\G' im Docstring ist keine gueltige
Escape-Sequenz und waere in einer kuenftigen Python-Version ein harter Fehler.

NOCH NICHT gemessen: das Verhalten MIT eingelegter Disc (Typ-Erkennung ueber
die Groesse, und ein Auswurf, bei dem wirklich etwas drin ist).

GEMESSEN: ruff sauber, 429 Tests gruen + 13 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:55:15 +02:00
HitonabiandClaude Opus 5 b723fee3a2 feat(drives): V2-4 (Teil 1) — Windows-Laufwerkstreiber, ohne Hardware geprueft
Ampel / ampel (push) Successful in 45s
WAS: rippy/drives/windows.py mit denselben zwei Vertraegen wie die
Linux-Fassung (auswerfen_versuchen gibt False zurueck, eject wirft).
Dazu win_ioctl.py: die Win32-Steuercodes, HERGELEITET statt abgeschrieben.

WARUM ctypes und nicht pywin32: 20 MB zusaetzliche Abhaengigkeit, die in
jedes PyInstaller-Paket muss und bei jedem Python-Wechsel neu passen will.
Gebraucht werden fuenf Funktionen aus kernel32 — die spricht ctypes direkt an.

DIE STEUERCODES WERDEN AUSGERECHNET, NICHT ABGETIPPT: win_ioctl.py enthaelt
das CTL_CODE-Makro aus winioctl.h als Funktion; jede Konstante wird daraus
gebildet. test_win_ioctl.py rechnet das Ergebnis gegen die in der
Microsoft-Dokumentation stehenden Zahlen. Grund: Ein falscher IOCTL-Code
meldet kein "unbekannter Befehl", sondern ERROR_INVALID_FUNCTION — und das
liest sich wie "dieses Laufwerk kann das nicht". Man sucht dann am Geraet
statt an einer Zahl. AGENTS Regel D, ernst genommen.

DIE ZWEI PORT-REGELN GELTEN AUCH HIER:
- Auswerfen heisst entriegeln, auswerfen, NACHSEHEN. Unter Linux wurde am
  26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG
  quittiert und nichts tut. IOCTL_STORAGE_EJECT_MEDIA meldet ebenfalls nur
  die Annahme des Befehls — also wird auch hier nachgesehen
  (CHECK_VERIFY2 muss ERROR_NOT_READY liefern).
- Ein unzugaengliches Laufwerk ist UNBEKANNT, nicht leer. device_info kennt
  deshalb drei Zustaende, nicht zwei.

EIN FEHLER, DEN DIE TESTS SOFORT GEFANGEN HABEN: Win32Fehler setzte
self.winerror VOR super().__init__(). Nachgemessen: Ein zweiargumentiges
OSError.__init__(code, text) setzt winerror wieder auf None. Damit erkannte
_medium_da ERROR_NOT_READY nicht mehr, hielt jedes leere Laufwerk fuer einen
echten Fehler und meldete "unbekannt" statt "leer". Vier Tests rot, Ursache
in einer Minute gemessen statt geraten.

⚠️ WAS HIER NICHT BEWIESEN IST: dass echte Hardware sich so verhaelt. Das
Laufwerk ist derzeit nirgends angeschlossen. Der Ablauf, die
Fehlerunterscheidung und die Typ-Zuordnung sind gegen ein nachgebautes
Laufwerk geprueft (51 Tests, laufen auf jeder Plattform) — die Hardware-
Schicht gilt bis zu einer Messung als GEBAUT, nicht als BEWIESEN.

GEMESSEN: ruff sauber, 400 Tests gruen + 4 uebersprungen (vorher 378).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:45:43 +02:00
HitonabiandClaude Opus 5 4337253797 docs: SAVEPOINT v4.0-beta — V2-0 bis V2-3 stehen, Laufwerk fehlt an der VM
Ampel / ampel (push) Successful in 41s
Vier Etappen gebaut und deployt, Polling ist weg (121 Anfragen/min je Tab
-> ~2 einmalige Abrufe), SSE-Strom live belegt.

Wichtig fuer die naechste Sitzung: Das optische Laufwerk haengt NICHT mehr
an der VM. Rippy laeuft als reine Komprimier-Maschine. Und der Vorfall,
der das aufgedeckt hat, ist dokumentiert — ein devices:-Eintrag in compose
ist eine Startbedingung, und die hat api, worker und ui stillgelegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:37:23 +02:00
HitonabiandClaude Opus 5 05ab655116 fix: ein fehlendes Laufwerk darf Rippy nicht am Starten hindern
Ampel / ampel (push) Successful in 40s
WAS: Die `devices:`-Eintraege fliegen aus docker-compose.yml. Was dieser
Host wirklich hat, ermittelt deploy/geraete-override.sh und schreibt es in
docker-compose.override.yml — die zieht Compose von allein dazu, der
Befehl bleibt `docker compose up -d`.

WARUM (Vorfall heute, 28.08.2026): Das USB-Laufwerk haengt nicht mehr an
der VM. Ein ganz normaler Deploy legte daraufhin api, worker UND ui still:

  Error response from daemon: error gathering device information while
  adding custom device "/dev/sr0": no such file or directory

Ein `devices:`-Eintrag ist eine STARTBEDINGUNG — und keiner der drei
Container braucht zum STARTEN ein Laufwerk. Rippy war unten, und die
Meldung stand mitten im Build-Rauschen (dieselbe Klasse wie das geschluckte
`|| echo` beim .env-Kopieren am 26.07.).

DER WIDERSPRUCH WAR AELTER ALS DER VORFALL: install.sh sagt bei fehlendem
Laufwerk ausdruecklich "die Installation laeuft trotzdem durch; diese
Maschine dient dann als reine KOMPRIMIER-Maschine" — die Compose-Datei sah
das anders. Eine Maschine ohne Laufwerk ist ein VORGESEHENER Betriebsfall:
der Windows-Worker und der GPU-Encoder sind genau das.

Das Skript uebernimmt die Erkennung aus install.sh unveraendert: sr- und
sg-Knoten ueber die SCSI-Adresse in /sys abgeglichen, nicht geraten. Und
es schreibt die Override AUCH dann, wenn kein Laufwerk da ist — sonst
bliebe eine alte Datei mit /dev/sr0 liegen und der Fehler waere derselbe,
nur schwerer zu finden, weil sie gitignored ist und in keinem Diff auftaucht.

deploy.sh ruft es vor dem Start auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:35:48 +02:00
HitonabiandClaude Opus 5 95705c8d88 feat(ui): V2-3 (Teil 2) — das UI haengt am Ereignis-Strom, Taktgeber raus
Ampel / ampel (push) Successful in 47s
WAS: EventStreamProvider haelt EINE SSE-Verbindung fuer die ganze
Anwendung. Dashboard, Log-Kasten, Log-Seite, Laufwerksliste und
Worker-Liste beziehen ihren Zustand daraus. Sieben von neun setInterval
sind weg.

GEMESSEN AN DEN TAKTGEBERN, je offenem Tab:
  Dashboard      5 Endpunkte / 4 s   75/min  ->  0
  Log-Kasten     2 Endpunkte / 5 s   24/min  ->  0
  Laufwerke      1 Endpunkt  / 5 s   12/min  ->  0
  Log-Seite      1 Endpunkt  /10 s    6/min  ->  0 (+1 Abruf beim Oeffnen)
  Worker-Liste   1 Endpunkt  /15 s    4/min  ->  0 (+1 Abruf beim Oeffnen)
                                     ------
                                     121/min ->  ~2 einmalige Abrufe

Zwei Taktgeber bleiben bewusst: FirstRunWizard (laeuft nur VOR der
Einrichtung) und RipTargetModal (nur solange der Dialog offen ist).

DIE REGEL IST UMGEZOGEN, NICHT VERSCHWUNDEN: Ein Abriss ist keine
Aussage ueber die Welt. Der Provider BEHAELT bei einem Fehler den letzten
Stand und setzt nur `verbunden` auf false; es wird nie eine Liste geleert.
Jede Komponente uebernimmt einen Wert nur, wenn er wirklich da ist —
`devices === null` heisst "konnte nicht nachsehen", nicht "keine
Laufwerke". Das war der Fehler hinter "wird oft neu geladen".

EIN PLACEBO WENIGER: Oben rechts stand ein fest verdrahtetes "ONLINE" mit
pulsierendem Punkt — es leuchtete gruen, auch wenn die API tot war. Jetzt
zeigt es LIVE oder VERBINDUNG WEG, und im Tooltip steht, wann die letzte
Meldung kam.

DER SERVER SCHIEBT JETZT AUCH DEN SERVER-ZUSTAND: Neuer Ereignistyp
system.status (Hardware, Worker, Ablageziele) im 15-Sekunden-Takt des
Waechters — EINMAL im Server statt 15/min je Tab. Nur mitgeschickte
Schluessel werden uebernommen; ein fehlender heisst "behalte deinen Stand".

DAZU EIN FORMATFEHLER GEFUNDEN UND BEHOBEN: /logs bildet ts -> timestamp
ab, mein Snapshot lieferte die rohe DB-Zeile. Das UI haette "Invalid Date"
gezeigt — und zwar NUR im Live-Betrieb, nicht beim manuellen Neuladen.
Jetzt gibt es _log_zeile() einmal, benutzt von beiden.

UND EINEN ZWEITEN: system_lesen lief per asyncio.get_event_loop() in einem
Worker-Thread — dort ist das NICHT die laufende Schleife. Die Coroutine
waere nie gelaufen. Die Schleife wird jetzt im Startup festgehalten.

GEMESSEN: ruff sauber, 378 Tests + 3 uebersprungen, `npm run build`
durch (1650 Module). Der Live-Beweis steht noch aus — er kommt mit dem
Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:32:50 +02:00
HitonabiandClaude Opus 5 f097a0b59d feat(bus): Waechter — Worker-Aenderungen werden zu Ereignissen
Ampel / ampel (push) Successful in 43s
WAS: rippy/bus/waechter.py sieht im Takt von 1 s in der Datenbank nach und
meldet Aenderungen an den Bus: job.created / job.phase / job.progress /
job.finished und log.line. 12 Tests, ohne Datenbank lauffaehig.

WARUM ES IHN BRAUCHT: Der Bus verteilt innerhalb EINES Prozesses. Der
Worker ist aber ein eigener Prozess, im Docker-Betrieb ein eigener
Container, im verteilten Betrieb ein anderer Rechner. Ohne Bruecke wuesste
die API nichts vom Fortschritt eines Rips.

WARUM UEBER DIE DATENBANK und nicht per HTTP-Rueckruf oder Redis:
- HTTP-Rueckruf: jeder Fortschrittswert haengt an einer Verbindung, ein
  kurzer API-Neustart liesse Ereignisse verschwinden, und der Worker
  braeuchte zusaetzliche Konfiguration.
- Redis Pub/Sub: sauber, kommt im verteilten Betrieb (V2-5) — aber der
  Standalone-Betrieb hat bewusst KEIN Redis, das war der Punkt von V2-2.
- Die Datenbank ist ohnehin die Wahrheit ueber den Job-Zustand, ist in
  JEDEM Modus da, und der Worker schreibt dort sowieso hin.

"Ist das nicht wieder Polling?" Doch — aber einmal, lokal und billig:
vorher N Tabs x 9 Endpunkte alle 4-5 s ueber HTTP durch nginx durch das
Rate-Limit; jetzt EINE Abfrage pro Sekunde von vier Spalten. Das Ziel war
nie "nirgendwo nachfragen", sondern: der Browser fragt nicht mehr.

DER TEST HAT EINEN ECHTEN FEHLER GEFUNDEN: Der Docstring versprach "erst
neue Jobs, dann Aenderungen", die Schleife lieferte aber die Reihenfolge
des dicts — ein Client haette ein job.progress fuer einen Job bekommen
koennen, den er noch gar nicht kennt. Jetzt drei getrennte Durchlaeufe.
Das Versprechen war richtig, der Code nicht.

Und er stirbt nicht still: Fehler gehen ins Log UND als system.notice an
den Bus (erster Fehlschlag, danach jeder zehnte), und lebt_seit_sekunden()
macht sein Alter abfragbar — AGENTS.md, "wer einen Vorrat anlegt, macht
sein Alter abfragbar".

GEMESSEN: ruff sauber, 371 Tests gruen + 3 uebersprungen (vorher 359).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:23:03 +02:00
HitonabiandClaude Opus 5 1fbe2cfc55 fix(test): Snapshot-Tests brauchten eine Datenbank — die Ampel hat keine
Ampel / ampel (push) Successful in 40s
WAS: test_snapshot_* ersetzen den Store per monkeypatch, statt
db.list_jobs() wirklich aufzurufen.

WARUM ROT (Lauf 170): Die Ampel startet KEINE Postgres. Mein neuer Test
war der erste im ganzen Repo, der eine Datenbank angefasst hat — alle
anderen in test_api_smoke.py pruefen nur Importe und Routen. Auf der VM
und lokal waere es nie aufgefallen: dort ist eine DB da bzw. der ganze
Test wird uebersprungen (kein fcntl unter Windows).

Geprueft wird die Snapshot-LOGIK ("konnte nicht nachsehen" ergibt None,
nicht []), nicht die Datenbank. Also gehoert die Datenbank da nicht rein.

Dazu ein zweiter Test: der Snapshot muss alle vier Bereiche liefern
(jobs/workers/logs/devices) — ein Client, dem einer fehlt, muesste den
Rest raten und fiele auf Polling zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:20:58 +02:00
HitonabiandClaude Opus 5 55db9eb13f feat(api): V2-3 (Teil 1) — Ereignis-Bus und SSE-Endpunkt /api/v2/events
Ampel / ampel (push) Failing after 41s
WAS: rippy/bus mit Ereignis-Schema, In-Process-Treiber und Ringpuffer.
Dazu der SSE-Strom in der API: erst ein Snapshot, danach nur Deltas,
mit lueckenloser Wiederaufnahme ueber Last-Event-ID.

WARUM: Ein offener Tab plus ein Worker verursachen heute rund 133
Anfragen pro Minute (Dashboard 75 + Log-Kasten 24 + Laufwerke 12 +
Worker-Liste 4 + Log-Seite 6 + Tray 12). Das Rate-Limit stand einmal
UNTER dieser Zahl — daher die sich leerende Job-Liste im Sekundentakt.
Mit einer offenen Verbindung sind es null.

DREI EIGENSCHAFTEN, ALLE AUS v1-FEHLERN:

1. Der Bus traegt NUR Nachrichten ueber Aenderungen, nie den Zustand.
   Wer den Zustand will, fragt den Store. Damit kann ein verpasstes
   Ereignis auch keinen Zustand loeschen — anders als beim fuenffachen
   `catch(() => [])` im alten UI, wo jeder fehlgeschlagene Abruf
   "es gibt keine Jobs" bedeutete.
2. Eine zu grosse Luecke wird ANGESAGT, nicht verschluckt. nachliefern()
   gibt None ("hol dir ein ganzes Bild") statt [] ("nichts verpasst") —
   dieselbe Unterscheidung wie timeout-Rueckgabe 124 bei den Netzpfaden.
   Stillschweigend weiterzumachen waere schlimmer: Das UI hielte sich
   fuer aktuell und waere es nicht.
3. Der Snapshot meldet unlesbare Laufwerke als None, nicht als leere
   Liste. "Konnte nicht nachsehen" ist etwas anderes als "gibt es nicht".

Ein unbekannter Ereignistyp fliegt beim Senden HOCH statt durchzugehen.
Ein Tippfehler waere sonst der stillste aller Fehlschlaege: Nachricht
raus, kein Empfaenger, nirgends ein Hinweis.

Ein langsamer Zuhoerer (Tab im Hintergrund, lahmes Handy) bremst den
Sender nicht — er wird markiert und bekommt beim naechsten Mal einen
Snapshot. Ein Rip darf nicht auf einen Browser warten.

GEMESSEN: ruff sauber, 359 Tests gruen + 3 uebersprungen (vorher 346).
13 neue Bus-Tests laufen auf jeder Plattform; die vier SSE-Tests haengen
an main.py und laufen damit in der Ampel.

NOCH OFFEN in V2-3: das UI auf den Strom umstellen (neun setInterval)
und die Ereignisse an den Zustandsaenderungen ausloesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:09:33 +02:00
HitonabiandClaude Opus 5 d2645f87e8 feat(core): V2-2 — SQLite hinter demselben Port, Auftrags-Queue mit Lease
Ampel / ampel (push) Successful in 39s
WAS: Der Store spricht jetzt beide Datenbanken (PostgreSQL wie bisher,
SQLite fuer den Standalone-Betrieb). Dazu die LocalQueue: Auftraege als
Tabelle, Vergabe per bedingtem UPDATE, Lease statt Zombie-Jagd. Und die
Konfigurationsschicht mit EINER Praezedenz fuer alle drei Betriebsarten.

WARUM: Ohne SQLite und ohne broker-lose Queue gibt es keinen Standalone-
Betrieb — und ohne den keine Windows-App und keine Headless-Variante.
Beides haengt an dieser Etappe.

DER GRUNDSATZ (KONZEPT-V2.md §3.1): Die Datenbank ist die Wahrheit ueber
den Job-Zustand, der Broker ist nur der Wecker. Daraus folgt die ganze
Queue: Ein Auftrag ist eine Zeile mit claimed_by und lease_until, ein
Knoten uebernimmt ihn per bedingtem UPDATE (es gewinnt genau EINER, auch
wenn zehn gleichzeitig fragen), und er haelt ihn per Lease am Leben.
Laeuft die Lease ab, ist der Auftrag frei — egal ob Absturz, Netzausfall
oder gezogener Stecker.

Damit gibt es den Zustand "laeuft, aber niemand arbeitet daran" nicht
mehr, den v1 mit zombies.py (206 Zeilen + 263 Zeilen Tests) nachtraeglich
einsammeln musste. Er kann hoechstens 60 Sekunden bestehen und heilt sich
dann selbst. Beides ist geprueft: zwei Knoten bekommen NICHT denselben
Auftrag, und eine abgelaufene Lease gibt ihn wirklich wieder her.

KEINE QUEUE-BIBLIOTHEK (Taskiq/ARQ/Celery-lite), Begruendung im
Modul-Docstring: Der teure Teil ist kein Task, sondern ein 30-90-Minuten-
Subprozess mit Fortschritts-Parsing. Was die Bibliotheken loesen, ist
nicht das Problem; was das Problem ist, muss man ohnehin selbst bauen.

ABWEICHUNG VOM ENTWURF — KEIN ALEMBIC (in KONZEPT-V2.md §3.2 vermerkt):
Die Datenbank auf der VM gibt es schon, Alembic muesste sie erst
stempeln — ein Handgriff auf einer laufenden Installation, den beim
naechsten Deploy jemand vergisst. Und es gibt hier nichts zu
versionieren: drei nachgetragene Spalten. store.migrieren() fragt
stattdessen per inspect() nach und legt nur Fehlendes an; das laeuft auf
beiden Dialekten. Der alte Weg (ADD COLUMN IF NOT EXISTS) war korrekte
Postgres-Syntax und waere auf SQLite gebrochen.

EIN KONSTRUKTIONSFEHLER, SELBST GEFUNDEN: lokal.py hatte anfangs
`from rippy.store import engine` — ein Import bindet den Wert EINMAL, ein
spaeteres store.verbinden() waere nie angekommen, und das Modul haette
weiter mit der alten Datenbank gesprochen. Jetzt wird store.engine zur
Aufrufzeit gelesen; die Warnung steht an beiden Stellen im Code.

GEMESSEN: ruff sauber, 346 Tests gruen + 3 uebersprungen (vorher 301).
Neu: 30 Tests gegen ECHTES SQLite (kein Fake) — inklusive der Gegenprobe,
dass WAL und busy_timeout wirklich gesetzt sind, und dass migrieren()
eine weggenommene Spalte zurueckholt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:03:46 +02:00
HitonabiandClaude Opus 5 7558be4854 fix: CRLF in gui.py wiederherstellen (Zeilenenden-Falle in V2-1)
Ampel / ampel (push) Successful in 39s
WAS: docker/worker/gui.py war CRLF und ist beim Umstellen des db-Imports
komplett auf LF gekippt — 1520 Zeilen Diff-Rauschen fuer EINE geaenderte
Zeile. Zeilenenden zurueckgedreht, der Inhalt bleibt.

WARUM ES PASSIERT IST: Mein Umschreib-Helfer las mit
io.open(p, encoding="utf-8") — im Textmodus wandelt Python CRLF beim LESEN
zu LF — und schrieb mit newline="" zurueck, also LF. Betroffen war nur
gui.py; die uebrigen angefassten Dateien sind ohnehin LF.

Richtig waere newline="" AUCH beim Lesen gewesen (dann bleiben die
Zeilenenden im String stehen) oder gleich der Binaermodus.

Das steht so schon in den Projekt-Notizen und ist mir trotzdem passiert,
weil der Helfer generisch war und die Datei nicht danach aussah.
Gegenprobe: `file docker/worker/gui.py` sagt wieder "with CRLF line
terminators", und `git diff HEAD~2 -- gui.py` zeigt genau eine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:21 +02:00
HitonabiandClaude Opus 5 dd1d0b7365 refactor(core): V2-1 — die vier Ports, ein Store, ein Laufwerks-Treiber
Ampel / ampel (push) Successful in 40s
WAS: rippy/ports.py beschreibt Store/Queue/Bus/Drives als Protocol. Zwei
weitere Doppelungen sind zusammengelegt: db.py (API+Worker) wird
rippy/store, und der Auswurf (api/devices.py + ripping.wirf_disc_aus)
wird rippy/drives/linux. Verhalten unveraendert.

WARUM: Drei Betriebsarten tragen nur, wenn ein Modus die Auswahl der
Treiber hinter vier Nahtstellen ist statt ein eigener Codestand
(KONZEPT-V2.md §1). Diese Etappe zieht die Nahtstellen ein, ohne schon
einen zweiten Treiber zu haben — die kommen in V2-2 (SQLite/LocalQueue)
und V2-4 (Windows).

DIE UNANGENEHMERE DOPPELUNG WAR DER AUSWURF: Er stand zweimal da, mit
UNTERSCHIEDLICHEN Vertraegen — devices.eject wirft OSError, ripping.
wirf_disc_aus gibt False zurueck und wirft nie. Beides ist richtig fuer
seine Seite (Browser-Meldung gegen "ein Rip stirbt nicht an einer
klemmenden Schublade"). Jetzt liegt EINE Mechanik darunter
(auswerfen_mit_grund) und beide Vertraege unveraendert darueber.
Die API-Fassung war ausserdem NIE getestet — jetzt schon, inklusive
"reicht ENOENT/EPERM unveraendert weiter".

get_settings hatte den einzigen echten Verhaltensunterschied der beiden
db.py: die Worker-Fassung schluckte jeden Fehler und gab {} zurueck.
Nicht still entschieden, sondern sichtbar gemacht — der Parameter
bei_fehler_leer steht jetzt in der Signatur, mit der offenen Frage im
Docstring. {} heisst fuer den Aufrufer "nichts gesetzt", nicht "konnte
nicht nachsehen"; das ist dieselbe Klasse wie catch(() => []) im alten
UI. Zu entscheiden in V2-2.

ZWEITER BEINAHE-FEHLER DIESER ETAPPE: linux.py importierte detection
auf Modulebene — und das zieht fcntl. Damit waere ripping.py und ueber
es der NATIVE WINDOWS-WORKER nicht mehr ladbar gewesen. Diesmal haben
die Tests es sofort gefangen (4 Sammelfehler). Behoben an der Wurzel:
Konstanten und die reine classify() leben jetzt in drives/cdrom.py,
ganz ohne fcntl. Nebengewinn — die classify-Tests liefen bisher NUR in
der Ampel ("erst nach dem Push bewiesen") und laufen jetzt ueberall.

Ausserdem: .dockerignore-Testmuster brauchen **, sonst greifen sie nur
in der obersten Ebene. Im laufenden Container nachgezaehlt: 30 test_*.py
lagen in den Images.

GEMESSEN: ruff sauber, 301 Tests gruen + 1 uebersprungen (vorher 290;
+4 neue eject-Tests, +7 classify-Tests die jetzt lokal laufen). Kein
Modul liegt mehr doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:54:28 +02:00
HitonabiandClaude Opus 5 edfe0d4313 docs: SAVEPOINT fuer v4.0-alpha — v2-Konzept entschieden, Etappe V2-0 gruen
Ampel / ampel (push) Successful in 39s
WAS: Neuer Kopfeintrag mit dem gemessenen Zustand (b98dc5e, Ampel gruen,
290 Tests, 0 doppelte Module), den drei Commander-Entscheiden, der
gebauten Etappe V2-0 und dem naechsten Schritt.

WARUM: AGENTS-Workflow Punkt 5 — die naechste Sitzung faengt nicht bei
Null an. Besonders wichtig diesmal, weil der Deploy auf die VM bewusst
aussteht und beim ersten Deploy nach V2-0 zwei Dinge gezielt zu pruefen
sind (liegt /app/rippy in beiden Containern, enthaelt das Worker-Zip
den Ordner).

Dokumentiert ist ausserdem der Fehler, den der Umbau fast ausgeliefert
haette: ein "try/except ImportError" um den detection-Import in
tasks.py haette einen falschen Modulpfad STILL geschluckt und auf Linux
jeden Rip verweigert. Die Lehre steht dabei — bei einem Modul-Umzug
reicht grep auf Zeilenanfaenge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:41:29 +02:00
HitonabiandClaude Opus 5 b98dc5eef5 refactor(core): V2-0 — gemeinsames Paket rippy statt drei Zwillingsdateien
Ampel / ampel (push) Successful in 41s
WAS: Neues Paket src/rippy (core/drives/rip). detection.py,
makemkv_daten.py und notify.py lagen je ZWEIMAL im Repo — unter
docker/api UND docker/worker, byte-identisch. Jetzt gibt es sie einmal;
beide Container importieren dieselbe Datei. Verhalten unveraendert.

WARUM: Es gab kein geteiltes Paket zwischen den Containern, deshalb die
Kopien. docker/api/db.py sagt es im Kopfkommentar selbst: "Wer die
Struktur aendert, aendert BEIDE Dateien." Ein Waechter-Test
(test_zwillinge_sind_byteweise_identisch) hat das mechanisch
abgesichert — er war noetig, weil die Konstruktion falsch war. Erste
Etappe des v2-Plans (KONZEPT-V2.md §8.3).

IM EINZELNEN:
- src/rippy/{core/notify, drives/detection, rip/makemkv_daten}.py
- conftest.py in der Wurzel legt src/ auf den sys.path (die Ampel ruft
  pytest dort auf).
- Beide Dockerfiles kopieren src/rippy nach /app/rippy — /app ist
  Arbeitsverzeichnis und uvicorn-App-Dir, also ohne PYTHONPATH findbar.
- worker_setup_paket packt das Paket ausdruecklich mit ins Zip: die
  Schleife sah nur die oberste Ebene, ein Unterordner waere nie
  mitgekommen und der Windows-Worker beim Start gestorben.
- Die zwei Test-Dateien fuer makemkv_daten sind zu einer verschmolzen
  (die Faelle aus beiden). Damit entfaellt auch der importlib-Umweg im
  Worker-Test: der war noetig, weil bei "pytest -q" aus der Wurzel
  docker/api zuerst eingesammelt wird und jeder weitere Import nur noch
  den sys.modules-Cache trifft — die Worker-Kopie wurde also nie
  angefasst. Netto -13 doppelte Tests, +2 neue (siehe unten).

EIN FEHLER, DEN DER UMBAU FAST AUSGELIEFERT HAETTE: In tasks.py steht
der detection-Import in einem "try/except ImportError" — absichtlich,
denn der native Windows-Worker hat kein fcntl und soll trotzdem
starten. Das except verschluckt aber JEDEN ImportError, auch einen
falschen Modulpfad. Auf Linux haette der Worker ab sofort still
detect_disc_type=None gesetzt und jeden Rip verweigert, ohne dass
irgendwo ein Fehler stuende — genau die Klasse "still scheiternder
Hintergrund-Prozess" aus AGENTS.md. Gefunden ueber eine zweite Suche
mit eingerueckten Treffern.

Waechter dagegen: src/rippy/test_paket.py prueft mit
importlib.util.find_spec (fuehrt nichts aus, laeuft also auch auf
Windows ohne fcntl), dass es die drei Modulpfade wirklich gibt. Beim
Wegnehmen von detection.py gegengeprueft: Test wird rot, nach
Wiederherstellen gruen.

GEMESSEN: ruff sauber, 290 Tests gruen + 1 uebersprungen (lokal, ohne
die zwei Linux-only-Module). Kein Modul liegt noch doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:29 +02:00
HitonabiandClaude Opus 5 20675a98fa docs: Konzept fuer Rippy v2 + drei Commander-Entscheide (28.08.2026)
WAS: KONZEPT-V2.md neu — Systemarchitektur, Daten-/Queue-Strategie, die
drei Betriebsmodi, API-/Event-Design, Migrationsplan V2-0 bis V2-7.
KONZEPT.md §10 und ROADMAP.md ziehen nach.

WARUM: Rippy soll drei Betriebsarten bekommen statt einer — Docker,
native Windows-App ohne Docker, Headless-Linux-Dienst; alle mit
demselben Webinterface. Das traegt technisch nur, wenn es EINEN Kern
gibt, dessen Betriebsmodus nur die Auswahl der Treiber hinter vier
Ports ist (Store, Queue, Bus, Drives).

Drei Entscheide des Commanders sind eingearbeitet:

- Reihenfolge: Echtzeit (SSE statt Polling) VOR der Windows-App. Die
  Windows-App braucht SQLite und die lokale Queue ohnehin.
- Disc-Schluessel: automatischer Abruf MIT Rueckfallebene, als Kette in
  drei Stufen (eigener Bestand -> Abruf -> Import von Hand). Das
  verschiebt die Grenze aus KONZEPT.md §10 vom 25.07.2026 bewusst —
  deshalb steht sie dort jetzt ausdruecklich fortgeschrieben statt
  still ersetzt (AGENTS Regel B). Bezugsadresse leer vorbelegt,
  Fehlschlag laut, Bestand wird nie still ueberschrieben.
- Speicherziele: der Host mountet, Rippy prueft und erzeugt die
  kopierbare Zeile. Raeumt die URSACHE der CIFS-Ausfaelle ab (Befund
  26.07.2026: die Verbindung lebt in der Netz-Namespace des
  api-Containers und stirbt mit ihm) statt weiter das Symptom zu
  heilen. SYS_ADMIN, DAC_READ_SEARCH, apparmor:unconfined und die
  Mount-Wache fallen damit weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:03 +02:00
254 changed files with 48209 additions and 3074 deletions
+11 -1
View File
@@ -29,11 +29,21 @@ Thumbs.db
.dockerignore .dockerignore
.docker/ .docker/
# Test # Test — ** ist Pflicht, sonst greifen die Muster NUR in der obersten Ebene
# des Bau-Kontexts. Ohne ** landeten am 28.08.2026 dreissig test_*.py in beiden
# Laufzeit-Images (im laufenden Container nachgezaehlt: find /app -name
# "test_*.py" | wc -l -> 30). Schadet nichts, gehoert aber nicht ins Image.
tests/ tests/
**/tests/
test_*.py test_*.py
**/test_*.py
*_test.py *_test.py
**/*_test.py
.pytest_cache/ .pytest_cache/
**/.pytest_cache/
**/__pycache__/
conftest.py
**/conftest.py
# Build # Build
dist/ dist/
+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")
+4
View File
@@ -54,3 +54,7 @@ logs/
# Data # Data
*.db *.db
*.sqlite *.sqlite
dist/
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
design-entwuerfe/
+23
View File
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen. Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.) (Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben
**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.**
Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade
ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint
dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch
ist. An einem Tag vier Mal passiert.
falsch: "einen Ordner unter „Benutzer" wählen"
richtig: 'einen Ordner unter „Benutzer" wählen'
**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE
Anführungszeichen.**
**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein,
halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`,
und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert,
zuletzt beim Schreiben genau dieses Absatzes.
**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug
schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über
`chr(92)` zusammensetzen.
## Workflow ## Workflow
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist. 1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
+1493
View File
File diff suppressed because it is too large Load Diff
+1131
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -220,3 +220,52 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei „Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
EINDEUTIGER Zuordnung wird umbenannt). EINDEUTIGER Zuordnung wird umbenannt).
- **28.08.2026 — DISC-SCHLÜSSEL: die Grenze vom 25.07. ist verschoben
(Commander-Entscheid).** Der Eintrag vom 25.07.2026 oben sagt: *„Rippy
liefert und verteilt KEINE Disc-Schlüssel und lädt auch keine herunter."*
**Das gilt ab jetzt nicht mehr unverändert.** Auf ausdrückliche Entscheidung
des Commanders bekommt Rippy v2 einen automatischen Abruf — mit den
bisherigen Wegen als Rückfallebene. Umgesetzt als Kette in DREI Stufen, die
der Reihe nach abgearbeitet wird und anhält, sobald eine trägt:
1. **Eigener Bestand** (Schlüsselspeicher der Installation) — immer zuerst.
Gefüllt vom Windows-Knoten, der die Schlüssel über die eigene
MakeMKV-Lizenz selbst abruft (unter Linux tut `makemkvcon` das nie,
Messung 25.07.2026), und von jedem Import.
2. **Automatischer Abruf** von der konfigurierten Quelle — wenn Stufe 1
die Disc nicht kennt.
3. **Import von Hand** (`_private_data.tar`, `KEYDB.cfg` über das UI) —
unverändert aus v1.
Fünf Regeln gehören zum Entscheid dazu: (a) die Bezugsadresse steht in der
KONFIGURATION und ist LEER vorbelegt — ohne Eintrag ist Stufe 2
übersprungen; eine vorbelegte Adresse, die irgendwann tot ist, wäre genau
die Falle aus `.env.example` („lässt die Konfiguration gesund aussehen und
den Bau später scheitern"). (b) Ein funktionierender Bestand wird NIE still
überschrieben — neue Datei daneben, prüfen, dann tauschen. (c) Ein
Fehlschlag ist LAUT (kein `except: pass`, Meldung im Log und im UI).
(d) Das UI zeigt Herkunft und Alter jedes Eintrags. (e) **Rippy bringt
selbst nichts mit** — weder Installer noch Docker-Image enthalten Schlüssel
oder eine vorbelegte Bezugsadresse; was abgerufen wird, trägt der Betreiber
der Installation ein. Ausführlich in `KONZEPT-V2.md` § 7.5 und § 10.
- **28.08.2026 — SPEICHERZIELE: der Host mountet, Rippy prüft
(Commander-Entscheid).** Das Muss-Feature „NFS/Bind-Mount für Medien-Store"
(§ 6) bleibt; was wegfällt, ist das Mounten DURCH Rippy. Begründung ist der
Befund vom 26.07.2026: Die CIFS-Verbindung lebt in der Netz-Namespace des
api-Containers und stirbt mit ihm — dagegen läuft heute eine Mount-Wache.
In v2 hängt der Host ein (fstab, `.mount`-Unit oder Compose-Volume-Treiber),
und Rippy erzeugt dafür die fertige, kopierbare Zeile. Die Eingabemaske im
UI bleibt; der Knopf „Verbinden" wird zu „Zeile kopieren". Die gesamte
PRÜF-Logik aus `mounts.py` bleibt erhalten (Erreichbarkeit mit Zeitgrenze
im Kindprozess, SMB-Klartextfehler, Pfad-Map-Vorschlag). Folge:
`CAP_SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`,
`propagation: rshared` und die Mount-Wache fallen ersatzlos weg.
- **28.08.2026 — RIPPY v2: drei Betriebsmodi statt eines
(Commander-Auftrag).** Die Multi-Container-Architektur aus § 6 bleibt als
EINER von drei Modi bestehen. Dazu kommen eine native Windows-Installation
ohne Docker und eine Headless-Linux-Anwendung — beide mit demselben
Webinterface. Technisch tragen alle drei denselben Kern; ein Modus ist nur
die Auswahl der Treiber hinter vier Ports (Store, Queue, Bus, Drives).
Damit sind Postgres und Redis für Einzelinstallationen keine Pflicht mehr
(SQLite + lokale Queue), bleiben im verteilten Modus aber unverändert.
Vollständige Spezifikation: **`KONZEPT-V2.md`**, Etappen in `ROADMAP.md`.
+140
View File
@@ -570,8 +570,148 @@ heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
--- ---
## Rippy v2 — Etappen V2-0 bis V2-7 (Plan vom 28.08.2026)
**Vollständige Spezifikation: `KONZEPT-V2.md`.** Hier stehen nur die Etappen
und ihre Abnahmekriterien.
**Ziel:** drei Betriebsmodi statt eines — Docker (wie heute, aufgeräumt), eine
native Windows-App ohne Docker, eine Headless-Linux-Anwendung. Alle drei mit
demselben Webinterface, alle drei aus EINEM Kern.
**Grundregel für den ganzen Weg:** Jede Etappe endet mit grüner Ampel und einem
lauffähigen System. v1 läuft bis V2-5 produktiv weiter — auf der VM liegen echte
Medien.
### V2-0: Monorepo — ein Paket statt Zwillingen
- [ ] `src/rippy/` als gemeinsames Paket anlegen (api UND worker importieren daraus)
- [ ] Die byte-identischen Zwillinge zusammenführen: `detection.py`,
`makemkv_daten.py`, `notify.py` — je zweimal im Repo
- [ ] Tests wandern mit; `test_zwillinge_sind_byteweise_identisch` wird
gegenstandslos und weicht einem Test, der die EINE Quelle prüft
- [ ] Beide Dockerfiles kopieren `src/rippy`, `PYTHONPATH` gesetzt
- **Verhalten unverändert.** Kein Funktionsgewinn, reine Struktur.
- **Fertig, wenn:** Ampel grün, `docker compose up` verhält sich wie vorher,
kein Modul mehr doppelt im Repo.
### V2-1: Ports einziehen
- [ ] `Store`, `Queue`, `Bus`, `Drives` als Python-`Protocol`
- [ ] v1-Verhalten läuft über die Treiber Postgres / Celery / Redis / Linux
- [ ] Kein Funktionsgewinn — reine Verdrahtung
- **Fertig, wenn:** Ampel grün und ein Rip auf der VM durchläuft.
### V2-2: Standalone (SQLite + lokale Queue)
- [ ] SQLite-Treiber mit WAL, `busy_timeout`, ein Schreiber-Kontext
- [ ] Alembic statt handgeschriebener `ALTER TABLE … IF NOT EXISTS`
(das ist Postgres-only und bricht auf SQLite)
- [ ] LocalQueue: Auftrags-Tabelle + Lease + Prozesspool, kein Broker
- [ ] Lease ersetzt `zombies.py` — abgelaufene Lease = Auftrag ist frei
- [ ] `rippyd --profil standalone`
- **Fertig, wenn:** ein DVD-Rip komplett ohne Postgres, Redis und Docker läuft.
### V2-3: Echtzeit — Polling raus
- [ ] Bus-Treiber (asyncio in-process / Redis Pub/Sub)
- [ ] `GET /api/v2/events` als SSE-Strom, `snapshot` beim Verbinden,
lückenlose `seq` für Wiederaufnahme
- [ ] UI auf einen `useEventStream`-Haken; die neun `setInterval` fliegen raus
- [ ] `test_grenze_deckt_die_eigene_last_ab` auf die neuen Zahlen ziehen
- **Fertig, wenn:** Grundlast ≈ 0/min gemessen (heute ≈ 133/min bei einem Tab
plus Worker) UND ein Verbindungsabriss keine Liste leert.
### V2-4: Windows nativ
- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
MMC `GET CONFIGURATION` für den Disc-Typ)
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
vorerst über den Poll aus `drives/detection.py`
- [x] Dienst + Fenster als GETRENNTE Prozesse; **WebView2-Fenster**
(`fenster.py`, Entscheid 4 in `KONZEPT-V2.md` § 10)
- [x] Eintrag in „Programme und Features" und im Taskmanager als `Rippy.exe`
(ausdrücklicher Wunsch des Commanders)
- [x] Desktop-Symbol und Startmenü-Eintrag, im Setup abschaltbar
- [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`,
`tools/beschaffen.py`) — Rippy findet, holt und aktualisiert
HandBrake/MakeMKV selbst
- [x] **Das Setup richtet die Werkzeuge wirklich ein** (`tools/einrichten.py`,
Entscheid 5) — HandBrakeCLI liegt im Paket (GPL-2), MakeMKV wird beim
Einrichten vom Hersteller geholt. Ein Setup, dem ein Pflichtwerkzeug
fehlt, meldet das im Klartext statt Erfolg.
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
ein leeres Blatt
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
Doppelklick stillschweigend und öffnete das Fenster.
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
behält alles.
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
**offen**
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
**Noch offen:** ein echter Rip auf Windows ist ungeprüft — das Laufwerk
hängt an der VM.
> **Abweichung vom Plan, bewusst und mit Folgen.** Statt Nuitka `--standalone`
> (one-dir) + Inno Setup wurde **PyInstaller onefile** genommen: ein Programm,
> drei Betriebsarten (`RippySetup.exe`, `--dienst`, `--deinstallieren`), kein
> zweites Werkzeug in der Kette.
>
> Der Preis stand oben schon im Plan — *„ein Onefile-Paket entpackt sich bei
> jedem Start neu nach `%TEMP%`"* — und er ist am 28.08.2026 fällig geworden:
> Ein Dienst lief eine Stunde, `/api/health` gab 200, `/` gab **404**. Vom
> Entpack-Verzeichnis des laufenden Prozesses waren 31 statt 44 Einträge übrig,
> `ui` und `api` fehlten. Die API meldete sich gesund, während die Oberfläche
> weg war.
>
> **Gegenmaßnahme:** Der Installer legt die Oberfläche als Kopie NEBEN das
> Programm (`windows_app.ui_auspacken`), und `daemon._ui_pfad()` nimmt diese
> Kopie vor dem Entpack-Verzeichnis. Zwei Tests halten das fest. Sollte sich
> derselbe Ausfall an den API-Modulen zeigen, ist one-dir die richtige Antwort
> — dann ist diese Abweichung zurückzunehmen.
### V2-5: Docker neu
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
- [ ] GPU-Durchreichung: `/dev/dri` für QSV/VAAPI, `runtime: nvidia` für NVENC
- [ ] **Host-Mounts statt Container-Mounts** (Entscheid 28.08.2026)
- [ ] Multi-Arch; ARM64 ist Encode-/API-Knoten (MakeMKV hat kein ARM64-Binary)
- **Fertig, wenn:** All-in-One und verteilt laufen und `SYS_ADMIN`,
`DAC_READ_SEARCH`, `apparmor:unconfined` weg sind.
### V2-6: CLI & Pakete
- [ ] `rippy status / drives / scan / rip / queue / logs --follow / doctor`
- [ ] `rippy doctor` = die Prüfphase aus `install.sh` als Befehl (erst alles
prüfen, dann berichten, nichts ändern)
- [ ] systemd-Unit mit `SupplementaryGroups=cdrom` und `DeviceAllow` für
block-sr UND char-sg (ohne sg findet MakeMKV kein Laufwerk)
- [ ] udev-Regel für echte Disc-Ereignisse; ioctl-Poll bleibt Rückfallebene
- [ ] `.deb` / AppImage
- **Fertig, wenn:** ein Headless-Server ohne Browser bedienbar ist.
### V2-7: Neue Features
- [ ] Multi-Drive Parallel-Ripping (Rip parallel, **Schlüssel-Phase
serialisiert** — vorher an zwei Laufwerken MESSEN, nicht annehmen)
- [ ] Zero-Click gegen Interaktiv, je Laufwerk und Disc-Typ
- [ ] Auto-Presets nach gemessener Hardware (Vorschlag, kein Zwang)
- [ ] Ton-/Untertitel-Regelwerk (Originalton, Wunschsprachen, erzwungene
Untertitel behalten, Kommentarspuren verwerfen, HD-Ton durchreichen)
- [ ] **Schlüsselkette in drei Stufen** (Entscheid 28.08.2026, `KONZEPT.md` § 10)
- [ ] Anime über AniList zusätzlich zu Jikan
- [ ] Medienserver-Refresh als Auftrag MIT Wiederholung (heute still scheiternd)
- [ ] FFmpeg-Direktpfad für reines Remuxen
- **Fertig, wenn:** je Feature ein Nachweis vorliegt.
---
## Ideen-Katalog (Rest) — bewusst offen ## Ideen-Katalog (Rest) — bewusst offen
> **Stand 28.08.2026:** Punkt 3 (Kodi-Refresh) und Punkt 4 (Windows-Worker als
> Dienst) sind in den v2-Plan aufgegangen — Punkt 4 ist V2-4, Punkt 3 steckt in
> V2-7 („Medienserver-Refresh"). Punkt 2 (AI-Box als Transcode-Worker) wird von
> V2-5 abgedeckt, sobald der Host-Mount-Weg steht.
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges 1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0` Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428 (deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
+1601 -1
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
überhaupt vorhanden?
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
```
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
Kern beendet mit 0
```
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
Messung zuvor ebenfalls grün.)
Nachstellen:
```
npm install electron@44 --no-save
node node_modules/electron/install.js
./node_modules/electron/dist/electron.exe sqlite-main.js
```
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
---
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
Tages jemand an der falschen Stelle.
+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

+22
View File
@@ -0,0 +1,22 @@
"""Pytest-Setup für das ganze Repo: das gemeinsame Paket `rippy` findbar machen.
Die Ampel ruft `pytest -q` in der Repo-Wurzel auf (.gitea/workflows/ci.yml).
Ohne diesen Eintrag scheitert JEDER Import von `rippy.*` — das Paket liegt
unter `src/`, und `src/` steht in keinem Standardpfad.
Warum `src/` und nicht `rippy/` direkt in der Wurzel: Sonst würde `pytest`
beim Einsammeln in dasselbe Verzeichnis schauen, in dem auch `docker/` liegt,
und ein zufällig gleichnamiges Modul könnte gewinnen. Ein eigenes Quellen-
Verzeichnis macht die Grenze eindeutig.
Die beiden conftest.py unter docker/api und docker/worker bleiben bestehen —
sie machen die dortigen FLACHEN Modul-Importe möglich (`import ripping`,
`import db`). Beide Mechanismen greifen nebeneinander.
"""
import os
import sys
_QUELLEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
if _QUELLEN not in sys.path:
sys.path.insert(0, _QUELLEN)
+12
View File
@@ -95,6 +95,18 @@ fi
sed -i '/^RIPPY_VERSION=/d' .env sed -i '/^RIPPY_VERSION=/d' .env
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
# Geraete dieses Hosts ermitteln, BEVOR gestartet wird.
# Ohne diesen Schritt haengt der Start an einem `devices:`-Eintrag, den es
# vielleicht nicht mehr gibt — am 28.08.2026 legte genau das die ganze
# Installation stillt, weil das USB-Laufwerk abgezogen war (Herleitung im Kopf
# von docker-compose.yml). Das Skript schreibt die Override immer neu, auch
# wenn kein Laufwerk da ist.
if [ -x ./deploy/geraete-override.sh ]; then
./deploy/geraete-override.sh .
else
echo "WARNUNG: deploy/geraete-override.sh fehlt — starte ohne Geraete-Erkennung."
fi
docker compose -p rippy up -d --build $DIENST docker compose -p rippy up -d --build $DIENST
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}' docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
REMOTE REMOTE
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
#
# Erzeugt docker-compose.override.yml mit den optischen Laufwerken DIESES Hosts.
#
# ## Warum es das gibt (Vorfall 28.08.2026)
#
# In docker-compose.yml stand:
#
# devices:
# - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
#
# Das ist eine STARTBEDINGUNG. Fehlt das Laufwerk, startet der Container nicht:
#
# Error response from daemon: error gathering device information while
# adding custom device "/dev/sr0": no such file or directory
#
# Genau das ist passiert, als das USB-Laufwerk von der VM abgezogen wurde: Ein
# ganz normaler Deploy legte api, worker UND ui still — obwohl keiner der drei
# ein Laufwerk zum Starten braucht. Rippy war unten, und die Meldung stand
# mitten im Build-Rauschen.
#
# Der Widerspruch war schon vorher da: `install.sh` sagt bei fehlendem Laufwerk
# ausdrücklich „die Installation läuft trotzdem durch; diese Maschine dient dann
# als reine KOMPRIMIER-Maschine" — die Compose-Datei sah das anders. Eine
# Maschine ohne Laufwerk ist ein vorgesehener Betriebsfall (der Windows-Worker
# und der GPU-Encoder sind genau das).
#
# Seither steht in docker-compose.yml KEIN devices-Block mehr. Was dieser Host
# wirklich hat, landet in docker-compose.override.yml — die zieht Compose von
# allein dazu (`docker compose up` braucht keinen extra Schalter), und sie ist
# gitignored, weil die Geräteknoten je Rechner anders heißen.
#
# Aufruf: ./deploy/geraete-override.sh [zielverzeichnis]
# (ohne Argument: das aktuelle Verzeichnis)
#
set -u
ZIEL="${1:-.}"
DATEI="$ZIEL/docker-compose.override.yml"
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht deshalb
# ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer dazugehört,
# ist je Host anders — sie wird über die SCSI-Adresse ermittelt statt geraten.
# Beide Knoten zeigen in /sys auf dasselbe Geräteverzeichnis (z. B. 3:0:0:0).
# (Dieselbe Herleitung wie in install.sh, Prüfung 2/5.)
SR=""
SG=""
LAUFWERK=""
for srpfad in /sys/block/sr*; do
[ -e "$srpfad" ] || continue
srname=$(basename "$srpfad")
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
[ -n "$adresse" ] || continue
for sgpfad in /sys/class/scsi_generic/sg*; do
[ -e "$sgpfad" ] || continue
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
SR="/dev/$srname"
SG="/dev/$(basename "$sgpfad")"
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
break 2
fi
done
done
kopf() {
cat <<KOPF
# ERZEUGT von deploy/geraete-override.sh — nicht von Hand ändern.
# Diese Datei ist gitignored: Die Geräteknoten heißen auf jedem Rechner anders.
# Neu erzeugen nach jedem An- oder Abstecken eines Laufwerks:
# ./deploy/geraete-override.sh && docker compose up -d
KOPF
}
if [ -z "$SR" ]; then
# KEIN Laufwerk. Wichtig: trotzdem eine gültige Datei schreiben und die alte
# damit überschreiben. Bliebe eine alte Override mit /dev/sr0 liegen, wäre der
# Fehler beim nächsten Start genau derselbe — nur schwerer zu finden, weil die
# Datei gitignored ist und in keinem Diff auftaucht.
{
kopf
echo "#"
echo "# Auf diesem Host wurde KEIN optisches Laufwerk gefunden."
echo "# Rippy läuft damit als reine Komprimier-Maschine — das ist ein"
echo "# vorgesehener Betriebsfall, kein Fehler. Zum Rippen das Laufwerk"
echo "# anstecken (in einer VM: USB-Passthrough) und dieses Skript erneut"
echo "# aufrufen."
echo "services: {}"
} > "$DATEI"
echo "Kein optisches Laufwerk gefunden — $DATEI ohne Geräte geschrieben."
echo "Rippy startet und kann komprimieren; Rippen geht erst mit Laufwerk."
exit 0
fi
{
kopf
echo "#"
echo "# Gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
echo "# $SR + $SG (über die SCSI-Adresse abgeglichen, nicht geraten)"
cat <<YAML
services:
api:
devices:
- $SR:/dev/sr0
worker:
devices:
- $SR:/dev/sr0
- $SG:/dev/sg1
YAML
} > "$DATEI"
echo "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
echo " $SR + $SG -> $DATEI"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+24 -12
View File
@@ -3,7 +3,24 @@
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices: # Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den # eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert # Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve). # mit EPERM.
#
# ⚠️ ABER NICHT HIER (Vorfall 28.08.2026): Ein `devices:`-Eintrag ist eine
# STARTBEDINGUNG. Als das USB-Laufwerk von der VM abgezogen wurde, legte ein
# ganz normaler Deploy api, worker UND ui still — obwohl keiner der drei zum
# Starten ein Laufwerk braucht:
#
# Error response from daemon: error gathering device information while
# adding custom device "/dev/sr0": no such file or directory
#
# Der Widerspruch war älter: install.sh sagt bei fehlendem Laufwerk
# ausdrücklich „läuft trotzdem durch, dann ist das eine reine
# KOMPRIMIER-Maschine" — diese Datei sah das anders.
#
# Deshalb stehen die Geräte jetzt in docker-compose.override.yml, erzeugt von
# `deploy/geraete-override.sh`. Compose zieht die Override von allein dazu;
# `docker compose up -d` bleibt derselbe Befehl. Nach jedem An- oder Abstecken
# eines Laufwerks das Skript erneut aufrufen.
services: services:
api: api:
@@ -54,8 +71,7 @@ services:
- temp:/app/temp - temp:/app/temp
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort). # MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data - ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
devices: # devices: siehe Kopf — kommen aus docker-compose.override.yml
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
@@ -118,15 +134,11 @@ services:
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die # Ohne diesen Mount löschte JEDER `up -d --build` beides; die
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab. # Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV - ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
devices: # devices: siehe Kopf dieser Datei — die optischen Knoten dieses Hosts
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS! # stehen in docker-compose.override.yml (deploy/geraete-override.sh).
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den # MakeMKV braucht BEIDE: /dev/srN und den passenden /dev/sgM; welche
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten # sg-Nummer dazugehoert, ist je Host anders und wird dort ueber die
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen. # SCSI-Adresse abgeglichen statt geraten.
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
+9
View File
@@ -17,10 +17,19 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ . COPY docker/api/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den API-Modulen. /app ist Arbeitsverzeichnis und uvicorn-App-Dir,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an # Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) — # native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker. # die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/ COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
# Der Worker importiert seit V2-0 aus dem gemeinsamen Paket (tasks.py, caps.py).
# Ohne diese Zeile fehlte es im Zip und der Windows-Worker stürbe beim Start
# mit ModuleNotFoundError — worker_setup_paket packt genau diesen Ordner mit ein.
COPY src/rippy worker_dist/rippy
COPY deploy/worker-windows/installer.py worker_dist/ COPY deploy/worker-windows/installer.py worker_dist/
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop # Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die # (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
+98 -18
View File
@@ -1,42 +1,122 @@
"""Cache module.""" """Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt.
## Warum das nicht mehr scheitern darf (Befund 28.08.2026)
Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis`
der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem
Windows-PC gibt es kein Redis, und jeder Aufruf endete mit:
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache:
Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an.
**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist
keiner da, wird eben jedes Mal neu gefragt TMDB und OMDb halten das aus.
Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als
wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es
nicht.
## Warum trotzdem einmal gemeldet wird
Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare
Verlangsamung und auf der VM wäre ein weggefallenes Redis ein echter
Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene
Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre
Lärm.
"""
from redis import Redis
from typing import Any, Optional
import json import json
import os import os
from typing import Any, Optional
from redis import Redis
from redis.exceptions import RedisError
redis_host = os.getenv("REDIS_HOST", "redis") redis_host = os.getenv("REDIS_HOST", "redis")
redis_port = int(os.getenv("REDIS_PORT", "6379")) redis_port = int(os.getenv("REDIS_PORT", "6379"))
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True) # Kurze Zeitgrenzen: Ein Zwischenspeicher, auf den man wartet, ist keiner.
# Ohne sie hing jeder Aufruf am Standard-Timeout der Bibliothek.
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True,
socket_connect_timeout=1.5, socket_timeout=1.5)
# Erst beim ersten Zugriff bekannt. None = noch nichts versucht.
_erreichbar: Optional[bool] = None
def _melde_einmal(fehler: Exception) -> None:
"""Beim ERSTEN Fehlschlag eine Zeile, danach Ruhe."""
global _erreichbar
if _erreichbar is False:
return
_erreichbar = False
print("Zwischenspeicher (%s:%s) nicht erreichbar — es wird ohne "
"gearbeitet: %s" % (redis_host, redis_port, fehler))
def zustand() -> dict:
"""Woran man ist. Für Diagnose und Selbstauskunft."""
return {"host": redis_host, "port": redis_port, "erreichbar": _erreichbar}
def init_cache() -> None: def init_cache() -> None:
"""Initialize cache connection.""" """Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie."""
pass global _erreichbar
try:
redis_client.ping()
_erreichbar = True
except (RedisError, OSError) as e:
_melde_einmal(e)
def get(key: str) -> Optional[Any]: def get(key: str) -> Optional[Any]:
"""Get value from cache.""" """Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher."""
value = redis_client.get(key) global _erreichbar
try:
value = redis_client.get(key)
except (RedisError, OSError) as e:
_melde_einmal(e)
return None
_erreichbar = True
if value: if value:
return json.loads(value) try:
return json.loads(value)
except (TypeError, ValueError):
# Ein kaputter Eintrag ist kein Grund, den Aufrufer scheitern zu
# lassen — er holt sich die Antwort dann eben frisch.
return None
return None return None
def set(key: str, value: Any, expire: Optional[int] = None) -> bool: def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
"""Set value in cache.""" """Wert ablegen. False heißt: ging nicht — und das ist in Ordnung."""
serialized = json.dumps(value) global _erreichbar
if expire: try:
return redis_client.setex(key, expire, serialized) serialized = json.dumps(value)
return redis_client.set(key, serialized) if expire:
return bool(redis_client.setex(key, expire, serialized))
return bool(redis_client.set(key, serialized))
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
except (TypeError, ValueError):
# Nicht serialisierbar. Das ist ein Fehler des Aufrufers, aber keiner,
# der eine Metadaten-Abfrage umwerfen darf.
return False
def delete(key: str) -> bool: def delete(key: str) -> bool:
"""Delete value from cache.""" try:
return redis_client.delete(key) return bool(redis_client.delete(key))
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
def clear() -> bool: def clear() -> bool:
"""Clear all cache.""" try:
return redis_client.flushdb() return bool(redis_client.flushdb())
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
+98 -9
View File
@@ -2,16 +2,105 @@
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke. kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
Import von `main.py` ein funktionierendes Celery auch dann, wenn nie ein Rip
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
Start:
File "celery_client.py", ...
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
ModuleNotFoundError: No module named 'celery.fixups'
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
**Was das für den Windows-Betrieb bedeutet ehrlich gesagt:** Die Oberfläche,
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
""" """
import os import os
from celery import Celery
from celery.utils import worker_direct
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) _client = None
# Wer Auftraege zustellt. None = Celery (verteilter Betrieb).
# Der Standalone-Betrieb setzt hier seine eigene Zustellung ein — siehe
# `zusteller_setzen()`.
_zusteller = None
class KeinBroker(RuntimeError):
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
def hole_client():
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
global _client
if _client is None:
try:
from celery import Celery
except ImportError as e:
raise KeinBroker(
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
"Broker ist damit nicht möglich (Umstellung auf die lokale "
"Auftrags-Queue: Etappe V2-5)."
) from e
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
return _client
def __getattr__(name):
"""`celery_client` von außen — baut den Client bei Bedarf.
Damit bleiben die bestehenden `from celery_client import celery_client`
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
"""
if name == "celery_client":
return hole_client()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def zusteller_setzen(funktion) -> None:
"""Legt fest, WER Auftraege bekommt.
Ohne Aufruf geht alles an Celery das ist der Docker-Betrieb, unveraendert.
Der Standalone-Betrieb (Windows-App, Headless-Linux) setzt hier seine
lokale Auftrags-Queue ein; dann laeuft die Arbeit im selben Prozess.
Die Unterschrift ist die von `abschicken`:
funktion(task_name, args, queue=None) -> irgendetwas
Das ist bewusst dieselbe Form, die Celery hat. So muss keine Aufrufstelle
wissen, in welchem Betrieb sie gerade laeuft.
"""
global _zusteller
_zusteller = funktion
def abschicken(task_name: str, args: list, queue: str = None):
"""Einen Auftrag zustellen — an Celery oder an die lokale Queue.
EINE Stelle fuer alle drei Auftragsarten (rip_disc, transcode_files,
scan_tracks). Vorher rief jede Aufrufstelle `send_task` selbst auf; damit
haette der Standalone-Betrieb an drei Stellen umgebogen werden muessen
und beim naechsten Auftragstyp an einer vierten.
"""
if _zusteller is not None:
return _zusteller(task_name, args, queue)
kwargs = {"args": args}
if queue:
kwargs["queue"] = queue
return hole_client().send_task(task_name, **kwargs)
def transcode_queue(node: str = None): def transcode_queue(node: str = None):
@@ -25,7 +114,9 @@ def transcode_queue(node: str = None):
if not node: if not node:
return "transcode" return "transcode"
try: try:
antworten = celery_client.control.ping(timeout=1.0) or [] from celery.utils import worker_direct
antworten = hole_client().control.ping(timeout=1.0) or []
online = {k for antwort in antworten for k in antwort.keys()} online = {k for antwort in antworten for k in antwort.keys()}
if node in online: if node in online:
return worker_direct(node) return worker_direct(node)
@@ -35,7 +126,5 @@ def transcode_queue(node: str = None):
def start_rip(device_path: str, job_id: str, target_dir: str = None): def start_rip(device_path: str, job_id: str, target_dir: str = None):
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" """Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
return celery_client.send_task( return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
)
+7 -2
View File
@@ -33,8 +33,13 @@ def parse_year(year: str) -> Optional[int]:
class OMDbClient: class OMDbClient:
def __init__(self): def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings # ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key # gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
# "from rippy import store as db", dieses Modul hat es nie
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("omdbApiKey") or settings.omdb_api_key
self.session = requests.Session() self.session = requests.Session()
def suche(self, title: str) -> list: def suche(self, title: str) -> list:
+7 -2
View File
@@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
class TheTVDBClient: class TheTVDBClient:
def __init__(self): def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings # ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key # gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
# "from rippy import store as db", dieses Modul hat es nie
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("tvdbApiKey") or settings.thetvdb_api_key
self.base_url = THETVDB_BASE_URL self.base_url = THETVDB_BASE_URL
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({
+14 -2
View File
@@ -24,8 +24,20 @@ class TMDBClient:
def __init__(self): def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable — # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.). # vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
from db import get_settings # ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key #
# Hier stand "from db import get_settings". docker/api/db.py gibt es
# seit der Zusammenlegung in V2-1 (dd1d0b7) nicht mehr — main.py
# schreibt seitdem "from rippy import store as db", aber dieses Modul
# hat das nie mitbekommen. Die Folge: JEDE Metadaten-Abfrage starb
# schon beim Erzeugen des Clients mit ModuleNotFoundError, und
# _auto_prescan verschluckte das in seinem "except Exception". Discs
# blieben namenlos — auf Windows UND auf der VM.
#
# bei_fehler_leer=True: Ohne Datenbank gilt der Schlüssel aus der
# Umgebung. Ein Metadaten-Client ist kein Grund, warum nichts geht.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("tmdbApiKey") or settings.tmdb_api_key
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"}) self.session.headers.update({"Content-Type": "application/json"})
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als # Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
-284
View File
@@ -1,284 +0,0 @@
"""Job-, Log- und Settings-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
Die jobs/logs-Tabellendefinition existiert bewusst identisch im Worker
(docker/worker/db.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
Bis 23.07. lag Postgres komplett brach: GET /jobs gab hart [] zurück, nichts
schrieb je eine Zeile der Job-Verlauf im UI war ein Placebo.
"""
import json
import os
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
select,
)
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
jobs = Table(
"jobs",
metadata,
Column("id", String(36), primary_key=True),
Column("disc_type", String(16)),
Column("device", String(64)),
Column("title", String(255)),
Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
logs = Table(
"logs",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", DateTime(timezone=True)),
Column("level", String(16)),
Column("source", String(32)),
Column("message", Text),
)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
storage_mounts = Table(
"storage_mounts",
metadata,
Column("name", String(64), primary_key=True),
Column("typ", String(8)), # nfs | cifs
Column("quelle", String(255)), # host:/export bzw. //host/share
Column("optionen", String(255)),
Column("username", String(128)),
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
)
def list_workers() -> list:
import json
with engine.connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = []
for z in zeilen:
eintrag = dict(z)
try:
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
except ValueError:
eintrag["encoders"] = []
try:
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
except ValueError:
eintrag["info"] = {}
if eintrag.get("last_seen"):
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
ergebnis.append(eintrag)
return ergebnis
def list_mounts() -> list:
with engine.connect() as conn:
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
with engine.begin() as conn:
conn.execute(
storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle,
optionen=optionen, username=username, passwort=passwort,
)
)
def delete_mount(name: str) -> None:
with engine.begin() as conn:
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
metadata.create_all(engine)
# create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen:
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None,
) -> None:
with engine.begin() as conn:
conn.execute(
jobs.insert().values(
id=job_id,
device=device,
disc_type=disc_type,
title=title,
target_dir=target_dir,
meta=meta,
status="pending",
progress=0,
created_at=utcnow(),
)
)
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def get_job(job_id: str) -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
).mappings().all()
return [dict(z) for z in zeilen]
def delete_job(job_id: str) -> None:
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
with engine.begin() as conn:
conn.execute(jobs.delete().where(jobs.c.id == job_id))
def delete_finished_jobs() -> int:
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
with engine.begin() as conn:
ergebnis = conn.execute(
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
)
return ergebnis.rowcount or 0
def delete_worker(name: str) -> None:
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
with engine.begin() as conn:
conn.execute(workers.delete().where(workers.c.name == name))
def hat_arbeit() -> bool:
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
`umount -l` mitten in einem laufenden Rip oder Encode wäre das ein
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
anlaufen.
"""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.status.in_(
("pending", "running", "ripping", "transcoding", "canceling")))
.limit(1)
).first()
return zeile is not None
def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.device == device)
.where(jobs.c.status.in_(("pending", "running")))
.limit(1)
).first()
return zeile is not None
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
def list_logs(limit: int = 200) -> list:
with engine.connect() as conn:
zeilen = conn.execute(
select(logs).order_by(logs.c.id.desc()).limit(limit)
).mappings().all()
return [dict(z) for z in zeilen]
def get_settings(key: str = "ui") -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if not zeile or not zeile[0]:
return {}
try:
return json.loads(zeile[0])
except ValueError:
return {}
def save_settings(werte: dict, key: str = "ui") -> None:
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
-110
View File
@@ -1,110 +0,0 @@
"""Laufwerks-Discovery über /sys und ioctls — ehrlich, ohne udev.
Warum kein udevadm mehr: Im Container läuft kein udevd, die udev-Datenbank ist
leer `udevadm info` lieferte schlicht nichts (der alte Weg zeigte deshalb nie
ein Laufwerk an). /sys/class/block/<name>/device/{vendor,model} kommt dagegen
direkt vom Kernel und funktioniert überall.
"""
import glob
import os
from fcntl import ioctl
from detection import (
CDS_DISC_OK,
classify,
disc_size_bytes,
disc_status,
drive_status,
)
# include/uapi/linux/cdrom.h
CDROMEJECT = 0x5309
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
CDROM_DRIVE_STATUS = 0x5326
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
AUSWURF_WARTEN_SEKUNDEN = 5
def eject(device_path: str) -> None:
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
und tut nichts. MakeMKV verriegelt die Tür während des Rips
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder danach blieb die
Schublade zu, während Rippy Disc ausgeworfen" ins Log schrieb. Gegenprobe
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
Deshalb macht das Werkzeug `eject` immer beides.
Gleichlautend in worker/ripping.wirf_disc_aus es gibt kein geteiltes Paket
zwischen den Containern; dort steht die ausführliche Herleitung.
"""
import time
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
try:
ioctl(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
ioctl(fd, CDROMEJECT, 0)
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
# Slot-Laufwerk hat keine Schublade.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
return
time.sleep(1)
raise OSError(
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
)
finally:
os.close(fd)
def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
return sorted(glob.glob("/dev/sr[0-9]*"))
def read_sys_attr(device_name: str, attr: str) -> str:
pfad = f"/sys/class/block/{device_name}/device/{attr}"
try:
with open(pfad, encoding="ascii", errors="replace") as f:
return f.read().strip()
except OSError:
return ""
def device_info(device_path: str) -> dict:
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl."""
name = os.path.basename(device_path)
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
try:
status_code = drive_status(device_path)
except OSError:
status_code = -1
disc_type = "unknown"
status = "empty"
if status_code == CDS_DISC_OK:
status = "ready"
try:
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
disc_type = "unknown"
return {
"id": name,
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
"type": disc_type,
"path": device_path,
"status": status,
"model": model,
"serial": read_sys_attr(name, "wwid"),
}
+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)}
+1128 -105
View File
File diff suppressed because it is too large Load Diff
-375
View File
@@ -1,375 +0,0 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk LibreDrive v06.3 läuft und
MakeMKV liest die Disc sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+3 -3
View File
@@ -50,7 +50,7 @@ def fetch_current_key(timeout: int = 15) -> str | None:
def _apply_key(key: str) -> bool: def _apply_key(key: str) -> bool:
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON, """Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat.""" darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
import db from rippy import store as db
einstellungen = db.get_settings() einstellungen = db.get_settings()
if (einstellungen.get("makemkvAppKey") or "").strip() == key: if (einstellungen.get("makemkvAppKey") or "").strip() == key:
return False return False
@@ -61,7 +61,7 @@ def _apply_key(key: str) -> bool:
async def refresh_once() -> bool: async def refresh_once() -> bool:
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert.""" """Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
import db from rippy import store as db
key = await asyncio.to_thread(fetch_current_key) key = await asyncio.to_thread(fetch_current_key)
if not key: if not key:
db.add_log("warning", "makemkv-key", db.add_log("warning", "makemkv-key",
@@ -77,7 +77,7 @@ async def refresh_once() -> bool:
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None: async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst """Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter.""" wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
import db from rippy import store as db
await asyncio.sleep(start_verzoegerung_s) await asyncio.sleep(start_verzoegerung_s)
while True: while True:
try: try:
+7 -3
View File
@@ -12,11 +12,13 @@ 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
import db from rippy import store as db
MEDIA_ROOT = "/app/media" MEDIA_ROOT = "/app/media"
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$") NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
@@ -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:
+180 -55
View File
@@ -6,11 +6,11 @@ lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
""" """
import os import os
import shutil
import subprocess import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
import detection from rippy import drives as _laufwerks_schicht
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
@@ -19,6 +19,19 @@ from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set from cache import get, set as cache_set
from cache.keys import generate_prescan_key from cache.keys import generate_prescan_key
# Der Treiber DIESER Maschine statt fest der Linux-Fassung — sonst waere
# dieses Modul (und ueber es main.py) unter Windows nicht ladbar. Beide
# Treiber bieten disc_size_bytes() und detect_disc_type() an; darauf achtet
# test_treiberwahl.py.
detection = _laufwerks_schicht.treiber()
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
# Neuauflagen und Regie-Fassungen desselben Films.
WENIGE_TREFFER = 3
def normalize_disc_label(label: str) -> str: def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion). """Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion).
@@ -74,50 +87,95 @@ def parse_udf_dstring(data: bytes) -> str:
return "" return ""
def disc_wurzel(device_path: str):
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
## Warum das getrennt ist (28.08.2026)
Der Weg hier war fest `mount -t udf` also Linux. Unter Windows haengt
Windows die Disc selbst ein: `\.\G:` entspricht `G:\`, und dort liegen
die Dateien einfach da. Ohne diese Unterscheidung fand Rippy unter
Windows NIE den Klartext-Titel und blieb beim Volume-Label haengen
(gemessen an einer echten Disc: `BD_EVG_D2` statt `Evangelion 2.22`) --
und mit so einem Label findet keine Metadaten-API etwas.
"""
if os.name == "nt":
# \.\G: -> G:\ (der Buchstabe ist alles, was wir brauchen)
buchstabe = device_path.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
wurzel = buchstabe + ":" + "\\"
return (wurzel, lambda: None) if os.path.isdir(wurzel) else (None, lambda: None)
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER,
)
if ergebnis.returncode != 0:
return None, lambda: None
def abhaengen():
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
creationflags=OHNE_FENSTER)
return mountpoint, abhaengen
def titel_aus_bdmt(wurzel: str) -> Optional[str]:
"""Der Klartext-Titel aus BDMV/META/DL/bdmt_*.xml. (ohne Mounten)
Getrennt von der Beschaffung der Wurzel, damit er ohne Disc pruefbar ist:
ein Ordner mit einer bdmt-Datei reicht.
"""
import re as _re
meta_dir = os.path.join(wurzel, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
try:
kandidaten = sorted(os.listdir(meta_dir))
except OSError:
return None
# bdmt_eng.xml bevorzugen, sonst die erste bdmt-Datei (bdmt_deu.xml, ...)
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
)
if not bdmt:
return None
try:
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
except OSError:
return None
# <di:name>Titel</di:name> - bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
return treffer.group(1).strip() if treffer else None
def read_disc_title_via_mount(device_path: str) -> Optional[str]: def read_disc_title_via_mount(device_path: str) -> Optional[str]:
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml. """Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
Das Volume-Label ist oft kryptisch (BD_EVG_D2) der echte Titel Das Volume-Label ist oft kryptisch (`BD_EVG_D2`) - der echte Titel
(Evangelion: 2.22 ") steht in den Disc-Metadaten. Die API darf (Evangelion 2.22") steht in den Disc-Metadaten. Unter Linux wird dafuer
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da). read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD). Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
""" """
import re as _re wurzel, aufraeumen = None, lambda: None
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
gemountet = False
try: try:
ergebnis = subprocess.run( wurzel, aufraeumen = disc_wurzel(device_path)
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint], return titel_aus_bdmt(wurzel) if wurzel else None
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
return None
gemountet = True
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
kandidaten = sorted(os.listdir(meta_dir))
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
)
if not bdmt:
return None
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
if treffer:
return treffer.group(1).strip()
return None
except Exception: except Exception:
return None return None
finally: finally:
if gemountet: try:
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15) aufraeumen()
except Exception:
pass
def titel_kandidaten(titel: str) -> List[str]: def titel_kandidaten(titel: str) -> List[str]:
@@ -273,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():
@@ -297,22 +356,37 @@ class PreScan:
if label: if label:
toc["title"] = normalize_disc_label(label) toc["title"] = normalize_disc_label(label)
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist): # Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
if shutil.which("makemkvcon"): #
result = subprocess.run( # ## Warum sie weg ist (Befund 30.08.2026)
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"], #
capture_output=True, # Der Commander: „das erkennen der disk dauert sehr sehr
text=True, # lange. Das ging mal viel schneller."
timeout=120 #
) # Hier stand ein `makemkvcon -r --noscan --minlength=300
for line in result.stdout.split('\n'): # info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
if line.startswith('TINFO:'): # vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
parts = line.split(',') # 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
if len(parts) >= 5: #
toc["tracks"].append({ # Dass es frueher schnell war, hat einen unschoenen Grund:
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title", # Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
"duration": int(parts[2]) if len(parts) > 2 else 0 # `shutil.which`, und das findet unter Windows nichts
}) # (Programme liegen nicht im PATH). `be3fac5` hat das am
# 28.08.2026 richtig repariert — und damit erst die Kosten
# sichtbar gemacht, die hier immer schon standen.
#
# Der Aufwand war umsonst: Das Ergebnis landete allein in
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
# und `GET /devices/{id}/tracks` — also dann, wenn sie
# 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
@@ -389,11 +463,13 @@ class PreScan:
# probieren — Disc-Titel sind selten API-freundlich formatiert. # probieren — Disc-Titel sind selten API-freundlich formatiert.
kandidaten = titel_kandidaten(title) or [title] kandidaten = titel_kandidaten(title) or [title]
movies = [] movies = []
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
for kandidat in kandidaten: for kandidat in kandidaten:
treffer = self.tmdb.search_movie(kandidat) treffer = self.tmdb.search_movie(kandidat)
if treffer and not movies: if treffer and not movies:
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
movies_kandidat = kandidat
for movie in treffer: for movie in treffer:
if movie.get("title", "").lower() == kandidat.lower(): if movie.get("title", "").lower() == kandidat.lower():
confidence = 0.95 confidence = 0.95
@@ -440,6 +516,55 @@ class PreScan:
if matched: if matched:
break break
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
#
# ## Der Befund des Commanders (28.08.2026)
#
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
# > wir ein cover."
#
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
# == kandidat.lower()`. An seiner Disc gemessen:
#
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
# 'Evangelion' 20 Treffer irgendein Evangelion
#
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
#
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
#
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
#
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
# ehrlich unter einem wörtlichen Treffer (0,95).
if not matched and movies and movies_kandidat == kandidaten[0] \
and len(movies) <= WENIGE_TREFFER:
details = self.tmdb.get_movie_details(movies[0]["id"])
if details:
confidence = 0.8
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": details.get("title", title),
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
"overview": details.get("overview", ""),
"poster_path": details.get("poster_path", ""),
"backdrop_path": details.get("backdrop_path", ""),
"runtime": details.get("runtime", 0),
"genres": [g["name"] for g in details.get("genres", [])],
"source": "tmdb",
}
matched = True
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die # Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1. # präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
# #
+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)
+694 -8
View File
@@ -2,16 +2,20 @@
Warum: Kein anderer Test importiert main.py ein Tippfehler dort fiele sonst Warum: Kein anderer Test importiert main.py ein Tippfehler dort fiele sonst
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün). erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort, ## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
wo auch die Ampel läuft.
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
Plattformen nachgemessen: 57 Routen, sauberer Import.
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
Plattform greift, ist eine halbe Zusage genau daran ist die Ampel am
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
""" """
import sys import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
import pytest
if sys.platform == "win32": # pragma: no cover
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
def test_main_importierbar_und_routen_verdrahtet(): def test_main_importierbar_und_routen_verdrahtet():
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel # Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt. # unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore", "/system/keystore",
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
"/betrieb",
): ):
assert pfad in routen, f"Route {pfad} fehlt" assert pfad in routen, f"Route {pfad} fehlt"
@@ -246,3 +253,682 @@ def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
zustand["da"] = True zustand["da"] = True
main._mounts_nachsehen() main._mounts_nachsehen()
assert any("antwortet wieder" in m for m in gelogged) assert any("antwortet wieder" in m for m in gelogged)
# --- Live-Ereignisse (SSE), Etappe V2-3 --------------------------------------
def test_events_route_ist_verdrahtet():
"""Ohne diese Route fällt das UI stumm auf seinen letzten Stand zurück —
und weil ein Abriss KEINE Aussage ist, sähe der Nutzer einfach nichts
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
from main import app
assert "/events" in {route.path for route in app.routes}
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():
"""`id:` ist nicht Kosmetik — der Browser schickt genau diesen Wert beim
Wiederverbinden als Last-Event-ID zurück. Fehlt er, gibt es keine
lückenlose Wiederaufnahme, und jeder WLAN-Wechsel reisst ein Loch."""
from main import _sse_rahmen
rahmen = _sse_rahmen({"seq": 42, "typ": "job.progress", "daten": {"prozent": 7}})
zeilen = rahmen.split("\n")
assert zeilen[0] == "id: 42"
assert zeilen[1] == "event: job.progress"
assert zeilen[2].startswith("data: {")
# Zwei Leerzeilen am Ende: eine schliesst das Ereignis, die zweite ist der
# Trenner. Ohne den doppelten Umbruch haelt der Browser das Ereignis fuer
# unvollstaendig und liefert es NIE aus.
assert rahmen.endswith("\n\n")
def test_sse_rahmen_uebersteht_umlaute():
"""Job-Titel und Log-Zeilen sind deutsch. Mit ensure_ascii=True kaeme
"Gr\u00f6\u00dfe" beim Nutzer an."""
from main import _sse_rahmen
rahmen = _sse_rahmen({"seq": 1, "typ": "log.line", "daten": {"text": "Größe"}})
assert "Größe" in rahmen
def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
"""„konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
Genau diese Vermischung hat in v1 die Job-Liste im Sekundentakt geleert
(fuenfmal `catch(() => [])` im UI). Ein Snapshot mit devices=[] wuerde dem
UI sagen du hast kein Laufwerk"; None sagt „ich weiss es gerade nicht",
und das UI behaelt seinen Stand.
OHNE DATENBANK: Die Ampel hat keine Postgres der erste Anlauf dieses
Tests lief deshalb rot (Lauf 170). Der Store wird hier ersetzt, denn
geprueft wird die Snapshot-LOGIK, nicht die Datenbank.
"""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
def kaputt():
raise OSError("Laufwerk haengt")
monkeypatch.setattr(main.device_discovery, "list_optical_devices", kaputt)
zustand = asyncio.run(main._snapshot())
assert zustand["devices"] is None, "Ein unlesbares Laufwerk darf nicht als [] durchgehen"
assert zustand["jobs"] == []
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
er den Rest raten und faellt auf Polling zurueck.
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
Viertelminute auf Platz fuer Rippy: unbekannt" — und das sieht aus wie
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
Windows-Fenster gesehen.
"""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
#
# `*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())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
assert "info" in zustand, (
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
"bis zu 15 Sekunden auf 'unbekannt'."
)
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
assert zustand["workers"] == [{"name": "pc"}]
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
async def platzt():
raise RuntimeError("Platte weg")
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
zustand = asyncio.run(main._snapshot())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
auf einem PC ohne Container und ohne zweiten Worker.
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
"""
from fastapi.testclient import TestClient
from main import app
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
# Lauf 170 rot geworden. /betrieb braucht keine.
antwort = TestClient(app).get("/betrieb")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["modus"] in ("standalone", "verteilt")
assert daten["plattform"] in ("windows", "linux", "macos")
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten",
"frei_blaettern"}
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
# der keinen Container hat (Befund 28.08.2026).
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
assert all(isinstance(w, bool) for w in daten["kann"].values())
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]:
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"]
+157
View File
@@ -0,0 +1,157 @@
"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen.
## Warum es diese Tests gibt (Befund 28.08.2026)
Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine
Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar:
**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und
`clients/thetvdb.py` machten im Konstruktor `from db import get_settings`.
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr main.py schreibt seitdem `from rippy import store as db`, diese drei
Module haben es nie mitbekommen. Damit starb JEDE Metadaten-Abfrage schon
beim Erzeugen des Clients mit `ModuleNotFoundError`, auf Windows UND auf der
VM. `_auto_prescan` verschluckte es in seinem `except Exception`.
**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an den
Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein
Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan
mit.
Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht.
Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen
den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss.
"""
import pytest
def test_tmdb_client_baut_sich_ohne_datenbank():
"""Der Test, der den Phantom-Import gefangen haette."""
from clients.tmdb import TMDBClient
klient = TMDBClient()
assert hasattr(klient, "api_key")
def test_omdb_client_baut_sich_ohne_datenbank():
from clients.omdb import OMDbClient
assert OMDbClient() is not None
def test_thetvdb_client_baut_sich_ohne_datenbank():
from clients.thetvdb import TheTVDBClient
assert TheTVDBClient() is not None
def test_kein_client_importiert_das_verschwundene_db_modul():
"""Der Waechter gegen genau diesen Fehler, mechanisch.
Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc
einlegt und dann als keine Metadaten", nicht als Import-Fehler.
"""
import os
hier = os.path.dirname(os.path.abspath(__file__))
fundstellen = []
for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")):
ordner[:] = [o for o in ordner if o != "__pycache__"]
for name in dateien:
if not name.endswith(".py"):
continue
pfad = os.path.join(wurzel, name)
with open(pfad, encoding="utf-8") as f:
for nr, zeile in enumerate(f, 1):
if zeile.strip().startswith(("from db import", "import db")):
fundstellen.append("%s:%d" % (name, nr))
assert not fundstellen, (
"docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt "
"rippy.store. Fundstellen: " + ", ".join(fundstellen))
# ── Der Zwischenspeicher ────────────────────────────────────────────────
def test_cache_ohne_redis_liefert_None_statt_zu_werfen():
"""Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.
Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den
Pre-Scan mit und die Disc blieb namenlos.
"""
from cache import cache
class ToterRedis:
def get(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def set(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def setex(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def delete(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def flushdb(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def ping(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
echt = cache.redis_client
cache.redis_client = ToterRedis()
try:
assert cache.get("egal") is None
assert cache.set("egal", {"a": 1}) is False
assert cache.delete("egal") is False
assert cache.clear() is False
cache.init_cache() # darf ebenfalls nicht werfen
assert cache.zustand()["erreichbar"] is False
finally:
cache.redis_client = echt
def test_cache_meldet_den_ausfall_nur_EINMAL(capsys):
"""Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf
kaeme sie mehrfach."""
from cache import cache
class ToterRedis:
def get(self, *a, **k):
raise cache.RedisError("weg")
echt, zustand = cache.redis_client, cache._erreichbar
cache.redis_client = ToterRedis()
cache._erreichbar = None
try:
for _ in range(5):
cache.get("x")
ausgabe = capsys.readouterr().out
assert ausgabe.count("nicht erreichbar") == 1, ausgabe
finally:
cache.redis_client, cache._erreichbar = echt, zustand
def test_ein_kaputter_eintrag_wirft_nicht():
"""Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die
Antwort eben frisch geholt."""
from cache import cache
class KaputterRedis:
def get(self, *a, **k):
return "{kein json"
echt = cache.redis_client
cache.redis_client = KaputterRedis()
try:
assert cache.get("x") is None
finally:
cache.redis_client = echt
@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"])
def test_jeder_client_ist_importierbar(name):
"""Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand
eine Disc einlegt."""
__import__("clients." + name)
+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
-118
View File
@@ -1,118 +0,0 @@
"""Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
das Zusammenfuehren der settings.conf.
"""
from makemkv_daten import (
ist_aacs_dump,
keydb_pruefen,
settings_conf_zusammenfuehren,
zaehle_disc_eintraege,
)
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
_GUELTIG = """; KEYDB.cfg — Beispiel
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
assert zaehle_disc_eintraege(nur_kommentare) == 0
assert zaehle_disc_eintraege("") == 0
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
def test_keydb_pruefen_meldet_leere_datei():
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html():
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen():
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False
assert ist_aacs_dump("unter\\ordner.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu")
assert 'app_Key = "T-neu"' in neu
assert "T-alt" not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DestinationDir = "/tmp"' in neu
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
# Datei mit abschliessendem Zeilenumbruch.
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
assert settings_conf_zusammenfuehren("", "") == ""
+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"]
+5 -1
View File
@@ -145,7 +145,11 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen # Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren. # den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join( worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py" # Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "ablauf.py"
) )
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks) spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read() quelltext = open(worker_tasks, encoding="utf-8").read()
+82
View File
@@ -0,0 +1,82 @@
"""Welche Pfade darf die API anfassen — und wo ist „oben"?
## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026)
`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container
ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und
die Folge war eine Oberflaeche, die stillschweigend nichts konnte:
* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht),
* `/browse` antwortete auf JEDEN Pfad mit 422,
* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag.
Sein Befund dazu: Wäre es möglich das Arbeitsverzeichnis zu ändern?
momentan geht das nicht."
Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im
Netz eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein
Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem
Rechner sitzt. Diese Tests halten beide Haelften fest.
"""
import main
# ── Die Grenze gilt, wo zugehoert wird ──────────────────────────────────
def test_im_container_gilt_die_wurzel_weiter():
"""Der Docker-Weg darf sich durch die Reparatur NICHT lockern."""
assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False)
assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False)
# Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb.
assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False)
def test_nativ_ist_auch_eine_freigabe_erlaubt():
"""Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar
keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar."""
assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True)
assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True)
def test_ein_leerer_pfad_ist_nie_erlaubt():
"""Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`."""
assert not main.pfad_erlaubt("", "/app/media", frei=True)
assert not main.pfad_erlaubt("", "/app/media", frei=False)
# ── Wo ist oben ─────────────────────────────────────────────────────────
def test_an_der_wurzel_ist_schluss():
"""Im Container. `None` heisst: kein Knopf „nach oben"."""
assert main.eltern_von("/app/media", "/app/media", frei=False) is None
assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media"
def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste():
"""Zwei Fallen auf einmal.
`os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` ein Knopf nach oben",
der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der
Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke:
Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer
100 GB Rohdaten.
"""
import ntpath
import os
echt = os.path.dirname
os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux
try:
assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == ""
assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users"
finally:
os.path.dirname = echt
def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
"""Eine leere Wurzel wuerde jede Pruefung durchwinken."""
assert main.medien_wurzel()
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
assert isinstance(main.betrieb_laufwerke(), list)
+5 -1
View File
@@ -55,7 +55,11 @@ def test_der_schluessel_heisst_im_worker_genauso():
import re import re
quelle = ( quelle = (
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py" # Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
pathlib.Path(__file__).resolve().parents[1] / "worker" / "ablauf.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier # Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
# ankommen. # ankommen.
+77
View File
@@ -144,3 +144,80 @@ def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
assert ergebnis.confidence == 0.3 assert ergebnis.confidence == 0.3
assert ergebnis.metadata["type"] == "unknown" assert ergebnis.metadata["type"] == "unknown"
assert ergebnis.title == "Nix Da" assert ergebnis.title == "Nix Da"
# ── Was auf FREMDEN Rechnern gilt (Befund 28.08.2026) ───────────────────
#
# Der Commander: "du hast jetzt nur mein laufwerk analysiert oder? wenn ich
# rippy weitergebe wie laeuft es dort?"
#
# Berechtigt. Gemessen wurde an EINEM Laufwerk (LG BU40N) und EINER Disc
# (BD-50 mit BDMV/META). Diese Tests halten fest, was bei ANDEREN Discs
# passieren muss -- ohne dass eine davon vorhanden sein muss.
def test_bluray_ohne_metadaten_gibt_ehrlich_nichts_zurueck(tmp_path):
"""Sehr viele Blu-rays haben kein BDMV/META. Dann greift der Rueckfall
auf das Volume-Label -- aber diese Funktion darf nichts erfinden."""
from prescan.prescan import titel_aus_bdmt
(tmp_path / "BDMV" / "STREAM").mkdir(parents=True)
assert titel_aus_bdmt(str(tmp_path)) is None
def test_dvd_hat_kein_bdmv(tmp_path):
from prescan.prescan import titel_aus_bdmt
(tmp_path / "VIDEO_TS").mkdir()
assert titel_aus_bdmt(str(tmp_path)) is None
def test_bdmt_wird_gelesen_wenn_da(tmp_path):
"""Der Weg, der bei der Disc des Commanders 'Evangelion 2.22' lieferte —
hier ohne Disc nachgestellt."""
from prescan.prescan import titel_aus_bdmt
meta = tmp_path / "BDMV" / "META" / "DL"
meta.mkdir(parents=True)
(meta / "bdmt_deu.xml").write_text(
'<?xml version="1.0"?><disclib><di:discinfo>'
'<di:title><di:name>Evangelion 2.22</di:name></di:title>'
'</di:discinfo></disclib>', encoding="utf-8")
assert titel_aus_bdmt(str(tmp_path)) == "Evangelion 2.22"
def test_englisch_wird_bevorzugt(tmp_path):
"""Die APIs suchen mit englischen Titeln besser."""
from prescan.prescan import titel_aus_bdmt
meta = tmp_path / "BDMV" / "META" / "DL"
meta.mkdir(parents=True)
for datei, name in (("bdmt_deu.xml", "Deutscher Titel"),
("bdmt_eng.xml", "English Title")):
(meta / datei).write_text(
"<x><di:name>%s</di:name></x>" % name, encoding="utf-8")
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
ENTFERNT er lief unter Windows nie (`shutil.which` findet dort nichts,
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
hier = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(hier, "prescan", "prescan.py"), encoding="utf-8") as f:
# NUR Code, keine Kommentare: Der erste Anlauf dieses Tests fiel ueber
# den eigenen Erklaertext, in dem der alte Aufruf zitiert wird. Ein
# Waechter, der an einer Erklaerung scheitert, prueft das Falsche.
code = [z for z in f if not z.strip().startswith("#")]
quelle = "".join(code)
assert "shutil.which(" not in quelle, (
"shutil.which findet unter Windows keine Programme aus 'Programme'")
assert "makemkvcon" not in quelle, (
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
+156
View File
@@ -0,0 +1,156 @@
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
## Der Befund des Commanders (28.08.2026)
> Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
> cover."
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
sondern eine Bedingung im Pre-Scan:
if movie.get("title", "").lower() == kandidat.lower():
An seiner Disc gemessen:
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
'Evangelion' 20 Treffer irgendein Evangelion
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster und wurde verworfen, weil der Titel nicht woertlich gleich war.
## Warum die Trefferzahl und nicht die Aehnlichkeit
Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
Aehnlichkeit das steht als Messung schon laenger im Code und faellt durch
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
weiss was er sucht. Wer 20 bekommt, hat geraten.
Diese Tests spritzen die Clients ein kein Netz, keine Schluessel, keine
Disc.
"""
import pytest
class FakeTMDB:
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
def __init__(self, treffer_je_suche, details=None):
self.treffer = treffer_je_suche
self.details = details or {}
self.gefragt = []
def search_movie(self, begriff):
self.gefragt.append(begriff)
return self.treffer.get(begriff, [])
def search_tv(self, begriff):
return []
def get_movie_details(self, kennung):
return self.details.get(kennung)
def get_tv_details(self, kennung):
return None
def find_by_imdb(self, kennung):
return None
class StummerClient:
def lookup(self, *a, **k):
return None
DETAILS_2_0 = {
22843: {
"title": "Evangelion: 2.0 You Can (Not) Advance",
"release_date": "2009-06-27",
"overview": "Der Pilotin Mari gelingt es …",
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
"runtime": 112,
"genres": [{"name": "Animation"}],
}
}
def _prescan(tmdb):
from prescan.prescan import PreScan
p = PreScan()
p.tmdb = tmdb
p.jikan = StummerClient()
p.omdb = StummerClient()
return p
def _toc(titel):
return {"title": titel, "year": None, "tracks": [], "duration": 0,
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
tmdb = FakeTMDB(
{"Evangelion 2.22": [{"id": 22843,
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
DETAILS_2_0)
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
assert ergebnis.confidence == 0.8
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
assert ergebnis.year == 2009
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
assert ergebnis.metadata["source"] == "tmdb"
def test_woertlich_gleicher_titel_bleibt_sicherer():
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
tmdb = FakeTMDB(
{"Logan": [{"id": 22843, "title": "Logan"}]},
{22843: dict(DETAILS_2_0[22843], title="Logan")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
assert ergebnis.confidence == 0.95
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
werden. Es bleibt beim ehrlichen Vorschlag."""
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
tmdb = FakeTMDB({"Evangelion": viele},
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
Sonst wuerde Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
"""
tmdb = FakeTMDB(
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
assert ergebnis.confidence == 0.6
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
assert ergebnis.confidence == 0.3
assert ergebnis.metadata["type"] == "unknown"
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
desselben Films, aber eng genug, um Raten auszuschliessen."""
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
assert ergebnis.confidence == erwartet
+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"
+124 -13
View File
@@ -6,19 +6,91 @@ import LogsPage from './pages/Logs'
import AnleitungPage from './pages/Anleitung' import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard' import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api' import { api } from './lib/api'
import { EventStreamProvider, useStrom } from './lib/useEventStream'
import { BetriebProvider } from './lib/useBetrieb'
import { Fehlergrenze } from './components/Fehlergrenze'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings' type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
export default function App() { /**
* Zeigt, ob der Live-Strom steht.
*
* Hier stand bis V2-3 ein fest verdrahtetes ONLINE" mit pulsierendem Punkt
* es leuchtete auch dann gruen, wenn die API tot war. Genau die Sorte
* Placebo-Anzeige, die in Etappe 19 schon einmal aufgeraeumt wurde
* (Fortschritt log, Auswurf tat nichts, Encoder wurden behauptet statt
* gemessen"). Jetzt sagt sie die Wahrheit.
*/
function VerbindungsAnzeige() {
const { verbunden, zuletzt } = useStrom()
if (verbunden) {
return (
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>LIVE</span>
</div>
)
}
return (
<div
className="flex items-center gap-2 text-xs font-mono text-amber-400 bg-amber-500/10 px-3 py-1.5 rounded-xl border border-amber-500/30"
title={
zuletzt
? `Letzte Meldung um ${zuletzt.toLocaleTimeString()}. Die Anzeige zeigt diesen Stand, bis die Verbindung zurueck ist.`
: 'Noch keine Verbindung zum Server.'
}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>VERBINDUNG WEG</span>
</div>
)
}
function AppInhalt() {
const [currentPage, setCurrentPage] = useState<Page>('dashboard') const [currentPage, setCurrentPage] = useState<Page>('dashboard')
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir // First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen). // ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
const [setupDone, setSetupDone] = useState<boolean | null>(null) const [setupDone, setSetupDone] = useState<boolean | null>(null)
/*
* Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung.
*
* Hier stand `.catch(() => setSetupDone(true))` mit der Begründung
* API/Setup nicht erreichbar nicht blockieren". Die Folge hat der
* Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation
* fehlte der Ersteinrichtungs-Assistent.**
*
* Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf,
* der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort der
* erste Abruf scheitert also fast immer. Aus ich konnte nicht nachsehen"
* wurde ist schon erledigt", und der Assistent war für immer weg.
*
* Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues
* weiß, behält, was er wusste und behauptet nichts.
*
* Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort
* entscheidet.
*/
useEffect(() => { useEffect(() => {
api.get('/setup') let abgemeldet = false
.then((r) => setSetupDone(Boolean(r.data?.done))) let versuche = 0
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
const fragen = () => {
api.get('/setup')
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
.catch(() => {
if (abgemeldet) return
versuche += 1
// Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis
// dahin nicht, hat der Nutzer ein anderes Problem als den
// Assistenten — dann zeigen wir die Oberfläche, damit er die Logs
// sehen kann.
if (versuche < 60) setTimeout(fragen, 2000)
else setSetupDone(true)
})
}
fragen()
return () => { abgemeldet = true }
}, []) }, [])
const navItems: { id: Page; label: string; icon: any }[] = [ const navItems: { id: Page; label: string; icon: any }[] = [
@@ -28,7 +100,22 @@ export default function App() {
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon }, { id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
] ]
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat // Solange /setup noch nicht geantwortet hat: sagen, dass gewartet wird.
// Ein leeres Fenster sieht aus wie ein Absturz — und nach einer frischen
// Installation ist genau das der erste Eindruck.
if (setupDone === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0b0f19]">
<div className="text-center">
<div className="text-amber-400 text-3xl mb-3"></div>
<p className="text-slate-300 text-sm">Rippy startet </p>
<p className="text-slate-500 text-xs mt-1 font-mono">
warte auf den Dienst
</p>
</div>
</div>
)
}
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} /> if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
return ( return (
@@ -69,20 +156,44 @@ export default function App() {
})} })}
</nav> </nav>
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20"> <VerbindungsAnzeige />
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>ONLINE</span>
</div>
</div> </div>
</header> </header>
{/* 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>
) )
} }
export default function App() {
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das
// alte Problem in neuer Form.
// BetriebProvider ganz aussen: Was Rippy anzeigen DARF, entscheidet sich
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
// compose ps" — einen Rat, den dort niemand befolgen kann.
return (
<Fehlergrenze>
<BetriebProvider>
<EventStreamProvider>
<AppInhalt />
</EventStreamProvider>
</BetriebProvider>
</Fehlergrenze>
)
}
+45 -4
View File
@@ -7,6 +7,7 @@ import { Button } from './ui/Button'
import { TypeBadge } from './ui/Badge' import { TypeBadge } from './ui/Badge'
import RipTargetModal, { RipOptionen } from './RipTargetModal' import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur' import MetadataKorrektur from './MetadataKorrektur'
import { useStrom } from '../lib/useEventStream'
interface DiscInfo { interface DiscInfo {
title: string title: string
@@ -35,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 {
@@ -63,6 +66,7 @@ export default function DeviceDiscovery() {
const [modalDevice, setModalDevice] = useState<Device | null>(null) const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null) const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { toast } = useToast() const { toast } = useToast()
const strom = useStrom()
const refreshDevices = async (manuell = false) => { const refreshDevices = async (manuell = false) => {
if (manuell) setRefreshing(true) if (manuell) setRefreshing(true)
@@ -124,11 +128,20 @@ export default function DeviceDiscovery() {
} }
} }
// Vorher: alle 5 s ein eigener Abruf von /devices (12 Anfragen pro Minute).
// Jetzt liefert der Server die Aenderung, sobald sie passiert.
//
// WICHTIG — nur uebernehmen, wenn der Server wirklich etwas WEISS:
// `strom.devices === null` heisst „konnte nicht nachsehen" (haengendes
// Laufwerk, Zeitgrenze) und NICHT „es gibt keine Laufwerke". In dem Fall
// bleibt der letzte bekannte Stand stehen. Genau diese Vermischung hat in
// v1 die Listen im Sekundentakt geleert.
useEffect(() => { useEffect(() => {
refreshDevices() if (strom.devices !== null) {
const interval = setInterval(refreshDevices, 5000) setDevices(strom.devices as Device[])
return () => clearInterval(interval) setLoading(false)
}, []) }
}, [strom.devices])
const QUELLEN_LABEL: Record<string, string> = { const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt', tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
@@ -168,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>
)
}
}
+11 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react' import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useBetrieb } from '../lib/useBetrieb'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder' import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
import { Button } from './ui/Button' import { Button } from './ui/Button'
@@ -74,6 +75,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [makemkvKey, setMakemkvKey] = useState('') const [makemkvKey, setMakemkvKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin') const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true) const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const betrieb = useBetrieb()
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([]) const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([]) const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
@@ -197,7 +199,11 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist." schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
/> />
<Zeile <Zeile
titel="Encoding-Worker" /* Auf einem Windows-PC gibt es keinen "Encoding-Worker" und
keinen Worker-Container -- Rippy komprimiert selbst. Der
Commander am 28.08.2026: "Das gilt fuer die ganze
standalone version fuer Windows." */
titel={betrieb.kann.externe_worker ? 'Encoding-Worker' : 'Kompression'}
geladen={geladen} geladen={geladen}
gut={encoderWorker.length > 0} gut={encoderWorker.length > 0}
gutText={encoderWorker.map(w => { gutText={encoderWorker.map(w => {
@@ -207,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
.filter(Boolean).join(', ') .filter(Boolean).join(', ')
return zusatz ? `${w.name} (${zusatz})` : w.name return zusatz ? `${w.name} (${zusatz})` : w.name
}).join(' · ')} }).join(' · ')}
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht." schlechtText={betrieb.kann.externe_worker
? 'Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht.'
: 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'}
/> />
<Zeile <Zeile
titel="Freier Platz" titel="Freier Platz"
@@ -351,7 +359,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span> <span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
</p> </p>
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90"> <p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
Keiner deiner Worker kann AVX2 die Vektorbefehle, von denen H.265 {betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein AVX2 die Vektorbefehle, von denen H.265
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong> lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
{' '}dauern. Deshalb wird eingestellt: {' '}dauern. Deshalb wird eingestellt:
</p> </p>
+13 -37
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
import { Card, CardHeader, CardTitle } from './ui/Card' import { Card, CardHeader, CardTitle } from './ui/Card'
import { useStrom } from '../lib/useEventStream'
interface LiveLogEntry { interface LiveLogEntry {
id: string id: string
@@ -12,40 +11,14 @@ interface LiveLogEntry {
} }
export default function LiveLogSection() { export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([]) // Vorher: zwei eigene Taktgeber (alle 5 s /jobs und /logs) = 24 Anfragen pro
const [currentJob, setCurrentJob] = useState<any>(null) // Minute allein aus diesem Kasten. Jetzt kommt beides aus der einen offenen
// Verbindung, die der Provider haelt.
useEffect(() => { const { jobs, logs } = useStrom()
const fetchJobs = async () => { const currentJob =
try { jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') ||
const response = await api.get('/jobs') jobs.find((j: any) => j.status === 'pending') ||
const jobs = response.data null
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
} catch (error) {
console.error('Fehler beim Laden der Jobs:', error)
}
}
const fetchLogs = async () => {
try {
const response = await api.get('/logs', { params: { limit: 50 } })
setLogs(response.data)
} catch (error) {
console.error('Fehler beim Laden der Logs:', error)
}
}
fetchJobs()
fetchLogs()
const jobInterval = setInterval(fetchJobs, 5000)
const logInterval = setInterval(fetchLogs, 5000)
return () => {
clearInterval(jobInterval)
clearInterval(logInterval)
}
}, [])
const getStatusColor = (level: string) => { const getStatusColor = (level: string) => {
switch (level) { switch (level) {
@@ -72,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>
)
}
+65 -14
View File
@@ -5,6 +5,7 @@ import { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Input, Select } from './ui/Input' import { Input, Select } from './ui/Input'
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder' import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
import { useBetrieb } from '../lib/useBetrieb'
interface TargetConfig { interface TargetConfig {
id: string id: string
@@ -157,12 +158,20 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
return () => clearInterval(interval) return () => clearInterval(interval)
}, [scanStatus, deviceId]) }, [scanStatus, deviceId])
const [targets, setTargets] = useState<TargetConfig[]>([ // Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, const betrieb = useBetrieb()
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
]) /*
const [browsePath, setBrowsePath] = useState<string>('/app/media') * Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
*
* 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 [browseParent, setBrowseParent] = useState<string | null>(null) const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([]) const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('') const [customPath, setCustomPath] = useState('')
@@ -181,6 +190,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([]) const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
const [arbeitsDir, setArbeitsDir] = useState('') const [arbeitsDir, setArbeitsDir] = useState('')
const [standardArbeitsDir, setStandardArbeitsDir] = useState('') const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
// Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in
// der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt
// — und stünde leer da, obwohl etwas gewählt ist.
const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('')
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -194,6 +207,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setGewaehlt(new Set()) setGewaehlt(new Set())
setEncoderNode('') setEncoderNode('')
setArbeitsDir('') setArbeitsDir('')
setEigenesArbeitsZiel('')
setBrowserOffen(false) setBrowserOffen(false)
setDiscSprachen(null) setDiscSprachen(null)
setAudioWahl(new Set()) setAudioWahl(new Set())
@@ -213,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 || '/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 },
@@ -240,6 +254,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar). // leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
/*
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
*
* Commander am 28.08.2026: Warum heißt das hier noch container platte? Er
* holt sich das Arbeitsverzeichnis ja von der Installation."
*
* Genau so ist es nur stand hier fest verdrahtet (Container-Platte)",
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
*/
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
// NACH dem Rip auf, weil nur der Worker selbst prüfte. // NACH dem Rip auf, weil nur der Worker selbst prüfte.
@@ -378,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onChange={e => setArbeitsDir(e.target.value)} onChange={e => setArbeitsDir(e.target.value)}
> >
<option value=""> <option value="">
Standard aus den Einstellungen Standard{arbeitsVorgabe ? `${arbeitsVorgabe}` : ''}
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
</option> </option>
{arbeitsZiele.map(z => ( {arbeitsZiele.map(z => (
<option key={z.path} value={z.path}> <option key={z.path} value={z.path}>
@@ -387,10 +411,13 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{z.free_gb != null ? `${z.free_gb} GB frei` : ''} {z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option> </option>
))} ))}
{eigenesArbeitsZiel && (
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
)}
</Select> </Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5"> <p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} /> <HardDrive size={13} />
Bei 4K-UHD bis zu 100 GB nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben. Bei 4K-UHD bis zu 100 GB nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben.
Dann muss Rippy am Ende nur umhängen statt zu kopieren. Dann muss Rippy am Ende nur umhängen statt zu kopieren.
</p> </p>
</div> </div>
@@ -588,12 +615,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onClick={() => { onClick={() => {
const auf = !browserOffen const auf = !browserOffen
setBrowserOffen(auf) setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('/app/media') if (auf && browseDirs.length === 0) laden('')
}} }}
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200" className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
> >
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Anderen Zielordner wählen (nur für diesen Rip) {selectedType === 'music'
? 'Anderen Zielordner wählen (nur für diesen Rip)'
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
</button> </button>
{browserOffen && ( {browserOffen && (
@@ -607,14 +636,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span> <span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
{browsePath || 'Laufwerke'}
</span>
<Button <Button
variant={customPath === browsePath ? 'amber' : 'secondary'} variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm" size="sm"
disabled={!browsePath}
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)} onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
> >
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'} {customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
</Button> </Button>
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
beliebiger Ordner etwa D:\Rippy-Arbeit ging bisher gar
nicht (momentan geht das nicht", 28.08.2026). */}
{selectedType !== 'music' && (
<Button
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => {
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
setEigenesArbeitsZiel(browsePath)
setArbeitsDir(browsePath)
}}
>
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
</Button>
)}
</div> </div>
<div className="max-h-36 overflow-y-auto p-1"> <div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => ( {browseDirs.map(d => (
+11 -4
View File
@@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Input } from './ui/Input' import { Input } from './ui/Input'
import { useStrom } from '../lib/useEventStream'
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -54,6 +55,7 @@ export default function WorkerVerwaltung() {
// eintragen lassen als eine falsche Adresse vorgaukeln. // eintragen lassen als eine falsche Adresse vorgaukeln.
const [rippyHost, setRippyHost] = useState('') const [rippyHost, setRippyHost] = useState('')
const { toast } = useToast() const { toast } = useToast()
const strom = useStrom()
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann // Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen). // erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
@@ -76,11 +78,16 @@ export default function WorkerVerwaltung() {
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') }) .catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
} }
// Die Worker-Liste kommt aus dem Live-Strom. /capabilities wird nur noch
// EINMAL geholt — dort steht ausser der Liste die Server-Version, und die
// aendert sich nur beim Deploy. Vorher lief der Abruf alle 15 s.
useEffect(() => { laden() }, [])
useEffect(() => { useEffect(() => {
laden() if (strom.workers.length || strom.verbunden) {
const interval = setInterval(laden, 15000) setWorkers(strom.workers as WorkerInfo[])
return () => clearInterval(interval) }
}, []) }, [strom.workers, strom.verbunden])
const installBefehl = variante === 'linux' const installBefehl = variante === 'linux'
? [ ? [
+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 (
+144
View File
@@ -0,0 +1,144 @@
/*
* Worauf läuft Rippy und was kann dieser Betrieb überhaupt?
*
* ## Der Befund des Commanders (28.08.2026)
*
* Im Windows-Fenster stand auf der Server-Status-Kachel:
*
* Worker erreichbar: 0 von 1
* Kein Worker antwortet ohne ihn läuft kein Rip. Prüfen: docker compose ps
* Container-Platte: unbekannt
* Freigaben: keine eingehängt
*
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
* Container, kein `docker compose`, keinen zweiten Worker Rippy rippt dort
* selbst. Sein Urteil: Du hast ja quasi nur rippy genommen und die docker
* installation für Windows gebaut."
*
* ## Warum ein Provider und kein Abruf je Seite
*
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
* dasselbe abfragen, sind vier Abrufe und vier Gelegenheiten, dass eine
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
* Laufzeit nicht; er wird EINMAL geholt.
*
* ## Warum die Vorgabe Docker" ist
*
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
* Docker-Annahme ist hier die richtige Vorgabe sie zeigt MEHR, und ein kurz
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
* einen Augenblick verschwindet und wieder auftaucht.
*
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als noch unterwegs":
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from './api'
export interface BetriebsFaehigkeiten {
/** Gibt es andere Maschinen, die Jobs übernehmen? */
externe_worker: boolean
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
freigaben_einhaengen: boolean
/** Sind Pfade wie /app/media überhaupt gemeint? */
container_pfade: boolean
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
werkzeuge_verwalten: boolean
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
frei_blaettern: boolean
}
export interface Betrieb {
modus: 'standalone' | 'verteilt'
plattform: 'windows' | 'linux' | 'macos'
im_container: boolean
kann: BetriebsFaehigkeiten
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
ablage_vorgabe: string
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
arbeits_vorgabe: string
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
* ausführen könnte dann darf auch keiner dastehen. */
hilfe_befehl: string
/** Ist die Auskunft schon da? False heißt noch unterwegs ODER nicht
* erreichbar" — nicht „es gibt keinen Betrieb". */
geladen: boolean
}
const VORGABE: Betrieb = {
modus: 'verteilt',
plattform: 'linux',
im_container: true,
kann: {
externe_worker: true,
freigaben_einhaengen: true,
container_pfade: true,
werkzeuge_verwalten: false,
frei_blaettern: false,
},
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
// 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',
geladen: false,
}
const BetriebContext = createContext<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
useEffect(() => {
let abgemeldet = false
api.get('/betrieb')
.then(antwort => {
const d = antwort.data
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
// verschwände ein Bereich stillschweigend.
if (abgemeldet || !d || !d.kann) return
setBetrieb({
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
plattform: d.plattform || 'linux',
im_container: !!d.im_container,
kann: {
externe_worker: !!d.kann.externe_worker,
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
container_pfade: !!d.kann.container_pfade,
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
frei_blaettern: !!d.kann.frei_blaettern,
},
ablage_vorgabe: d.ablage_vorgabe || '',
arbeits_vorgabe: d.arbeits_vorgabe || '',
hilfe_befehl: d.hilfe_befehl || '',
geladen: true,
})
})
.catch(() => {
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
})
return () => { abgemeldet = true }
}, [])
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
export function useBetrieb(): Betrieb {
return useContext(BetriebContext)
}
/**
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
* (Windows-Client oder Docker-All-in-One)?
*
* Bewusst NICHT ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
* die nächste falsche Annahme ein.
*/
export function useAlleinbetrieb(): boolean {
return !useBetrieb().kann.externe_worker
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Der Live-Strom des UI EINE Verbindung für die ganze Anwendung.
*
* ## Was das ablöst
*
* Bis hierher hatte jede Komponente ihren eigenen Taktgeber. Nachgerechnet an
* den `setInterval`-Aufrufen verursachte EIN offener Tab plus ein Worker rund
* 133 Anfragen pro Minute:
*
* Dashboard 5 Endpunkte alle 4 s -> 75/min
* Log-Kasten 2 Endpunkte alle 5 s -> 24/min
* Laufwerke 1 Endpunkt alle 5 s -> 12/min
* Log-Seite 1 Endpunkt alle 10 s -> 6/min
* Worker-Liste 1 Endpunkt alle 15 s -> 4/min
* Windows-Tray /jobs alle 5 s -> 12/min
*
* Das Rate-Limit stand einmal UNTER dieser Zahl (100/min). Etwa jede vierte
* Anfrage bekam 429 und weil das UI einen Fehlschlag als es gibt nichts"
* verbuchte, leerte sich die Job-Liste im Sekundentakt. Der Commander meldete
* das als wird oft neu geladen".
*
* ## DIE Regel dieser Datei
*
* Ein Verbindungsabriss ist KEINE Aussage über die Welt.
*
* Bei einem Fehler wird der letzte bekannte Stand BEHALTEN und `verbunden`
* auf false gesetzt das UI zeigt dann ein Banner. Es wird niemals eine
* Liste auf leer gesetzt. Genau das war der alte Fehler: fünfmal stand
* `catch(() => [])` im Code, und jeder fehlgeschlagene Abruf hieß damit
* keine Jobs, keine Laufwerke, keine Ablagen".
*
* ## Warum ein Context und nicht ein Hook je Komponente
*
* `EventSource` öffnet je Aufruf eine eigene HTTP-Verbindung. Ein Hook, den
* fünf Komponenten benutzen, wären fünf Verbindungen und fünf Snapshots
* also genau das Problem in neuer Form. Der Provider hält eine.
*/
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
export type Job = {
id: string
status: string
progress: number
title?: string | null
type?: string
device?: string
error?: string | null
meta?: Record<string, any> | null
[k: string]: any
}
export type Device = { id: string; name: string; type: string; path: string; status: string; [k: string]: any }
export type Worker = { name: string; encoders?: string[]; info?: Record<string, any>; last_seen?: string | null }
/**
* Dieselbe Form, die auch `GET /logs` liefert (`_log_zeile` in main.py).
* `timestamp`, nicht `ts` sonst zeigt das Log-Fenster Invalid Date", und
* zwar NUR im Live-Betrieb.
*/
export type LogZeile = { id: string; timestamp?: string; level?: string; source?: string; message?: string }
export type StromZustand = {
jobs: Job[]
/** Server-Zustand aus dem langsamen Takt des Waechters. null = noch nichts gehoert. */
systemInfo: any | null
/** Ablageziele mit freiem Platz. null = noch nichts gehoert. */
ablagen: any[] | null
/** null heißt ausdrücklich „konnte nicht nachsehen" — NICHT „es gibt keine". */
devices: Device[] | null
workers: Worker[]
logs: LogZeile[]
/** false = Verbindung weg. Die Daten oben sind dann der letzte bekannte Stand. */
verbunden: boolean
/** Wie oft die Verbindung schon neu aufgebaut wurde — fürs Log, nicht fürs UI. */
neuverbindungen: number
/** Zeitpunkt des letzten empfangenen Ereignisses. */
zuletzt: Date | null
}
const LEER: StromZustand = {
jobs: [],
systemInfo: null,
ablagen: null,
devices: null,
workers: [],
logs: [],
verbunden: false,
neuverbindungen: 0,
zuletzt: null,
}
const StromContext = createContext<StromZustand>(LEER)
/** Wie viele Log-Zeilen im Speicher gehalten werden. */
const LOG_GRENZE = 300
export function EventStreamProvider({ children }: { children: ReactNode }) {
const [zustand, setZustand] = useState<StromZustand>(LEER)
// In einem Ref, damit die Handler nicht bei jedem Zustandswechsel neu
// gebunden werden müssen — sonst risse die Verbindung ständig ab.
const neuverbindungen = useRef(0)
useEffect(() => {
const quelle = new EventSource('/api/events')
const merken = (patch: Partial<StromZustand>) =>
setZustand((alt) => ({ ...alt, ...patch, zuletzt: new Date() }))
// ── Snapshot: das ganze Bild ──────────────────────────────────────
quelle.addEventListener('snapshot', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
merken({
jobs: d.jobs ?? [],
// devices === null kommt vom Server, wenn er die Laufwerke nicht
// lesen konnte. Das wird DURCHGEREICHT, nicht zu [] geglättet.
devices: d.devices === undefined ? null : d.devices,
workers: d.workers ?? [],
logs: d.logs ?? [],
// ⚠️ Server-Zustand MIT übernehmen (Befund 28.08.2026).
//
// Er stand hier nicht, obwohl der Schnappschuss ihn liefert — und
// `system.status` kommt erst nach bis zu 15 Sekunden. Nach jedem
// Neuladen stand deshalb „Platz für Rippy: unbekannt" auf dem
// Bildschirm. Das sieht aus wie eine Auskunft und ist keine.
//
// `undefined` heißt weiterhin „nicht mitgeschickt": dann bleibt der
// alte Stand, statt auf leer zu fallen.
systemInfo: d.info !== undefined ? d.info : null,
ablagen: d.ablagen !== undefined ? d.ablagen : null,
verbunden: true,
})
})
// ── Job-Deltas ────────────────────────────────────────────────────
const jobPatch = (e: Event) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const id = ereignis.entitaet_id
const daten = ereignis.daten ?? {}
setZustand((alt) => {
if (daten.status === 'geloescht') {
return { ...alt, jobs: alt.jobs.filter((j) => j.id !== id), verbunden: true, zuletzt: new Date() }
}
const bekannt = alt.jobs.some((j) => j.id === id)
const jobs = bekannt
? alt.jobs.map((j) => (j.id === id ? { ...j, ...daten } : j))
: [{ id, ...daten } as Job, ...alt.jobs]
return { ...alt, jobs, verbunden: true, zuletzt: new Date() }
})
}
for (const typ of ['job.created', 'job.progress', 'job.phase', 'job.finished']) {
quelle.addEventListener(typ, jobPatch)
}
// ── Laufwerke ─────────────────────────────────────────────────────
quelle.addEventListener('drive.changed', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
setZustand((alt) => {
if (alt.devices === null) return alt // noch kein Stand — auf Snapshot warten
const id = ereignis.entitaet_id
return {
...alt,
devices: alt.devices.map((d) => (d.id === id ? { ...d, ...ereignis.daten } : d)),
verbunden: true,
zuletzt: new Date(),
}
})
})
// ── Log-Zeilen ────────────────────────────────────────────────────
quelle.addEventListener('log.line', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const d = ereignis.daten ?? {}
const zeile: LogZeile = {
id: String(ereignis.entitaet_id),
timestamp: ereignis.ts,
level: d.level,
source: d.source,
message: d.message,
}
setZustand((alt) => ({
...alt,
logs: [zeile, ...alt.logs].slice(0, LOG_GRENZE),
verbunden: true,
zuletzt: new Date(),
}))
})
// ── Server-Zustand (langsamer Takt) ───────────────────────────────
quelle.addEventListener('system.status', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
// Nur uebernehmen, was WIRKLICH mitgeschickt wurde. Ein fehlender
// Schluessel heisst „konnte ich diesmal nicht lesen" — dann bleibt der
// alte Stand stehen, statt auf leer zu fallen.
setZustand((alt) => ({
...alt,
systemInfo: d.info !== undefined ? d.info : alt.systemInfo,
workers: d.workers !== undefined ? d.workers : alt.workers,
ablagen: d.ablagen !== undefined ? d.ablagen : alt.ablagen,
verbunden: true,
zuletzt: new Date(),
}))
})
quelle.onopen = () => merken({ verbunden: true })
// ── Abriss: Stand BEHALTEN, nur die Verbindung melden ─────────────
quelle.onerror = () => {
// Kein Leeren, kein Zurücksetzen. Der Browser verbindet von selbst neu
// und schickt dabei Last-Event-ID mit; der Server liefert die Lücke nach
// oder schickt einen frischen Snapshot.
neuverbindungen.current += 1
setZustand((alt) => ({
...alt,
verbunden: false,
neuverbindungen: neuverbindungen.current,
}))
}
return () => quelle.close()
}, [])
const wert = useMemo(() => zustand, [zustand])
return <StromContext.Provider value={wert}>{children}</StromContext.Provider>
}
/** Der gemeinsame Live-Zustand. */
export function useStrom(): StromZustand {
return useContext(StromContext)
}
+40 -11
View File
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react' import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { PageHeader } from '../components/ui/PageHeader' 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 { useBetrieb } from '../lib/useBetrieb'
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) { function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
return ( return (
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
export default function AnleitungPage() { export default function AnleitungPage() {
const fett = "font-semibold text-slate-100" const fett = "font-semibold text-slate-100"
/*
* Auch die Anleitung haengt am Betrieb.
*
* Commander-Befund 28.08.2026: Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Eine Anleitung, die einem Windows-Nutzer erklaert, wie er Freigaben
* einhaengt und weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
* das er nicht hat. Das ist schlimmer als gar keine Anleitung: Sie laesst
* ihn glauben, er habe etwas falsch gemacht.
*/
const betrieb = useBetrieb()
return ( return (
<div className="space-y-6 max-w-6xl mx-auto"> <div className="space-y-6 max-w-6xl mx-auto">
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
</p> </p>
</Abschnitt> </Abschnitt>
<Abschnitt icon={HardDrive} titel="NAS &amp; Speicherziele"> <Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
{betrieb.kann.freigaben_einhaengen ? (
<p>
Einstellungen Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), Freigaben
auflisten", Freigabe wählen, „Einhängen &amp; speichern". Danach taucht das Ziel im
Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
</p>
) : (
<p>
Einstellungen Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
ein Netzwerkpfad wie <code>\NAS\Filme</code> Rippy schreibt einfach dorthin.
Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig.
</p>
)}
<p> <p>
Einstellungen Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer + <span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), Freigaben knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen
auflisten", Freigabe wählen, „Einhängen &amp; speichern". Danach taucht das Ziel im Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden. Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
</p>
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
Einstellungen Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
jedem Rip und bricht sonst mit Klartext ab.
</p> </p>
</Abschnitt> </Abschnitt>
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
</p> </p>
</Abschnitt> </Abschnitt>
{/* Nur im verteilten Betrieb. Einem Windows-Nutzer zu erklaeren,
wie er weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
das er nicht hat und laesst ihn glauben, er habe etwas falsch
gemacht (Commander-Befund 28.08.2026). */}
{betrieb.kann.externe_worker && (
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker"> <Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p> <p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker. Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker.
@@ -126,6 +154,7 @@ export default function AnleitungPage() {
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt). nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
</p> </p>
</Abschnitt> </Abschnitt>
)}
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System"> <Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System">
<p> <p>
+157 -58
View File
@@ -10,6 +10,8 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal' import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog' import RetryDialog from '../components/RetryDialog'
import { useStrom } from '../lib/useEventStream'
import { useBetrieb } from '../lib/useBetrieb'
interface JobMeta { interface JobMeta {
year?: number year?: number
@@ -42,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[] }[]
} }
@@ -63,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
@@ -126,6 +132,10 @@ export default function Dashboard() {
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([]) const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([]) const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([]) const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const strom = useStrom()
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
const betrieb = useBetrieb()
const { toast } = useToast() const { toast } = useToast()
/* /*
@@ -232,64 +242,43 @@ export default function Dashboard() {
} }
/* /*
* ZWEI TAKTE statt einem. * KEIN TAKTGEBER MEHR (Etappe V2-3).
* *
* JEDER Abruf hier gibt bei Fehlschlag `null` und `null` bedeutet nichts * Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall * Worker und Ablagen zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste, * der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das * (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
* hat der Commander als wird oft neu geladen" gemeldet. Siehe `fetchJobs`. * es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
* Genau das hat der Commander als wird oft neu geladen" gemeldet.
* *
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75 * Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche die API wies bei *
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97 * DIE REGEL BLEIBT sie ist nur umgezogen: Ein fehlender Wert im Strom
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen * heisst nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und * jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie. * letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
* 75/min sind damit 15 + 15/min geworden. * VERBINDUNG WEG" statt leerer Listen.
*/ */
useEffect(() => { useEffect(() => {
const nichts = () => null setJobs(strom.jobs as Job[])
setLoading(false)
}, [strom.jobs])
// Schnell (4 s): was sich während eines Rips wirklich bewegt. useEffect(() => {
const schnellLaden = async () => { if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
try { }, [strom.devices])
const [jobsData, devData] = await Promise.all([
fetchJobs(),
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (jobsData) setJobs(jobsData)
if (devData) setLaufwerke(devData)
} finally {
setLoading(false)
}
}
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online useEffect(() => {
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen. if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
const langsamLaden = async () => { }, [strom.workers, strom.verbunden])
const [sysData, capsData, ablData] = await Promise.all([
api.get('/system/info').then(r => r.data).catch(nichts),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung.
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (sysData) setSystemInfo(sysData)
if (capsData) setWorkersLive(capsData)
if (ablData) setAblagen(ablData)
}
schnellLaden() useEffect(() => {
langsamLaden() if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
const schnell = setInterval(schnellLaden, 4000) }, [strom.systemInfo])
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) } useEffect(() => {
}, []) if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
}, [strom.ablagen])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending') const queueJobs = jobs.filter(j => j.status === 'pending')
@@ -343,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'
@@ -599,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
@@ -697,7 +724,30 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */} {/* 2. Wer arbeitet hier und das hängt am BETRIEB.
Commander-Befund 28.08.2026: Im Windows-Fenster stand
Worker erreichbar: 0 von 1 Kein Worker antwortet.
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
keinen zweiten Worker, auf den man warten könnte, und
kein docker compose, das man prüfen könnte. Die Meldung
war nicht nur unpassend, sie war falsch. */}
{!betrieb.kann.externe_worker ? (
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
</span>
<span className="text-slate-200">
auf diesem Rechner
</span>
</div>
<p className="text-[11px] font-mono text-slate-500">
{aktiverJob
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
</p>
</div>
) : (
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
@@ -711,7 +761,9 @@ export default function Dashboard() {
{workerOnline.length === 0 ? ( {workerOnline.length === 0 ? (
<p className="text-[11px] font-mono text-rose-400"> <p className="text-[11px] font-mono text-rose-400">
Kein Worker antwortet ohne ihn läuft kein Rip. Kein Worker antwortet ohne ihn läuft kein Rip.
Prüfen: <span className="text-slate-300">docker compose ps</span> {betrieb.hilfe_befehl && (
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
)}
</p> </p>
) : ( ) : (
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -732,15 +784,52 @@ export default function Dashboard() {
</div> </div>
)} )}
</div> </div>
)}
{/* 3. Platz Container-Platte UND die eingehängten Freigaben. {/* 3. Platz Container-Platte UND die eingehängten Freigaben.
Vorher stand hier nur die Container-Platte; wer wissen Vorher stand hier nur die Container-Platte; wer wissen
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" /> Container-Platte: <HardDrive size={14} className="text-amber-400" />
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'}
@@ -763,7 +852,16 @@ export default function Dashboard() {
</div> </div>
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die {/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
Rohdaten liegen und bei externem Encoden liegen MÜSSEN. */} Rohdaten liegen und bei externem Encoden liegen MÜSSEN.
NUR im Container. Unter Windows hängt Rippy nichts ein:
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
Der Satz Ohne Freigabe kann ein externer Encoder nichts
tun er sieht die Container-Platte nicht" stand am
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
Es gibt weder einen externen Encoder noch eine
Container-Platte. */}
{betrieb.kann.freigaben_einhaengen && (
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
@@ -797,6 +895,7 @@ export default function Dashboard() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+15 -4
View File
@@ -5,6 +5,7 @@ import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader' import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card' import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { useStrom } from '../lib/useEventStream'
interface LogEntry { interface LogEntry {
id: string id: string
@@ -21,6 +22,7 @@ export default function LogsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const strom = useStrom()
const refreshLogs = async (manuell = false) => { const refreshLogs = async (manuell = false) => {
if (manuell) setRefreshing(true) if (manuell) setRefreshing(true)
@@ -37,11 +39,20 @@ export default function LogsPage() {
} }
} }
// Einmal die volle Historie holen (200 Zeilen — der Strom liefert im
// Snapshot nur die letzten 50), danach kommt alles Neue von selbst.
// Der Aktualisieren-Knopf bleibt: Er holt die Historie erneut.
useEffect(() => { refreshLogs() }, [])
useEffect(() => { useEffect(() => {
refreshLogs() if (!strom.logs.length) return
const interval = setInterval(refreshLogs, 10000) setLogs((alt) => {
return () => clearInterval(interval) const bekannt = new Set(alt.map((l) => String(l.id)))
}, []) const neue = strom.logs.filter((l) => !bekannt.has(String(l.id)))
return neue.length ? ([...neue, ...alt] as LogEntry[]) : alt
})
setLoading(false)
}, [strom.logs])
const getLevelColor = (level: string) => { const getLevelColor = (level: string) => {
switch (level) { switch (level) {
+202 -23
View File
@@ -4,10 +4,12 @@ import { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import StorageMounts from '../components/StorageMounts' import StorageMounts from '../components/StorageMounts'
import WorkerVerwaltung from '../components/WorkerVerwaltung' import WorkerVerwaltung from '../components/WorkerVerwaltung'
import { useBetrieb } from '../lib/useBetrieb'
import { PageHeader } from '../components/ui/PageHeader' 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'
@@ -48,7 +50,10 @@ const defaultSettings: SettingsState = {
tmdbApiKey: '', tmdbApiKey: '',
tvdbApiKey: '', tvdbApiKey: '',
omdbApiKey: '', omdbApiKey: '',
outputDir: '/app/media', // Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
// beim Laden gesetzt.
outputDir: '',
movieDir: 'movies', movieDir: 'movies',
seriesDir: 'series', seriesDir: 'series',
musicDir: 'music', musicDir: 'music',
@@ -187,6 +192,7 @@ function zeitLesbar(iso: string): string {
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings) const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const betrieb = useBetrieb()
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping') const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null) const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
@@ -208,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)
@@ -248,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 {
@@ -485,7 +531,12 @@ export default function SettingsPage() {
const istFreigabe = (pfad: string) => const istFreigabe = (pfad: string) =>
ziele.some(z => z.path === pfad && z.is_mount) ziele.some(z => z.path === pfad && z.is_mount)
const ablageWarnung = (() => { const ablageWarnung = (() => {
const ablage = settings.outputDir || '/app/media' // ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
// es weder den einen noch die andere — der Satz stand am 28.08.2026
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
if (!betrieb.kann.externe_worker) return ''
const ablage = settings.outputDir || betrieb.ablage_vorgabe
const arbeit = settings.workDir const arbeit = settings.workDir
if (!arbeit) { if (!arbeit) {
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf ' return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
@@ -505,16 +556,45 @@ export default function SettingsPage() {
return '' return ''
})() })()
/*
* Die Reiter haengen am BETRIEB.
*
* Commander-Befund 28.08.2026: Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Ein Reiter Worker" fuehrt in einer Standalone-Installation zu einer
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
* Speicherziele" bietet dort das Einhaengen von Freigaben an etwas, das
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
* Bedienelemente, die ins Leere fuehren.
*/
const tabs: { id: SettingsTab; label: string; icon: any }[] = [ const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc }, { id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu }, { id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu }, ...(betrieb.kann.externe_worker
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
: []),
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
// das steckt weiter unten in einer eigenen Bedingung.
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive }, { id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe }, { id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle }, { id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench }, { id: 'system', label: 'System', icon: Wrench },
] ]
/*
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
*
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
* Zwischenzeit auf Worker" steht oder wer den Reiter beim Neuladen aus
* dem Zustand mitbringt saehe sonst eine leere Seite ohne jeden Hinweis.
*/
const sichtbareReiter = tabs.map(t => t.id)
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
return ( return (
<div className="max-w-6xl mx-auto space-y-6"> <div className="max-w-6xl mx-auto space-y-6">
<PageHeader <PageHeader
@@ -533,7 +613,7 @@ export default function SettingsPage() {
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]"> <div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeTab === tab.id const isActive = aktiverReiter === tab.id
return ( return (
<button <button
key={tab.id} key={tab.id}
@@ -554,7 +634,7 @@ export default function SettingsPage() {
{/* Tab Content */} {/* Tab Content */}
<CardContent className="p-6 space-y-8"> <CardContent className="p-6 space-y-8">
{/* Ripping Settings */} {/* Ripping Settings */}
{activeTab === 'ripping' && ( {aktiverReiter === 'ripping' && (
<section className="space-y-6"> <section className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
@@ -697,7 +777,7 @@ export default function SettingsPage() {
)} )}
{/* Verarbeitung */} {/* Verarbeitung */}
{activeTab === 'verarbeitung' && ( {aktiverReiter === 'verarbeitung' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" /> <Cpu size={20} className="text-amber-500" />
@@ -901,12 +981,41 @@ export default function SettingsPage() {
/storage-targets dieselbe Liste wie bei den Speicherzielen, /storage-targets dieselbe Liste wie bei den Speicherzielen,
inklusive freiem Platz. inklusive freiem Platz.
*/} */}
{!betrieb.kann.container_pfade ? (
/*
* Ein echtes Pfadfeld statt einer Auswahl dieselbe
* Entscheidung wie bei der Ablage darüber, und aus demselben
* Grund.
*
* Commander am 28.08.2026: Wäre es möglich das
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
*
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
* kommt aus /storage-targets, und das las `/app/media` ein
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
* genau einem Eintrag, und der hieß Container-Platte".
*
* Die Liste ist inzwischen repariert (sie bringt jetzt die
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
* wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
* Liste kennt D:\Rippy-Arbeit oder eine UNC-Freigabe.
* Deshalb hier ein Feld, in das man ihn schreiben kann.
*/
<OrdnerWaehler
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe}
onChange={(pfad) => handleChange('workDir', pfad)}
/>
) : (
<Select <Select
label="Arbeitsverzeichnis für Roh-Rips (Standard)" label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir} value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)} onChange={(e) => handleChange('workDir', e.target.value)}
> >
<option value="">Container-Platte (Standard) klein, nur für DVD/Blu-ray</option> <option value="">
Container-Platte (Standard) klein, nur für DVD/Blu-ray
</option>
{ziele.map(z => ( {ziele.map(z => (
<option key={z.path} value={z.path}> <option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''} {z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
@@ -914,13 +1023,35 @@ export default function SettingsPage() {
</option> </option>
))} ))}
</Select> </Select>
)}
{!betrieb.kann.container_pfade && !!ziele.length && (
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
wählt anders genau daran ist am 25.07. eine Platte
vollgelaufen. */
<div className="flex flex-wrap gap-1.5">
{ziele.map(z => (
<button
key={z.path}
onClick={() => handleChange('workDir', z.path)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
settings.workDir === z.path
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
}`}
>
{z.name}{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</button>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim <strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300"> Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert. Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
Container-Platte. Am besten dieselbe Freigabe wie das Ziel dann muss Rippy die {betrieb.kann.container_pfade
Rohdatei am Ende nur umhängen statt sie zu kopieren. ? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
{systemInfo?.plaetze?.length ? ( {systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</> <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null} ) : null}
@@ -930,7 +1061,7 @@ export default function SettingsPage() {
)} )}
{/* Worker */} {/* Worker */}
{activeTab === 'worker' && ( {aktiverReiter === 'worker' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" /> <Cpu size={20} className="text-amber-500" />
@@ -941,7 +1072,7 @@ export default function SettingsPage() {
)} )}
{/* Speicherziele */} {/* Speicherziele */}
{activeTab === 'speicherziele' && ( {aktiverReiter === 'speicherziele' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" /> <HardDrive size={20} className="text-amber-500" />
@@ -961,6 +1092,19 @@ export default function SettingsPage() {
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen <HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen
</h3> </h3>
{!betrieb.kann.container_pfade ? (
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
Windows-PC gibt es keine Liste von Container-Zielen, aus
der man waehlen koennte es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */
<OrdnerWaehler
label="Ordner"
value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe}
onChange={(pfad) => handleChange('outputDir', pfad)}
/>
) : (
<Select <Select
label="Ablage" label="Ablage"
value={settings.outputDir} value={settings.outputDir}
@@ -974,6 +1118,7 @@ export default function SettingsPage() {
</option> </option>
))} ))}
</Select> </Select>
)}
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert. für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert.
@@ -1005,12 +1150,15 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<StorageMounts /> {/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
</section> </section>
)} )}
{/* APIs */} {/* APIs */}
{activeTab === 'apis' && ( {aktiverReiter === 'apis' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" /> <Globe size={20} className="text-amber-500" />
@@ -1070,7 +1218,7 @@ export default function SettingsPage() {
)} )}
{/* Benachrichtigungen */} {/* Benachrichtigungen */}
{activeTab === 'benachrichtigungen' && ( {aktiverReiter === 'benachrichtigungen' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" /> <AlertCircle size={20} className="text-amber-500" />
@@ -1120,7 +1268,7 @@ export default function SettingsPage() {
)} )}
{/* System */} {/* System */}
{activeTab === 'system' && ( {aktiverReiter === 'system' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" /> <Wrench size={20} className="text-amber-500" />
@@ -1188,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,
+6
View File
@@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ . COPY docker/worker/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den Worker-Modulen. Celery startet mit /app als Arbeitsverzeichnis,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
File diff suppressed because it is too large Load Diff
+81 -18
View File
@@ -8,10 +8,49 @@ 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 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
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:
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
Bis V2-4 stand hier `shutil.which("HandBrakeCLI")`. Das findet unter
Windows nichts, auch wenn HandBrake installiert ist (Program Files statt
PATH). Die Folge waere gewesen: Rippy meldet "keine Encoder gefunden" auf
einem Rechner, auf dem alles da ist und die Encoder-Auswahl im UI bliebe
leer, ohne dass jemand den Grund saehe.
"""
return werkzeuge.finden("handbrake")
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
@@ -148,12 +187,12 @@ def parse_preset_liste(text: str) -> list:
def hole_handbrake_presets() -> list: def hole_handbrake_presets() -> list:
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert).""" """`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"): if not _hb():
return [] return []
try: try:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--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 ""))
@@ -262,12 +301,12 @@ def cpu_merkmale() -> tuple:
def hole_handbrake_hilfe() -> str: def hole_handbrake_hilfe() -> str:
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert).""" """`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"): if not _hb():
return "" return ""
try: try:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--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):
@@ -330,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", ""),
@@ -349,13 +398,13 @@ def werkzeug_versionen() -> dict:
presets = hole_handbrake_presets() presets = hole_handbrake_presets()
if presets: if presets:
info["presets"] = presets info["presets"] = presets
if shutil.which("makemkvcon"): if werkzeuge.finden("makemkv"):
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
if shutil.which("HandBrakeCLI"): if _hb():
try: try:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--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 ""))
@@ -364,8 +413,8 @@ def werkzeug_versionen() -> dict:
info["handbrake"] = "installiert" info["handbrake"] = "installiert"
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen. # Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
try: try:
import db from rippy import store as db
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip() ui_key = (db.get_settings(bei_fehler_leer=True).get("makemkvAppKey") or "").strip()
except Exception: except Exception:
ui_key = "" ui_key = ""
if ui_key: if ui_key:
@@ -385,11 +434,25 @@ def werkzeug_versionen() -> dict:
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine # Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
# KEYDB.cfg", obwohl ihn das gar nichts angeht. # KEYDB.cfg", obwohl ihn das gar nichts angeht.
try: try:
import makemkv_daten from rippy.rip import makemkv_daten
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:
+1 -1
View File
@@ -32,7 +32,7 @@ celery_app.conf.update(
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr # Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.). # zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
import caps # noqa: E402 import caps # noqa: E402
import db # noqa: E402 from rippy import store as db # noqa: E402
import zombies # noqa: E402 import zombies # noqa: E402
-261
View File
@@ -1,261 +0,0 @@
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
Die Tabellendefinition existiert bewusst identisch in API und Worker
(docker/api/db.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
"""
import os
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
)
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
jobs = Table(
"jobs",
metadata,
Column("id", String(36), primary_key=True),
Column("disc_type", String(16)),
Column("device", String(64)),
Column("title", String(255)),
Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
logs = Table(
"logs",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", DateTime(timezone=True)),
Column("level", String(16)),
Column("source", String(32)),
Column("message", Text),
)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
Die ALTERs stehen identisch in docker/api/db.py wer zuerst startet,
migriert; der andere findet die Spalten dann bereits vor.
"""
metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
import json
payload = json.dumps(encoder_liste)
info_payload = json.dumps(info or {})
with engine.begin() as conn:
vorhanden = conn.execute(
workers.select().where(workers.c.name == name)
).first()
if vorhanden:
conn.execute(
workers.update().where(workers.c.name == name).values(
encoders=payload, info=info_payload, last_seen=utcnow()
)
)
else:
conn.execute(
workers.insert().values(
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
)
)
def get_settings(key: str = "ui") -> dict:
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
import json
from sqlalchemy import select
try:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if zeile and zeile[0]:
return json.loads(zeile[0])
except Exception:
pass
return {}
def get_job(job_id: str) -> dict:
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def save_settings(werte: dict, key: str = "ui") -> None:
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
import json
from sqlalchemy import select
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id)
).first()
return zeile[0] if zeile else ""
def list_jobs_mit_status(stati) -> list:
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile die
Erkennung braucht nichts weiter.
"""
from sqlalchemy import select
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati)))
).mappings().all()
return [dict(z) for z in zeilen]
def zaehle_online_worker(sekunden: int = 120) -> int:
"""Wie viele Worker gelten laut Herzschlag gerade als online?
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
melden sich weniger Worker als bekannt sind, ist die Auskunft
unvollständig dann wird NICHTS als Leiche gewertet.
"""
from datetime import timedelta
from sqlalchemy import func, select
grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn:
anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar()
return int(anzahl or 0)
def meta_merken(job_id: str, **felder) -> None:
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
`update_job(meta=...)` würde die Spalte ersetzen Poster, Jahr, Titel-Wahl
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
schreiben.
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern im
schlimmsten Fall fehlt die Marke und der Neu"-Knopf fragt nach.
"""
import json
try:
zeile = get_job(job_id)
if not zeile:
return
try:
vorher = json.loads(zeile.get("meta") or "{}")
except (ValueError, TypeError):
vorher = {}
if not isinstance(vorher, dict):
vorher = {}
vorher.update(felder)
update_job(job_id, meta=json.dumps(vorher))
except Exception as e:
try:
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
except Exception:
pass
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
-97
View File
@@ -1,97 +0,0 @@
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices die Ausgabe ist
immer nur "block special", die Erkennung lieferte also strukturell IMMER
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
bewies damit nichts.
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
"""
import os
import struct
from fcntl import ioctl
# include/uapi/linux/cdrom.h
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def _open_nonblock(device_path: str) -> int:
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
def drive_status(device_path: str) -> int:
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
finally:
os.close(fd)
def disc_status(device_path: str) -> int:
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DISC_STATUS, 0)
finally:
os.close(fd)
def disc_size_bytes(device_path: str) -> int:
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
fd = _open_nonblock(device_path)
try:
buf = bytearray(8)
ioctl(fd, BLKGETSIZE64, buf)
return struct.unpack("Q", bytes(buf))[0]
finally:
os.close(fd)
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
def detect_disc_type(device_path: str) -> str:
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
try:
if drive_status(device_path) != CDS_DISC_OK:
return "no_disc"
return classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
return "unknown"
+1 -1
View File
@@ -188,7 +188,7 @@ def deinstallieren() -> None:
def _bruecke_bauen(): def _bruecke_bauen():
try: try:
import db from rippy import store as db
import logbruecke import logbruecke
db.init_db() db.init_db()
-66
View File
@@ -1,66 +0,0 @@
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D):
- Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook
- Slack: POST JSON {"text": "..."} api.slack.com/messaging/webhooks
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title=
docs.ntfy.sh/publish
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
(Home Assistant, n8n, eigene Skripte).
"""
import requests
TIMEOUT_SEKUNDEN = 10
def erkenne_webhook_typ(url: str) -> str:
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
u = url.lower()
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
return "discord"
if "hooks.slack.com" in u:
return "slack"
if "ntfy" in u:
return "ntfy"
return "generisch"
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
typ = erkenne_webhook_typ(url)
if typ == "discord":
return typ, {"content": f"**{titel}**\n{text}"}
if typ == "slack":
return typ, {"text": f"*{titel}*\n{text}"}
if typ == "ntfy":
return typ, None
return typ, {"title": titel, "message": text, "level": level}
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
typ, payload = baue_payload(url, titel, text, level)
try:
if typ == "ntfy":
antwort = requests.post(
url, data=text.encode("utf-8"),
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
)
else:
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
except requests.RequestException as e:
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
if antwort.status_code >= 300:
raise RuntimeError(
f"Webhook antwortete mit HTTP {antwort.status_code}: "
f"{(antwort.text or '')[:200]}"
)
+399 -128
View File
@@ -17,7 +17,54 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
from winlauf import OHNE_FENSTER # Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
#
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
from rippy.drives.linux import ( # noqa: F401
AUSWURF_WARTEN_SEKUNDEN,
CDROM_DRIVE_STATUS,
CDROM_LOCKDOOR,
CDROMCLOSETRAY,
CDROMEJECT,
CDS_DISC_OK,
CDS_DRIVE_NOT_READY,
CDS_NO_DISC,
CDS_TRAY_OPEN,
_auswurf_geglueckt,
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
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
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
def werkzeug(name: str) -> str:
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
## Warum hier nicht mehr `shutil.which` steht
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das dort liegen
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
beide installiert sind: Windows-Programme liegen in Program Files und
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen
MakeMKV lag da, und `shutil.which` sah es nicht.
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
der Unterschied zwischen "laeuft" und "laeuft nicht".
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
damit NICHTS subprocess findet das Programm dort ueber den PATH wie
bisher.
"""
return werkzeuge.finden(name) or _NACKTER_NAME[name]
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media") RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
@@ -27,114 +74,9 @@ class RipAbbruch(Exception):
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB).""" Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py
CDROMEJECT = 0x5309
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
CDROM_DRIVE_STATUS = 0x5326
CDROMCLOSETRAY = 0x5319
# Antworten von CDROM_DRIVE_STATUS (cdrom.h)
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
AUSWURF_WARTEN_SEKUNDEN = 5
def _auswurf_geglueckt(status: int) -> bool:
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
Sowohl Schublade offen" als auch „kein Datenträger" zählen: Ein
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
"""
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None,
schliessen=None, warten=None) -> bool:
"""Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE.
## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen)
Der Commander meldete: Der Button gibt es in den Settings, aber es passiert
nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt:
wirf_disc_aus("/dev/sr0") True
CDROM_DRIVE_STATUS danach 4 (Disc drin)
Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV
verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und
entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf
trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides:
erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc:
CDROM_LOCKDOOR 0 + CDROMEJECT Status 2 (SCHUBLADE OFFEN)
## Und deshalb wird das Ergebnis geprüft, nicht geglaubt
Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier
Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung
wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im
Log stand Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts;
gefragt wird jetzt das Laufwerk.
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux der native
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
"""
if ioctl_fn is None:
try:
from fcntl import ioctl
except ImportError: # Windows — dieser Worker rippt nie
return False
ioctl_fn = ioctl
if warten is None:
import time
warten = time.sleep
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
schliessen = schliessen or os.close
try:
fd = oeffnen(device_path)
except OSError:
return False
try:
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
try:
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass
try:
ioctl_fn(fd, CDROMEJECT, 0)
except OSError:
return False
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
try:
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
return True
except OSError:
return False
warten(1)
return False
finally:
try:
schliessen(fd)
except OSError:
pass
def check_makemkv_installed() -> bool: def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist.""" """Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None return bool(werkzeuge.finden("makemkv"))
def check_abcde_installed() -> bool: def check_abcde_installed() -> bool:
@@ -147,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).
@@ -158,12 +118,12 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm) mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
""" """
return [ return [
"makemkvcon", werkzeug("makemkv"),
"-r", "-r",
"--noscan", "--noscan",
"--progress=-same", "--progress=-same",
"mkv", "mkv",
f"dev:{device_path}", makemkv_quelle(device_path),
str(titel), str(titel),
output_dir, output_dir,
] ]
@@ -318,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(
["makemkvcon", "-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)
@@ -332,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:
@@ -339,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):
@@ -391,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(
["makemkvcon", "-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:
@@ -420,8 +386,8 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
return 0 return 0
try: try:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["HandBrakeCLI", "--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):
@@ -467,7 +433,7 @@ def get_progress_from_prgv(line: str) -> int:
def check_handbrake_installed() -> bool: def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist.""" """Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") is not None return bool(werkzeuge.finden("handbrake"))
DEFAULT_HB_PRESET = "H.265 MKV 1080p30" DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
@@ -535,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:
@@ -560,20 +532,55 @@ 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 = [
"HandBrakeCLI", werkzeug("handbrake"),
"--input", input_path, "--input", input_path,
"--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)]
@@ -638,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:
@@ -661,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:
@@ -690,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
@@ -701,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
@@ -718,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",
@@ -732,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,
} }
@@ -770,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.
@@ -784,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:
@@ -791,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",
@@ -809,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:
@@ -819,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:
@@ -847,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 "")
@@ -899,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",
+9 -2
View File
@@ -45,7 +45,7 @@ import re
import subprocess import subprocess
import urllib.request import urllib.request
from winlauf import OHNE_FENSTER from rippy.platform.winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"` # `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"') DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
@@ -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:
+60 -952
View File
File diff suppressed because it is too large Load Diff
+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"
+10 -1
View File
@@ -7,7 +7,7 @@ erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
zwei noch nie angelegte Ordner. zwei noch nie angelegte Ordner.
""" """
import tasks import ablauf as tasks
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt ------------- # --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
@@ -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
+77 -17
View File
@@ -105,7 +105,7 @@ def test_matche_episoden_ohne_treffer_gibt_none():
def test_pfad_lokal_uebersetzt_fuer_windows_worker(): def test_pfad_lokal_uebersetzt_fuer_windows_worker():
from tasks import pfad_lokal from ablauf import pfad_lokal
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp" mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc" assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
@@ -121,30 +121,39 @@ 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 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():
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith(). """Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb.""" /app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
import tasks import ablauf as tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True assert tasks.unter_wurzel("/app/media", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
@@ -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():
import tasks """Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
Windows-Reparatur nichts."""
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 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
+1 -1
View File
@@ -13,7 +13,7 @@ import os
import pytest import pytest
import tasks import ablauf as tasks
class FakeDb: class FakeDb:
+163 -80
View File
@@ -13,14 +13,22 @@ 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,
) )
def test_makemkv_cmd_vollstaendig(): def test_makemkv_cmd_vollstaendig():
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x") cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert cmd[0] == "makemkvcon" # Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter
# Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem
# nackten Namen faende `subprocess` es dort nie. Im Container bleibt es
# der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf
# dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig;
# gepruefet wird deshalb, WORAUF der Befehl zeigt.
assert "makemkvcon" in cmd[0].lower()
assert "-r" in cmd # Robot-Mode: maschinenlesbar assert "-r" in cmd # Robot-Mode: maschinenlesbar
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.) assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
assert "--progress=-same" in cmd # Fortschritt im selben Stream assert "--progress=-same" in cmd # Fortschritt im selben Stream
@@ -34,15 +42,65 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
für Blu-rays prinzipiell funktionsunfähig).""" für Blu-rays prinzipiell funktionsunfähig)."""
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv") cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
assert cmd[0] == "HandBrakeCLI" # Wie beim MakeMKV-Befehl: seit V2-4 steht hier der GEFUNDENE Pfad. Unter
# Windows liegt HandBrakeCLI in Program Files oder in Rippys eigenem
# Werkzeug-Ordner und nicht im PATH — mit dem nackten Namen faende
# `subprocess` es dort nie.
assert "handbrakecli" in cmd[0].lower()
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv" assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
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
@@ -365,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:
@@ -380,91 +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
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) ----- 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,
def _laufwerk(verriegelt=True, kennt_lockdoor=True): aber es ist keine Datei entstanden" samt der falschen Vermutung
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt. Zielordner nicht beschreibbar" — und die schickte die Suche in die
vollkommen falsche Richtung.
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
""" """
import ripping import ripping
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK, class FakeProcess:
"aufrufe": []} 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 ioctl_fn(fd, befehl, arg=0): def kill(self):
zustand["aufrufe"].append(befehl) pass
if befehl == ripping.CDROM_LOCKDOOR:
if not kennt_lockdoor: def wait(self):
raise OSError("ioctl unbekannt")
zustand["verriegelt"] = bool(arg)
return 0 return 0
if befehl == ripping.CDROMEJECT:
if not zustand["verriegelt"]:
zustand["status"] = ripping.CDS_TRAY_OPEN
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
if befehl == ripping.CDROM_DRIVE_STATUS:
return zustand["status"]
raise OSError("unerwartetes ioctl")
return zustand, ioctl_fn ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["status"] == "error"
assert "--audio-codec" in ergebnis["error"]
def test_auswurf_entriegelt_zuerst_und_klappt_dann(): assert "Fehler in Rippy" in ergebnis["error"]
import ripping # ... und NICHT die alte Vermutung über den Zielordner
assert "beschreibbar" not in ergebnis["error"]
zustand, ioctl_fn = _laufwerk(verriegelt=True)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is True
assert zustand["status"] == ripping.CDS_TRAY_OPEN
# Reihenfolge: entriegeln VOR auswerfen
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
import ripping
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is False
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
import ripping
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
def test_auswurf_ohne_laufwerk_wirft_nicht():
import ripping
def oeffnen_kaputt(p):
raise OSError("kein Laufwerk")
assert ripping.wirf_disc_aus(
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
schliessen=lambda fd: None, warten=lambda s: None) is False
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) ----------- # --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
@@ -593,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]
+5 -1
View File
@@ -221,7 +221,11 @@ def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
import os import os
import re import re
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py") # Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ablauf.py")
quelle = open(pfad, encoding="utf-8").read() quelle = open(pfad, encoding="utf-8").read()
# Nur die Aufrufe, die wirklich die Job-Zeile aendern. # Nur die Aufrufe, die wirklich die Job-Zeile aendern.
-32
View File
@@ -1,32 +0,0 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben genau das alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
"""
import subprocess
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
+254
View File
@@ -0,0 +1,254 @@
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
## Aufruf
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
* pyinstaller, pystray, pillow
* Das UI muss gebaut sein: cd docker/ui && npm run build
## Warum die Versions-Ressource nicht optional ist
Ohne sie steht im Taskmanager unter Beschreibung" nichts, und in den
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte
und Windows SmartScreen sieht das genauso.
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
daneben aus dieser Ressource.
## Was mit hinein muss
docker/api/ die API-Module (main.py und Nachbarn)
docker/ui/dist/ die gebaute Weboberflaeche
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/`
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
"""
import argparse
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
VERSION_VORLAGE = """
VSVersionInfo(
ffi=FixedFileInfo(
filevers=({v0}, {v1}, {v2}, 0),
prodvers=({v0}, {v1}, {v2}, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(u'040704b0', [
StringStruct(u'CompanyName', u'Rippy'),
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
StringStruct(u'FileVersion', u'{version}'),
StringStruct(u'InternalName', u'Rippy'),
StringStruct(u'OriginalFilename', u'Rippy.exe'),
StringStruct(u'ProductName', u'Rippy'),
StringStruct(u'ProductVersion', u'{version}')])
]),
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
# Windows die Angaben je nach Systemsprache gar nicht an.
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
]
)
"""
def versionsdatei(version: str, ordner: str) -> str:
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
pfad = os.path.join(ordner, "version_info.txt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(VERSION_VORLAGE.format(
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
return pfad
def pruefen() -> list:
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
aussieht wie fertig und es nicht ist.
"""
maengel = []
if sys.platform != "win32":
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
maengel.append("docker/api/main.py fehlt.")
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
"die App nicht rippen.")
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
maengel.append(
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
"Abhilfe: cd docker/ui && npm ci && npm run build")
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
if not os.path.isfile(icon):
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
else:
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
# noch keine Datei, die funktioniert.
import sys as _sys
_sys.path.insert(0, HIER)
from icon import fehlende_pflichtgroessen
fehlt = fehlende_pflichtgroessen(icon)
if fehlt:
maengel.append(
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (fehlt,))
try:
import PyInstaller # noqa: F401
except ImportError:
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
for modul, paket, wofuer in (
("pystray", "pystray", "kein Tray-Symbol"),
("PIL", "pillow", "kein Tray-Symbol"),
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
):
try:
__import__(modul)
except ImportError:
maengel.append(
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
return maengel
def bauen(ausgabe: str, version: str) -> str:
arbeit = os.path.join(ausgabe, "_bau")
os.makedirs(arbeit, exist_ok=True)
trenner = ";" # Windows-Trennzeichen fuer --add-data
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
import sys as _sys
_sys.path.insert(0, HIER)
from werkzeuge import paket_ordner_bauen
werkzeug_ordner = paket_ordner_bauen(ausgabe)
if not werkzeug_ordner:
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
befehl = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--onefile",
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
# Sekunden auf, bis Python so weit ist.
#
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
"--windowed",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
"--distpath", ausgabe,
"--workpath", os.path.join(arbeit, "work"),
"--specpath", arbeit,
"--paths", os.path.join(REPO, "src"),
"--paths", os.path.join(REPO, "docker", "api"),
"--paths", os.path.join(REPO, "docker", "worker"),
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "rippy.drives.windows",
"--hidden-import", "pystray._win32",
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
# genau dorthin, wovon wir gerade weg wollten.
"--hidden-import", "webview",
"--hidden-import", "webview.platforms.winforms",
"--hidden-import", "webview.platforms.edgechromium",
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
# und beides zieht Ballast nach.
"--exclude-module", "psycopg2",
"--exclude-module", "tkinter",
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
]
if werkzeug_ordner:
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
print("PyInstaller wird aufgerufen …\n")
ergebnis = subprocess.run(befehl, cwd=REPO)
if ergebnis.returncode != 0:
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
exe = os.path.join(ausgabe, "RippySetup.exe")
if not os.path.isfile(exe):
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
return exe
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
print("Rippy — Windows-Bau")
print(" Repo %s" % REPO)
print(" Ausgabe %s" % args.ausgabe)
print(" Version %s" % args.version)
print()
maengel = pruefen()
if maengel:
print("Der Bau kann so nicht laufen:")
for m in maengel:
print(" X %s" % m)
return 1
print("Alle Voraussetzungen erfuellt.")
if args.nur_pruefen:
return 0
os.makedirs(args.ausgabe, exist_ok=True)
exe = bauen(args.ausgabe, args.version)
groesse = os.path.getsize(exe) / 1024**2
print()
print("Fertig: %s (%.1f MB)" % (exe, groesse))
arbeit = os.path.join(args.ausgabe, "_bau")
shutil.rmtree(arbeit, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
"""Einstiegspunkt der gebuendelten RippySetup.exe.
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
steht, laesst sich nicht testen also steht hier so wenig wie moeglich.
## Warum hier trotzdem etwas zur Ausgabe steht
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
KEINE Konsole aufgeht ausdruecklicher Wunsch des Commanders am 28.08.2026:
Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
schreibt uvicorn tut das , wuerde mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
zwar mitten im Start, ohne dass irgendwo etwas stuende.
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
1. In einem Terminal gestartet? -> dort hineinschreiben
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
"""
import multiprocessing
import sys
def _ausgabe_richten() -> None:
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return # aus dem Quellcode gestartet
if winlauf.an_elternkonsole_haengen():
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
# sys.stdout trotz Konsole None.
try:
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
buffering=1)
sys.stderr = sys.stdout
return
except OSError:
pass
winlauf.ausgabe_umleiten()
def main() -> int:
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
multiprocessing.freeze_support()
_ausgabe_richten()
from rippy.windows_app import main as rippy_main
return rippy_main()
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
## Der Befund (28.08.2026)
Commander: *Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
Leeres Blatt)."*
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
Bilder = 1
256x256 32 bit 5657 Bytes PNG
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
die passende Größe aus der Datei:
Taskleiste, Verknüpfung klein 16x16
Desktop, Startmenü 32x32 / 48x48
Große Kacheln, Alt-Tab 256x256
Fehlt die passende Größe, skaliert die Shell nicht immer selbst sie zeigt
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
enthält, passiert das zuverlässig.
## Was diese Datei tut
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
Die kleinen Größen werden mit `LANCZOS` verkleinert bei 16x16 ist das der
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
Aufruf (macht der Bau selbst, siehe `build.py`):
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
"""
import argparse
import os
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def groessen_in(pfad: str) -> list:
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
Fehler, den es hier zu verhindern gilt.
"""
import struct
with open(pfad, "rb") as f:
roh = f.read()
if len(roh) < 6:
return []
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
if reserviert != 0 or typ != 1:
return []
gefunden = []
for i in range(anzahl):
block = roh[6 + i * 16: 22 + i * 16]
if len(block) < 16:
break
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
gefunden.append(breite)
return sorted(gefunden)
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
PFLICHTGROESSEN = (16, 32, 48, 256)
def fehlende_pflichtgroessen(pfad: str) -> tuple:
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
vorhanden = set(groessen_in(pfad))
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
def bauen(quelle: str = None, ziel: str = None) -> str:
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
Wirft, wenn es nicht geht ein Symbol, das still fehlschlaegt, fuehrt
genau zu dem leeren Blatt, das hier abgestellt werden soll.
"""
from PIL import Image
quelle = quelle or QUELLE
ziel = ziel or quelle
bild = Image.open(quelle)
bild = bild.convert("RGBA")
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
# Groesse, und aus 16x16 laesst sich kein 256er machen.
if hasattr(bild, "size") and bild.size[0] < 256:
try:
bild = Image.open(quelle)
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
bild = bild.convert("RGBA")
except Exception: # noqa: BLE001
pass
ebenen = []
for kante in GROESSEN:
if kante >= bild.size[0]:
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
else:
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
# zwischen einem erkennbaren Zeichen und Pixelbrei.
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
ebenen[-1].save(ziel, format="ICO",
sizes=[(k, k) for k in GROESSEN])
return ziel
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
p.add_argument("--quelle", default=QUELLE)
p.add_argument("--ziel", default=None)
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
vorher = groessen_in(args.quelle)
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
if args.nur_pruefen:
return 0 if len(vorher) > 1 else 1
ziel = bauen(args.quelle, args.ziel)
print("Nachher: %s" % groessen_in(ziel))
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
#
# py -3.12 -m venv venv
# venv\Scripts\pip install -r docker/api/requirements.txt
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
# venv\Scripts\python packaging/windows/build.py
#
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
# ist eine Entscheidung, kein Zufall.
pyinstaller==6.22.2
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
# zu beenden, ausser ueber den Taskmanager.
pystray==0.19.5
pillow==12.3.0
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
pywebview==6.2.1
pythonnet==3.1.0
+76
View File
@@ -0,0 +1,76 @@
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
## Der Befund (28.08.2026)
Commander: *Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
Leeres Blatt)."*
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
Windows holt sich fuer jede Stelle die passende Groesse:
Taskleiste, kleine Verknuepfung 16x16
Desktop, Startmenue 32x32 / 48x48
Alt-Tab, grosse Kacheln 256x256
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst sie zeigt das leere
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
regelmaessig.
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
"""
import os
import pytest
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def _icon_modul():
import sys
sys.path.insert(0, HIER)
import icon
return icon
def _groessen():
return _icon_modul().groessen_in(ICON)
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
# fordern, die dieser Test nicht prueft — oder umgekehrt.
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
def test_icon_ist_da():
assert os.path.isfile(ICON), ICON
def test_icon_ist_eine_gueltige_ico():
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
def test_pflichtgroesse_ist_enthalten(kante):
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
vorhanden = _groessen()
assert kante in vorhanden, (
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (kante, kante, vorhanden)
)
def test_eine_einzige_groesse_reicht_nie():
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
assert len(_groessen()) > 1, (
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
"gefuehrt hat."
)
+135
View File
@@ -0,0 +1,135 @@
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
Commander am 28.08.2026: *handbrake und makemkv MÜSSEN mitgeliefert werden
oder während des Setups separat installiert werden! Ohne das ist das tool
NICHT einsatzfähig."*
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
die Lizenz, nicht die Bequemlichkeit:
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
startet dessen Installer der Nutzer bezieht sie also weiterhin von
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
## Warum die Datei nicht im Repo liegt
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
"""
import os
import shutil
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
# verlangt ihn bei jeder Weitergabe.
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
steht unter der GPL-2. Das bedeutet fuer dich:
* Du darfst es weitergeben und veraendern.
* Der Quelltext ist oeffentlich verfuegbar unter:
https://github.com/HandBrake/HandBrake
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
Mitgelieferte Fassung: {version}
Bezogen von: {url}
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
installiert.
"""
def vendor_ordner(basis: str = None) -> str:
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
Gibt `{"pfad": , "version": , "geladen": bool}` zurück; `pfad` ist
leer, wenn es nicht geklappt hat. Wirft NICHT ein Bau ohne Netz soll
möglich bleiben, muss dann aber laut sagen, was fehlt.
"""
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import beschaffen
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
melden(" HandBrakeCLI wird geholt …")
try:
pfad = beschaffen.handbrake_holen(
ziel_ordner=ordner,
fortschritt=lambda t, anteil=None: melden(" " + t))
except Exception as e: # noqa: BLE001
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
return {"pfad": "", "version": "", "geladen": False}
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
def _version_von(exe: str) -> str:
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import katalog
try:
return katalog.version_von("handbrake", exe) or ""
except Exception: # noqa: BLE001
return ""
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
Struktur genau die, in der `katalog.kandidaten()` sucht:
tools/handbrake/HandBrakeCLI.exe
tools/handbrake/LIZENZ-HandBrake.txt
"""
hb = handbrake_bereitstellen(ziel_basis, melden)
if not hb["pfad"]:
return ""
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
ordner = os.path.join(basis, "handbrake")
os.makedirs(ordner, exist_ok=True)
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
shutil.copy2(hb["pfad"], ziel_exe)
from rippy.tools import beschaffen
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
encoding="utf-8") as f:
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
url=beschaffen.HANDBRAKE_API))
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
return basis
if __name__ == "__main__":
ordner = paket_ordner_bauen()
print("Paket-Ordner:", ordner or "(leer)")
raise SystemExit(0 if ordner else 1)
+11
View File
@@ -0,0 +1,11 @@
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
node_modules/
out/
*.tsbuildinfo
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
dist-setup*/
vendor/
.electron-cache/
+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

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