63 Commits

Author SHA1 Message Date
Hitonabi a14449ef85 fix(worker-ui): fix syntax error in gui.py and ruff error in test_verwaltung.py
Ampel / ampel (push) Successful in 39s
2026-07-27 17:17:30 +02:00
Hitonabi 4343b0901d feat(worker-ui): adjust thumbnails, log view, and button logic to match web dashboard
Ampel / ampel (push) Failing after 40s
2026-07-27 17:16:00 +02:00
Hitonabi c3e77399ad fix(worker): mock ntpath.dirname in test_erreichbarkeit so it passes on Linux CI runner
Ampel / ampel (push) Failing after 39s
2026-07-27 17:13:45 +02:00
Hitonabi 75426224d1 fix(worker): ensure sys.path in test_verwaltung for pytest importlib mode
Ampel / ampel (push) Failing after 39s
2026-07-27 17:11:11 +02:00
Hitonabi e5ff5373bb fix(worker): restore pure logic module verwaltung.py and its tests (SoC)
Ampel / ampel (push) Failing after 39s
2026-07-27 17:06:38 +02:00
Hitonabi 9b30c0a490 chore: remove obsolete test_verwaltung.py
Ampel / ampel (push) Failing after 40s
2026-07-27 17:02:37 +02:00
Hitonabi 061f79ecda fix: ruff linter errors (multiple statements, bare except, unused vars)
Ampel / ampel (push) Failing after 38s
2026-07-27 17:00:52 +02:00
Hitonabi a32a1bb075 fix: bare except in main.py
Ampel / ampel (push) Failing after 38s
2026-07-27 16:56:36 +02:00
Hitonabi 32979d7bde docs: SAVEPOINT update fuer Etappe 25 (Windows Worker in Flet)
Ampel / ampel (push) Failing after 40s
2026-07-27 16:53:59 +02:00
Hitonabi d15f4b2ab6 feat: Thumbnails, bunte Logs und Button-Status im Worker UI 2026-07-27 16:17:30 +02:00
Hitonabi a221a161f4 feat: Tray-Icon fuer Worker GUI (pystray) — Fenster schliessen minimiert ins Tray 2026-07-26 22:33:54 +02:00
Hitonabi 264b2c77f0 chore: finale exe mit venv --clear fix neu gebaut 2026-07-26 22:32:12 +02:00
Hitonabi 16ea54e35d fix: venv --clear bei Reinstall, verhindert veraltete Paketversionen 2026-07-26 22:30:12 +02:00
Hitonabi 81ba6bd1c6 fix: don't override pinned flet==0.23.2 with unpinned pip install 2026-07-26 22:24:20 +02:00
Hitonabi 06c1718a52 fix: installer buttons (Durchsuchen/Freigabe holen), Worker-Start via os.startfile, Shortcut via PowerShell 2026-07-26 22:15:50 +02:00
Hitonabi de500f9890 feat: complete rewrite of worker installer in Flet (standalone exe) 2026-07-26 22:07:24 +02:00
Hitonabi 9a151f3d8b feat: Worker V2 Flet GUI mit Feature-Parität und neuem Installer 2026-07-26 22:01:27 +02:00
Hitonabi e165e2a0d3 feat: Komplettierung der aktuellen Rippy-Etappe
Ampel / ampel (push) Failing after 29s
- Versionsanzeige für den Windows-Worker im UI inkl. Prüfung
- Absicherung der install.sh gegen fehlendes systemd
- Serien-Episoden-Erkennung und Laufzeitabgleich anhand TMDB-Daten (Heuristik)
- Umstellung der Windows-Worker-Installation auf SchTasks (Dienst-Ersatz)
- Kodi-Bibliotheks-Refresh über JSON-RPC integriert
2026-07-26 21:13:27 +02:00
Hitonabi d15900d1eb fix(audio): nur die beste Tonspur pro Sprache verlustfrei kopieren
Ampel / ampel (push) Failing after 29s
2026-07-26 20:55:57 +02:00
Hitonabi 4cb1fab416 docs(savepoint): die Kette ist zum ersten Mal ganz durchgelaufen
Ampel / ampel (push) Failing after 30s
40,9 GB Rip -> Auswurf -> Kompression auf dem Windows-PC -> 12,29 GB abgelegt,
Roh-Verzeichnis danach automatisch aufgeraeumt. Die Sprachwahl am ERGEBNIS
nachgeprueft (HandBrakeCLI --scan auf die abgelegte Datei): nur noch deutsche
Ton- und Untertitelspuren, kein Japanisch, keine unbenannten - vorher meldete
der Disc-Scan deu 4x, jpn 2x, und 4x.

Ein Fund aus dem Ergebnis, bewusst NICHT still repariert: Der Ton ist fuenfmal
MP3 2.0 mit 160 kbps. Das Preset "HQ 1080p30 Surround" schreibt laut
--preset-export zwei Regeln vor (av_aac stereo 160 / Surround 640); angewandt
wurde nur die erste, auf jede behaltene Spur. Der Surround-Ton eines Presets
namens "Surround" faellt damit still weg. Warum der Encoder MP3 statt av_aac
wurde, ist ungeklaert - Rippy uebergibt keinen Audio-Schalter. Als offener
Punkt notiert statt geraten; die Preset-Wahl ist eine Entscheidung des
Commanders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:52:12 +02:00
Hitonabi 4915bba0b3 docs(savepoint): der volle Durchlauf - vier Belege und ein gefundener Fehler
Ampel / ampel (push) Failing after 30s
Belegt: Auswurf im automatischen Weg (bisher nur der UI-Knopf), Phasen-Marke
springt bei der Uebergabe auf true, Sprachwahl kommt beim externen Encoder an
("Ton: deu, Untertitel: deu"), Mount-Wache heilt in 4,2 s.

Gefunden: Jeder erste Film in einer neuen Ablage war unerreichbar - die Pruefung
liess nur eine fehlende Ordner-Ebene durch, obwohl makedirs die ganze Kette
anlegt. Der Fehler wartete seit v3.17 darauf, dass jemand eine frische Ablage
benutzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:47:27 +02:00
Hitonabi 96c400f2f2 fix(worker): erster Film in einer neuen Ablage war immer unerreichbar
Ampel / ampel (push) Failing after 30s
Im ersten vollstaendigen Durchlauf aufgefallen: Der Rip lief sauber durch (40 GB
Akira, Marke rip_fertig sprang korrekt auf true), die Kompression brach 0,2 s
spaeter ab mit "Dieser Worker erreicht die Ziel (fertige Datei) nicht:
\\192.168.178.62\rippy\movies\Akira (1988)".

Die Freigabe war erreichbar. Es fehlten zwei noch nie angelegte Ordner - movies
und der Filmordner darin. Die Pruefung liess aber nur EINE fehlende Ebene durch
(sie sah nach dirname), obwohl gleich darauf os.makedirs die ganze Kette anlegt.
Damit war jeder ERSTE Film in einer neuen Ablage systematisch unerreichbar.
Jetzt zaehlt, ob irgendein Vorfahre existiert (erster_vorhandener_ordner,
begrenzt auf 12 Stufen, damit auf einer toten Freigabe nicht endlos geklopft
wird).

Zweiter Fehler in derselben Meldung: Sie behauptete "RIPPY_PATH_MAP ist gesetzt,
deckt diesen Pfad aber nicht ab" - und nannte im selben Satz den korrekt
uebersetzten UNC-Pfad. Wer dem folgte, suchte in der Karte statt in der
Freigabe. Die Karte wird nur noch beschuldigt, wenn sie den Pfad wirklich nicht
angefasst hat; sonst steht da, was zutrifft: uebersetzt, aber gerade nicht
erreichbar. Dazu der Artikel-Fehler "erreicht die Ziel" behoben.

Diese Pruefung hatte KEINEN einzigen Test - deshalb kam beides durch. Jetzt 12
(301 gruen). Beim Schreiben ist noch ein dritter Fehler aufgefallen:
`isdir=os.path.isdir` als Vorgabewert bindet die Funktion beim IMPORT, ein
Ersetzen geht danach ins Leere. Aufloesung jetzt beim Aufruf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:43:51 +02:00
Hitonabi 936b0837bf docs(savepoint): Uhrzeit-Meldung erledigt - mit dem Beweis, der zaehlt
Ampel / ampel (push) Successful in 32s
Die "Substantial drift 7200 seconds"-Meldung kam nicht alle paar Sekunden,
sondern einmal pro Absender-Hostname (celery memoized nach hostname) - und der
Container-Hostname wechselt bei jedem Neubau. Die vier Meldungen im Log sind
genau vier Deploys.

Der Beweis, dass sie weg ist: Um 14:57:33 synchronisierte sich der Worker mit
celery@d160eb2f3afd, dem frischen Container nach dem Deploy. Ein NEUER Hostname
umgeht die Merk-Sperre, die Warnung haette also feuern muessen. Sie kam nicht.
Dazu gemessen: TZ=UTC wirkt auf Windows wirklich (time.timezone -3600 -> 0), und
die celery-Zeitstempel im lokalen Worker-Log stehen in UTC.

Ausserdem festgehalten: Der Windows-Worker laeuft auf gemischten Dateistaenden
(tasks.py 13:19, ripping.py 14:06, zombies.py 14:17) - die Sprachwahl ist
vollstaendig drin, meta_merken fehlt noch. Es fehlt eine Versionsanzeige, die
so etwas sichtbar macht, statt es nur beim Nachsehen auf dem PC zu finden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:23:14 +02:00
Hitonabi a9cfcb272a docs(konzept): Rate-Limit 100/min -> 600/min als Abweichung vermerkt
Ampel / ampel (push) Successful in 32s
Regel B: keine stille Aenderung an einer KONZEPT-Vorgabe. Das Muss-Feature
"Rate-Limiting pro Client-IP" bleibt, nur die Zahl aendert sich - mit der
Rechnung, die zeigt warum: 100/min lag unter Rippys eigener Last (ein offener
Tab braucht 111/min, ein Windows-Worker 12 dazu). Festgehalten durch
test_grenze_deckt_die_eigene_last_ab, damit die Zahl nicht unbemerkt zurueck
unter die Grundlast wandert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:09:23 +02:00
Hitonabi 163216a68e docs(savepoint): v3.20 - die Bremse war das Problem, nicht die Last
Ampel / ampel (push) Successful in 31s
SAVEPOINT v3.20 mit den Messwerten: /jobs und /capabilities byteweise identisch
ueber zwanzig Sekunden (es lud also nichts neu), 97 Antworten mit HTTP 429 im
nginx-Log, 812 von 876 Anfragen scheinbar von einer IP, und die Rechnung, die
zeigt warum: ein offener Tab braucht 123 Anfragen/min, erlaubt waren 100.

Dazu drei neue Lehren in AGENTS.md:
- Ein verpasster Abruf ist keine Nachricht ueber die Welt (`catch(() => [])`).
- Eine eigene Schutzbremse gegen die eigene Last rechnen - und jedes Greifen
  protokollieren, sonst ist sie unsichtbar.
- Eine geschluckte Warnung ist eine Falle (`|| echo` in einem 200-Zeilen-Log).
- Und: wenn der Commander eine Korrelation nennt, ist das eine Spur, auch wenn
  seine vermutete Erklaerung daneben liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:06:56 +02:00
Hitonabi 9156e8a4a9 fix(deploy): leere Argumente und eine fehlende .env brachen das Deploy - still
Ampel / ampel (push) Successful in 30s
Zwei Funde beim Deployen, beide fuer die Weitergabe relevant:

1. `./deploy.sh` ohne Argumente - der dokumentierte Normalfall - endete mit
   "line 5: $4: unbound variable". Leere Argumente ueberleben den Weg durch ssh
   nicht: Die Gegenseite bekommt die Befehlszeile als EINEN String und parst sie
   neu, wobei "" ersatzlos verschwindet. ${4:-} statt "$4".

2. Fehlte die .env-Quelle, war das eine geschluckte Warnung. Auf der Ziel-VM lag
   die echte .env unter ~/projects/rippy/.env, der Default zeigte auf
   ~/rippy/.env. Das `cp` schlug also jedes Mal fehl, die Warnung scrollte im
   Build-Rauschen vorbei, und gebaut wurde mit der Kopie im Klon: zwei Tage alt,
   darin ein MAKEMKV_URL_BASE-Notbehelf auf einen web.archive.org-Schnappschuss
   (liefert inzwischen 525, waehrend makemkv.com wieder 200 gibt) und ein
   JWT_SECRET_KEY aus der Zeit vor dem Auth-Rueckbau. Jeder worker-Build brach
   daran ab, und die Ursache stand nirgends.

Jetzt: .env uebernommen -> es steht da. Quelle fehlt, Klon hat eine -> laute
Warnung samt Dateidatum und Kandidatenliste. Keine von beiden -> Abbruch, statt
mit leerem DB-Passwort zu bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:57:12 +02:00
Hitonabi bfb13f44a5 fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s
Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.

## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"

Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.

Drei Fehler griffen ineinander:

1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
   offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
   und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
   von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
   (812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
   fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
   keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
   stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
   alles zurueck.

Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.

## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"

Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.

Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.

10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:49:10 +02:00
Hitonabi f7555a17d1 fix(zombies): die Fehlermeldung unterscheidet jetzt Rip von Kompression
Ampel / ampel (push) Successful in 29s
Nachtrag, direkt am echten Fall aufgefallen. Die Zombie-Erkennung hat den toten
Rip gefunden (Beleg: nach 140 s noch "processing 12 %", nach 160 s "failed") -
aber der Fehlertext sagte:

  "Die Rohdateien wurden NICHT geloescht: mit 'Neu komprimieren' laeuft die
   Kompression erneut, ohne die Disc noch einmal zu rippen."

Der Satz war fuer einen toten TRANSCODE geschrieben, wo die Roh-MKV vollstaendig
ist. Seit "running" mit zu den Arbeitsstati gehoert, traf er auch abgebrochene
RIPS - und da ist er schlicht falsch: Die Datei ist ein Bruchstueck (im Vorfall
5,1 GB von rund 40), und wer dem Rat folgt, komprimiert einen Film, der bei 12 %
aufhoert.

Jetzt unterscheidet der Text die Phase:
  Rip tot        -> "Der Rip war bei 12 % - die Roh-Datei ist UNVOLLSTAENDIG ...
                     Richtig ist: Disc wieder einlegen und neu rippen."
  Transcode tot  -> "Der Rip war fertig, nur die Kompression nicht ..."

Die deutschen Anfuehrungszeichen haben dabei zum vierten Mal an einem Tag
zugeschlagen (in DOPPELT gequoteten Python-Strings beendet das schliessende
Zeichen den String). Die betroffenen Zeilen sind jetzt einfach gequotet, wie es
der Bestand ohnehin macht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:23:15 +02:00
Hitonabi 19f3dc5330 fix(zombies): "running" fehlte - ein abgestuerzter RIP wurde NIE gefunden
Ampel / ampel (push) Successful in 30s
Der Commander: "Ausserdem ist gerade mitten im Rip das Laufwerk ausgegangen...
glaube ich zumindest." Gemessen war es etwas anderes, und der Fund ist groesser
als der Vorfall.

WAS MESSBAR WAR:
  Job 2182d525   status=running progress=12   (Rip gestartet 14:00:24)
  makemkvcon     laeuft NICHT (per /proc geprueft, PID gegengeprueft)
  Rohdatei       5.167.382.528 Bytes, waechst in 10 s nicht
  Laufwerk       Status 4 (Disc drin), /dev/sr0 + /dev/sg1 da
  Worker-Start   14:07:02  <- mein `docker compose up -d --build`

Das Laufwerk ist also NICHT ausgegangen. Der Rip wurde von MEINEM Deploy
getoetet: `up -d --build` baut den worker-Container neu, und der laufende Rip
stirbt mit ihm. Genau davor warnt der SAVEPOINT seit v3.18 - die Warnung half
nichts, weil sie niemand liest und nichts sie prueft.

DER EIGENTLICHE FUND: Die Zombie-Erkennung lief um 14:09:04 und meldete
`{'geprueft': 0, 'aufgeraeumt': []}` - obwohl der tote Job direkt vor ihr lag.
Ursache:

  ARBEITS_STATI = ("ripping", "transcoding", "canceling")   # zombies.py
  db.update_job(job_id, status="running", ...)              # tasks.py - der Rip

Der Rip setzt "running", gesucht wurde "ripping". Dieser Wert steht
ausschliesslich in Celerys Task-META und NIE in einer Job-Zeile (nachgeprueft:
kein einziger Schreiber im ganzen Baum). Die Zombie-Erkennung aus v3.14 wurde
gebaut, um genau einen abgestuerzten Rip zu finden - und hat ihn nie gesehen.

Besonders tueckisch: `geprueft: 0` sah bei jedem Worker-Start wie "nachgesehen,
alles gesund" aus, waehrend sie nach einem Status suchte, den es nicht gibt.
Deshalb blieb der Job auf "processing 12 %" stehen - mit einer Restzeit-Schaetzung
von 1 h 30 min obendrauf, die es fuer einen toten Prozess nicht geben duerfte.

GEBAUT:
  * "running" in ARBEITS_STATI.
  * Ein Test, der das Auseinanderlaufen MECHANISCH verhindert: Er liest tasks.py,
    sammelt jeden Status, den der Worker per db.update_job in eine Job-Zeile
    schreibt, und verlangt, dass jeder davon entweder ein Arbeitsstatus oder ein
    Endzustand ist. Ein Kommentar haette das nicht verhindert.
  * GET /health/arbeit + eine Sperre in deploy.sh: Laeuft ein Job, bricht der
    Deploy ab (uebersteuerbar mit RIPPY_TROTZDEM=1 - dann ist es eine
    Entscheidung und kein Versehen). Ein Satz Code gegen eine verlorene Stunde.
  * Server-Status zeigt jetzt die eingehaengten FREIGABEN mit freiem Platz, nicht
    nur die Container-Platte (Commander-Wunsch). Genau dort liegen die Rohdaten,
    und bei externem Encoden muessen sie dort liegen - wer wissen wollte, ob noch
    Platz fuer eine Disc ist, sah die falsche Zahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:16:05 +02:00
Hitonabi 6438beec05 fix(worker): kein CMD-Blitzen mehr - und die 7200-Sekunden-Meldung ist erklaert
Ampel / ampel (push) Successful in 31s
Zwei Meldungen, beide auf dieselbe Wurzel zurueckgefuehrt: Der Worker startet
Konsolenprogramme, und das hat unter Windows Nebenwirkungen.

1. "Es geht immer alle paar Sekunden ne CMD auf." Kein Geist, sondern der
   HERZSCHLAG. Der Worker laeuft als pythonw.exe, also ohne eigene Konsole. Wer
   ohne Konsole ein Konsolenprogramm startet, bekommt von Windows eine NEUE - und
   die ist sichtbar. `capture_output=True` hilft nicht: es leitet die Datenstroeme
   um, unterdrueckt aber kein Fenster. Und caps.py fragt jede Minute
   `HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu makemkvcon aus
   der Schluessel-Automatik: vier bis fuenf Fenster pro Minute, in Schueben.

   Neues winlauf.py haelt CREATE_NO_WINDOW an EINER Stelle; alle elf Aufrufe in
   caps.py, ripping.py und schluessel.py benutzen es. Auf Linux ist der Wert 0 und
   creationflags=0 eine Nulloperation - im Worker-Container gegengeprueft, damit
   derselbe Code auf beiden Seiten laeuft.

2. Die 7200 Sekunden sind CELERYS EIGENE UMRECHNUNG, nicht die Uhren. Erst fiel
   auf, dass die Sekundenbruchteile nur 40 ms auseinanderliegen (.177269 vs
   .134799) - derselbe Augenblick, zweimal verschieden gerechnet. Die Quelle sagt
   warum (celery/utils/time.py):

     def utcoffset():                    # Sekunden WEST von UTC, in Stunden
         return time.altzone // 3600     # CEST -> -2 ;  UTC -> 0
     def adjust_timestamp(ts, offset, here=utcoffset):
         return ts - (offset - here()) * 3600

   Absender VM (offset 0), Empfaenger Windows-PC (here() = -2):
   ts - (0 - (-2)) * 3600 = ts - 7200. Celery verschiebt den empfangenen Stempel
   also selbst und vergleicht ihn dann mit der eigenen Uhr. Die Annahme dahinter -
   Ereignisse truegen Ortszeit - stimmt nicht, `Event()` stempelt Epoch.

   Abhilfe gemessen, nicht geraten: Mit TZ=UTC meldet Windows utcoffset 0
   (timezone=0, isdst=0 - auf dem Commander-PC geprueft), damit wird die
   Umrechnung zur Nulloperation. Beide .bat-Dateien setzen es jetzt. Nebeneffekt
   ist erwuenscht: Der Worker rechnet dann in derselben Zone wie die VM und wie
   Rippys UI.

   Fuer Rippy war die Meldung ohnehin folgenlos - /capabilities fragt per
   Celery-Ping (Frage/Antwort, ohne Zeitstempel), nicht ueber die
   Gossip-Ereignisse. Aber eine Warnung, die bei jedem Blick ins Log steht und
   nichts bedeutet, kostet Vertrauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:05:54 +02:00
Hitonabi 5f01aa89fc fix(deinstaller): er brauchte Adminrechte und hatte keine
Ampel / ampel (push) Successful in 30s
Commander: "Ich kann den worker uebrigens immernoch nicht deinstallieren."

Der Screenshot zeigt ZWEI Dinge. Das erste ist erklaerbar: Es lief noch der ALTE
uninstall.ps1 von der letzten Installation - Zeile 2 mit dem kaputten
`if (-not $Force) { if ([System.Windows.Forms.MessageBox]...` . Meine Reparatur
schreibt die Datei erst beim naechsten Installer-Lauf neu.

Das zweite ist MEIN Fehler, und er haette auch die neue Fassung erwischt:

  Remove-Item : Das Element C:\Program Files\Rippy Worker\doc\LICENSE
  kann nicht entfernt werden: Der Zugriff auf den Pfad wurde verweigert.

Der Standard-Zielordner liegt unter "C:\Program Files", und der gehoert nicht dem
Benutzer. Die Installer-.exe ist mit -requireAdmin gebaut und fragt beim Start per
UAC - der Deinstaller hatte nichts Vergleichbares. Er war damit im STANDARDFALL
nutzlos, und mein `rd /s /q` waere genauso aufgelaufen.

Jetzt erhoeht er sich selbst: Schreibprobe im Ordner, Admin-Abfrage, und nur wenn
beides fehlt, ein Neustart per RunAs mit -Force (damit die Bestaetigung nicht
zweimal kommt). GEMESSEN statt angenommen - wer den Worker ins eigene Profil
installiert hat, bekommt gar keine UAC-Frage. An seinem echten Ordner
gegengeprueft: schreibbar=False, istAdmin=False -> Entscheidung "erhoehen".

Der erzeugte Deinstaller ist diesmal komplett nachgestellt worden: parst, und die
Reihenfolge stimmt (Add-Type Zeile 7, MessageBox Zeile 10, Schreibprobe,
Erhoehung, erst dann Loeschen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:55:05 +02:00
Hitonabi f146f33f5d feat(installer): Verknuepfung auf dem Desktop - mit Rippy-Icon
Ampel / ampel (push) Successful in 30s
Commander: "Und wenn man den worker installiert braucht man auch eine exe auf dem
Desktop abgelegt wird zum starten you know?"

Berechtigt: Die Startdateien lagen nur im Programmordner. Wer den Worker von Hand
starten wollte, musste erst "C:\Program Files\Rippy Worker" suchen - und genau
das war heute noetig, als der Worker stand.

Jetzt legen beide Installer eine Verknuepfung "Rippy Worker" auf den Desktop
(GUI mit Haekchen, Kommandozeile per -KeineDesktopVerknuepfung abschaltbar). Drei
Details, die den Unterschied machen:

  * Das ICON wird mit ausgeliefert. rippy.ico lag im Repo, ging aber nie an die
    Zielmaschine - die Verknuepfung haette das Batch-Standardsymbol getragen und
    waere zwischen den anderen Icons unfindbar gewesen. Jetzt im Worker-Paket.
  * WindowStyle 7 (minimiert): start-tray.bat startet pythonw, also ohne
    Konsolenfenster - aber die .bat selbst blitzt sonst kurz auf. Gilt jetzt auch
    fuer die Autostart-Verknuepfung, wo derselbe Blitz war.
  * Der DEINSTALLER nimmt sie mit, an beiden moeglichen Orten (eigener Desktop
    und Desktop aller Benutzer). "Rueckstandsfrei entfernbar" ist ein
    MUSS-Kriterium; ein totes Symbol auf dem Desktop waere genau so ein Rueckstand.

Dieselbe Rechte-Ueberlegung wie beim Autostart: bei einer Installation unter
"Programme" auf den Desktop ALLER Benutzer, sonst auf den eigenen - bei einer
UAC-Erhoehung ueber ein fremdes Admin-Konto waere der eigene der falsche.

Layout headless gerendert und angesehen (Fenster 648 -> 672 px, nichts
ueberlappt), beide .ps1 mit echtem PowerShell 5.1 geprueft, .exe neu gebaut.
Und die CRLF-Falle aus dem Savepoint hat prompt wieder zugeschlagen: Mein
Python-Rewrite machte aus install-gui.ps1 LF - zurueckgedreht, `file` bestaetigt
BOM und CRLF fuer beide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:46:15 +02:00
Hitonabi a448ebc4de fix(ui): die Pfad-Warnung sagt jetzt, was zu tun ist - und bietet einen Knopf
Ampel / ampel (push) Successful in 29s
Commander: "Das ist ja quatsch. Mein PC hat das Ziel als Worker direkt auf dem
PC eingebunden."

Nachgemessen: Die Warnung hatte SACHLICH recht, war aber unbrauchbar. Sein PC hat
die Freigabe wirklich als Netzlaufwerk (X:) gemountet - nur lag das
ARBEITSVERZEICHNIS darauf und die ABLAGE nicht. Das Ziel war weiter
/app/media/movies, also die VM-Platte, und dorthin kommt sein PC nicht. Beleg aus
der Worker-Meldung:

  pfad_map = /app/media/rippy=\192.168.178.62\rippy
  Ziel     = /app/media/movies        <- nicht abgedeckt, richtig gewarnt

Die alte Meldung nannte den Container-Pfad und die Mapping-Zeichenkette - also
genau die zwei Dinge, die ein Nicht-Entwickler nicht deuten kann. Jetzt steht der
Handgriff drin, mit dem NAMEN der Freigabe, die dieser Worker wirklich erreicht
("Einstellungen -> Ripping -> Ablage auf 'rippy' stellen").

Dazu ein Knopf, der das Ziel NUR FUER DIESEN RIP auf die Freigabe legt
(/app/media/movies -> /app/media/rippy/movies), ohne die globale Ablage
anzufassen. Wer den Rip jetzt starten will, will jetzt eine Loesung und keine
Wegbeschreibung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:38:31 +02:00
Hitonabi b4a0dd551a docs(savepoint): v3.19 - sieben Meldungen, drei echte Fehler, eine Kette
Ampel / ampel (push) Successful in 30s
Der Savepoint haelt fest, was gemessen wurde und was nicht. Die wichtigste Lehre
steht auch in AGENTS.md: Bei "zu langsam" die DAUER je Schritt messbar machen
statt die plausibelste Ursache zu beheben. Meine erste Erklaerung fuer die 150 s
war falsch und machte es sogar langsamer (202 s); erst Zeitstempel im Log zeigten
die Stelle - ein os.makedirs, das drei Minuten im Kernel hing. Danach 8 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:25:46 +02:00
Hitonabi 848deb1c24 fix(ui): Sprachnamen auf Deutsch - "Undetermined" versteht niemand
Ampel / ampel (push) Successful in 29s
An der echten Akira-Blu-ray gemessen, nachdem die Sprachauswahl live lief:

  Ton:        deu 4x, jpn 2x, und 4x
  Untertitel: deu 4x, und 8x

MakeMKV liefert die Namen englisch ("German", "Japanese") und fuer Spuren ohne
Sprach-Kennzeichnung "Undetermined" - das sind typischerweise Audiokommentare.
Der Commander liest alles auf Deutsch (AGENTS Grundregel 4), und
"Undetermined" ist fuer einen Nicht-Entwickler schlicht keine Auskunft. Jetzt
steht dort "Ohne Sprachangabe", und die zwei Dutzend haeufigsten Codes haben
deutsche Namen. Unbekannte Codes behalten MakeMKVs Namen - geraten wird nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:22:13 +02:00
Hitonabi 9230d0dd7a fix(weitergabe): drei Luecken geschlossen, die einen Fremden gestolpert haetten
Ampel / ampel (push) Successful in 29s
Weitergabe nicht durch Lesen geprueft, sondern indem ich mich wie ein fremder
Rechner verhalten habe: frischer `git clone` von Gitea in ein leeres Verzeichnis,
dann `./install.sh --nur-pruefen`. Ergebnis: 2,6 MB, alles gruen, Laufwerk samt
richtigem sg-Knoten ueber die SCSI-Adresse erkannt. Der Weg selbst traegt.

Drei Luecken sind dabei aufgefallen:

1. IN BEISPIELBEFEHLEN STAND MEINE IP. `install.ps1` und
   `remote-transcode-worker.yml` nannten im Aufruf-Beispiel 192.168.178.162 -
   also genau die Zeile, die ein Fremder kopiert. Jetzt Platzhalter, beim
   Windows-Installer mit dem Hinweis, wo die richtige IP steht (und dass es NICHT
   die des eigenen PCs ist - der haeufigste Irrtum).

2. DER ASSISTENT FRAGTE DEN MAKEMKV-BETA-KEY NICHT. Er stand in der README, in
   der .env und in den Einstellungen - nur nicht dort, wo man beim Einrichten
   hinsieht. Ein Fremder installiert also, legt eine Blu-ray ein und bekommt
   spaeter einen Fehlschlag, ohne dass ihn jemand darauf hingewiesen haette. Das
   ist die wahrscheinlichste Stolperstelle einer frischen Installation. Jetzt
   fragt der Assistent ihn ab, mit dem Unterschied im Klartext: DVDs gehen ohne,
   Blu-ray braucht ihn - und er ist NICHT der Disc-Schluessel einer 4K-Disc.

3. Eine Docstring nannte meine NAS-IP als Beispiel - jetzt neutral (//NAS/rippy).

Tests und Log-Beispiele behalten die echten Namen: Sie dokumentieren Messungen,
und genau das ist ihr Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:18:30 +02:00
Hitonabi c171f8879c feat(schluessel): der Windows-PC holt die 4K-Schluessel jetzt selbst
Ampel / ampel (push) Successful in 31s
Auf Commander-Entscheid gebaut. Belegt am 25.07.2026 auf beiden Maschinen:
`makemkvcon` unter LINUX ruft Disc-Schluessel NIE ab, die WINDOWS-Version schon
(Meldung 3338). Deshalb scheiterte jede unbekannte UHD-Disc auf der VM mit "The
volume key is unknown", und der Weg, der funktioniert, war Handarbeit: Laufwerk
an den PC, Disc oeffnen, _private_data.tar suchen, im UI hochladen.

Das laeuft jetzt von selbst - und zwar ZWEISCHICHTIG, mit Absicht:

  1. Der WAECHTER (verlaesslich): sieht _private_data.tar nach und laedt sie zu
     Rippy hoch, sobald sie sich geaendert hat. Braucht keine
     Laufwerkserkennung, kein Disc-Oeffnen, nichts geraten. Deckt auch den Fall
     ab, dass man die Disc einfach in der MakeMKV-Oberflaeche oeffnet.
  2. Das ANSTOSSEN (nach bestem Wissen): liegt eine Disc im Laufwerk, wird
     `makemkvcon info` darauf losgelassen - dabei holt MakeMKV den Schluessel.

Warum getrennt: Das Format der BELEGTEN `DRV:`-Zeile liess sich auf dem
Commander-PC nicht messen, weil dort kein optisches Laufwerk steckt (alle 16
Plaetze melden `DRV:i,256,999,0,"","",""` - das ist gemessen). Geraten wird also
nur in Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach
nichts - Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im
verlaesslichen Teil.

Gemessene Fundstellen: Datenverzeichnis ist `%USERPROFILE%\.MakeMKV` (NICHT
%APPDATA%\MakeMKV, wie man vermuten wuerde) - dort lag die echte Datei mit
6.420.480 Bytes. Programm: C:\Program Files (x86)\MakeMKV\makemkvcon64.exe,
v1.18.4. Hochgeladen wird mit ROHEM Koerper an POST /system/keystore, weil der
API python-multipart fehlt.

Die Automatik schaltet sich selbst ab, wenn MakeMKV nicht installiert ist: Auf
einem reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede
Minute ins Leere greift, waere nur Rauschen. Ihre Meldungen gehen ueber die
Log-Bruecke auch nach Rippy - das ist genau die Auskunft, auf die man nach dem
Einlegen einer neuen UHD-Disc wartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:14:52 +02:00
Hitonabi 148ac494c2 feat(sprachen): Rippy fragt vor dem Rip, welche Sprachen du willst
Ampel / ampel (push) Successful in 29s
Commander-Anforderung: "Die Disc hat Material in X Sprachen und X Untertiteln -
Rippy muss VOR dem Rip fragen: Was genau willst du haben? In der Automatik muss
das ebenfalls einstellbar sein."

Die Auskunft lag laengst vor und wurde weggeworfen: Derselbe Titel-Scan, der die
Titel-Tabelle fuellt, liefert in derselben makemkvcon-Ausgabe die Streams mit.
Ein zweiter Info-Lauf haette eine Minute Wartezeit gekostet - jetzt kommt beides
aus einem Aufruf.

Format an der Akira-Blu-ray im Laufwerk gemessen (AGENTS Regel D):

  SINFO:<titel>,<stream>,<attribut>,<code>,"<wert>"
  1 = Typ ("Audio"/"Subtitles"), 3 = Sprachcode, 4 = Sprachname,
  6 = Codec, 14 = Kanaele, 30 = Beschreibung

DIE FALLE dabei: Die Sprache steht in 3/4, NICHT in 28/29. Die tragen auf JEDEM
Stream "eng"/"English" - auch auf einer deutschen Tonspur und auf dem
Videostream; das ist MakeMKVs eigene Anzeigesprache. Wer 28 nimmt, haelt jede
Disc fuer englisch. Ein Test haelt das fest.

Angewendet wird die Wahl bei der KOMPRESSION, nicht beim Rippen - drei Gruende:
der Rip bleibt vollstaendig und verlustfrei (Muss-Feature laut KONZEPT); HandBrake
hat dafuer dokumentierte Schalter (--audio-lang-list / --subtitle-lang-list, die
genau die ISO-639-2-Codes nehmen, die MakeMKV liefert - beides gegengeprueft);
und wer spaeter andere Sprachen will, komprimiert neu statt die Disc wieder
einzulegen. Genau das sagt der Dialog auch, sonst glaubt man, es werde
unvollstaendig gerippt.

Nichts angeklickt heisst "alles behalten" - das Verhalten von vorher. Auch die
Einstellung ist bewusst LEER vorbelegt: Ein stilles "deu" wuerde bei einem
japanischen Original die Originaltonspur wegwerfen, ohne dass jemand gefragt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:10:21 +02:00
Hitonabi 58f4991d92 fix(mounts): 3 min 15 s hingen in EINER Zeile - os.makedirs auf dem toten Mount
Ampel / ampel (push) Successful in 29s
Nachtrag, weil die letzte Runde die Wiederanbindung nicht schneller machte
(202 s statt 150 s). Die Zeitstempel im Log zeigten, wo die Zeit sitzt:

  12:53:56  API gestartet
  12:54:03  "antwortet nicht - wird neu verbunden"    <- Erkennung: 7 s, gut
  12:57:18  "eingehaengt"                             <- Reparatur: 3 min 15 s

Die Erkennung war also schon schnell; die REPARATUR fraess die Zeit. Und zwar
nicht in den Mount-Versuchen, sondern in der ersten Zeile von mounten():

  os.makedirs(ziel, exist_ok=True)

`exist_ok` prueft mit os.path.isdir, und ein `stat` auf einen toten CIFS-Mount
blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf, der nur "lege
den Ordner an, falls er fehlt" bedeutet, hing drei Minuten - BEVOR irgendeine der
sorgfaeltig begrenzten Pruefungen dran war. Dritter Fund derselben Sorte an einem
Tag: os-Aufruf auf einen Netzpfad ohne Zeitgrenze.

Jetzt klaert `pfad_lage()` die Lage mit einem abbrechbaren Kind-Prozess
(`timeout 4 ls -d`, drei Antworten: da / weg / unklar), und makedirs laeuft nur
bei "weg". Dieselbe Falle in `reparieren()` (os.path.ismount als Vorbedingung -
gebraucht wird es nicht, `umount -l` auf einen leeren Pfad kostet nichts) und in
`aushaengen()` (ismount + rmdir).

Dazu steht die DAUER jetzt im Log ("neu verbunden (4.2s)"). Sie war die
entscheidende Spur; wer sie ablesen kann, muss sie nicht rekonstruieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:01:09 +02:00
Hitonabi 2a90538473 fix: Deinstaller, Verwaltungsfenster, Dashboard-Widerspruch, Mount-Tempo
Ampel / ampel (push) Successful in 30s
Vier Meldungen des Commanders, alle nachgemessen.

1. DEINSTALLER LIEF NICHT MEHR - reproduziert mit echtem PowerShell:

     Der Typ [System.Windows.Forms.MessageBox] wurde nicht gefunden.

   `Add-Type -AssemblyName System.Windows.Forms` stand EINE ZEILE ZU SPAET, die
   MessageBox wurde davor benutzt. Der Deinstaller starb also in seiner ersten
   Arbeitszeile, jedes Mal. Zwei weitere Maengel gleich mit:
   Kodierung war ASCII trotz Umlauten, und `Remove-Item -Recurse -Force
   $PSScriptRoot` loescht den Ordner, in dem das laufende Skript liegt - das
   klappt auf Windows nicht zuverlaessig (venv-DLLs sind geladen). Jetzt raeumt
   ein losgeloestes cmd nach, sobald PowerShell weg ist. Der erzeugte Deinstaller
   ist gegengeprueft: parst, BOM da, Umlaute intakt.

2. VERWALTUNGSFENSTER (Doppelklick aufs Tray). Zeigt Status, Aufgaben und Log,
   plus Knoepfe fuer Rippy, Log-in-Rippy und Deinstallieren - Deinstallieren geht
   damit auch aus dem Tray-Menue. Eigener PROZESS statt Fenster im Tray, weil
   pystray und tkinter beide den Haupt-Thread wollen; tkinter statt WinForms,
   weil es bei jeder Windows-Python-Installation dabei ist. Headless gerendert
   und angesehen. Alles Fachliche kommt von Rippy (/capabilities, /jobs), damit
   dort nicht eine zweite, abweichende Wahrheit steht.

3. DASHBOARD-WIDERSPRUCH. Oben stand "Akira im Laufwerk erkannt", die
   Server-Status-Karte gleichzeitig "Bereit - keine Disc in Arbeit / Disc
   einlegen". Zwei Aussagen, ein Blick. Die Karte fragt jetzt /devices und sagt
   "Disc erkannt - wartet auf Rippen starten" samt Titel.

4. MOUNT-TEMPO. Der Commander: "150 Sekunden? Das ist verrueckt langsam." Recht
   hat er. Gemessen ging die Zeit fast komplett in einen FEHLVERSUCH: `umount -l`
   ist lazy, der Abbau passiert spaeter, und wer direkt danach mountet, riskiert
   dass der Abbau hinter dem neuen Mount landet. Der erste Reparaturversuch
   scheiterte dadurch regelmaessig - und der zweite kostete 30 s mount-Timeout
   plus Pruefungen. Jetzt werden 1,5 s auf den Abbau gewartet, bevor neu
   gemountet wird; die Wache prueft erstmals nach 3 s statt 10 s und benutzt zum
   ERKENNEN die einfache schnelle Probe (die Doppelprobe steckt dort, wo der
   Wettlauf lauert: direkt nach dem Mount).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:52:53 +02:00
Hitonabi 4c2331563c docs(savepoint): v3.18 - Auswurf, externer Worker, und die Mount-Ursache
Ampel / ampel (push) Successful in 31s
Stellt eine Aussage aus v3.17 richtig: Dort stand die Mount-Sache als "Ursache
liegt beim NAS, nicht gefunden". Gemessen liegt sie bei uns - die CIFS-Verbindung
lebt in der Netz-Namespace des api-Containers und stirbt mit ihm. Das NAS ist
unschuldig.

AGENTS.md bekommt die Lehre, die diesen Abend zweimal gekostet hat: Ein
Rueckgabewert ist kein Beweis, wo die Wirkung pruefbar ist. CDROMEJECT quittiert
Erfolg auf einem verriegelten Laufwerk, `mount` quittiert Erfolg auf einer
Verbindung, die Sekunden spaeter stirbt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:40:31 +02:00
Hitonabi 4cf7acbb96 fix(mounts): DIE URSACHE gefunden - die CIFS-Verbindung stirbt mit dem Container
Ampel / ampel (push) Successful in 31s
Der Savepoint v3.17 fuehrte das als "Ursache liegt beim NAS, nicht gefunden".
Gemessen ist es etwas ganz anderes, und es liegt bei uns:

  /proc/fs/cifs/DebugData  ->  Net namespace: 4026532653
  api-Container            ->  net:[4026532653]   DIESELBE
  worker-Container         ->  net:[4026532540]   andere

Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS - dort wird
sie eingehaengt, weil nur dieser Container CAP_SYS_ADMIN hat. Wird der Container
neu gebaut, stirbt sein Netz-Namespace und mit ihm der Socket. Der Mount steht
danach weiter in /proc/mounts (per rshared auf den Host propagiert) und sieht
vollkommen gesund aus - aber jeder Zugriff laeuft in den CIFS-Timeout.

Damit erklaert sich alles, was vorher widerspruechlich aussah: warum es nach
JEDEM Deploy passiert, warum `mount` Erfolg meldet, warum /proc/mounts genau eine
korrekte Schicht zeigt, und warum nur ein echtes Neu-Verbinden hilft. Das NAS ist
unschuldig (eine Sitzung, Status 1, 630 Credits, Ping 0,47 ms).

Zweiter Fund, der den Rest erklaert: Direkt nach einem frischen Mount antwortete
die Freigabe - und Sekunden spaeter nicht mehr. Das ist ein Wettlauf mit
`umount -l`: lazy heisst, der Abbau passiert spaeter, und faellt er samt
Propagation hinter den neuen Mount, zeigt der Pfad wieder auf die Leiche. Eine
einzige Probe kann das nicht sehen - deshalb prueft `wirklich_erreichbar()`
zweimal mit drei Sekunden Abstand, und zwar sowohl beim Mounten als auch in der
Wache.

Dazu: erste Pruefung der Wache schon nach 10 s statt 60 s. Genau dann ist die
Lage nach einem Deploy kaputt.

Ehrlich offen bleibt die strukturelle Folge: Der api-Container HAELT die
NAS-Verbindung. Startet er mitten in einem Rip neu, verliert auch der Worker sein
Ziel. Das saubere Gegenmittel waere ein Mount auf dem HOST statt im Container -
ein eigener Umbau, und er widerspraeche "Speicherziele ueber das UI einhaengen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:32:51 +02:00
Hitonabi 549727f648 fix(mounts): Wache, die die Freigabe nach einem Rebuild von selbst zurueckholt
Ampel / ampel (push) Successful in 28s
Dreimal in Folge reproduziert, jetzt bei JEDEM Deploy: Nach `docker compose up -d
--build` ist die CIFS-Freigabe tot. `mount` meldet Rueckgabewert 0, /proc/mounts
zeigt genau eine korrekt aussehende Schicht, die Erreichbarkeits-Probe antwortet
direkt nach dem Mount sogar - und Sekunden spaeter laeuft jeder Zugriff in die
Zeitgrenze. Derselbe Ablauf ein bis zwei Minuten spaeter stellt sie zuverlaessig
her (POST /storage-mounts/rippy/repair, mehrfach belegt).

Die Ursache liegt am NAS und ist nicht gefunden. Aber die Wirkung ist teuer: Nach
jedem Update war jeder Rip auf die NAS kaputt, ohne dass irgendwo etwas davon zu
sehen war - und der Commander haette es jedes Mal von Hand richten muessen.
Wenn die Heilung bekannt und billig ist, gehoert sie automatisiert, auch ohne die
Ursache zu kennen.

Die Wache sieht jede Minute nach und verbindet stumme Freigaben neu. Zwei Dinge
sind dabei wichtiger als die Heilung selbst:

1. NIE waehrend ein Job laeuft. Neu verbinden heisst `umount -l`; mitten in einem
   Rip oder Encode waere das ein Datenverlust. Die Wache steht still, solange
   irgendein Job nicht durch ist - auch bei einem wartenden, der jeden Moment
   anlaufen kann (db.hat_arbeit).
2. Gemeldet wird nur der UEBERGANG. Ist das NAS ausgeschaltet, waere ein Log je
   Minute ein Wasserfall.

Der Zustand steht in /health/vorraete, damit man sehen kann, dass die Wache lebt
- dieselbe Lehre wie heute Nachmittag beim stillen except.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:24:09 +02:00
Hitonabi 87484d6863 feat(worker): der externe Worker wird erwachsen - Log in Rippy, Anzeige, Slots
Ampel / ampel (push) Successful in 28s
Commander 26.07.2026: "der externe Encoder Worker ist ein bisschen duenn - der
koennte noch viel mehr." Drei Punkte, alle am Tray.

1. LOG IN RIPPY STATT TXT-DATEI (ausdruecklich gewuenscht). Das Tray schrieb sein
   Log nach %LOCALAPPDATA% und oeffnete es im Editor - wer wissen wollte, warum
   der Worker nichts tut, musste sich an den PC setzen. Neue Bruecke
   (logbruecke.py) meldet die wichtigen Zeilen nach Rippy, Quelle "w:<name>",
   und die Logs-Seite hat jetzt Knoepfe je Quelle: "was macht mein PC" ist ein
   Klick. Der Filter konnte Quellen schon immer, es gab nur keinen Knopf.

   Durchgelassen wird WENIG und mit Grund: Die Job-Meldungen stehen laengst in
   Rippy (tasks.py schreibt sie selbst). Es fehlte, was DANEBEN passiert und den
   Worker unbrauchbar macht, ohne dass ein Job existiert - hochgefahren oder
   nicht, Verbindung zu Redis/Postgres, Abstuerze. Alles andere fliegt weg:
   Celery ist bei --loglevel=info gespraechig, die logs-Tabelle hat keine
   Aufraeumung, und ein zugemuelltes Log ist so unbrauchbar wie keins. Dazu eine
   Drossel (30 Zeilen/Minute), die MELDET, wieviel sie verschluckt hat.
   Zeilenformat woertlich aus dem laufenden Container abgenommen (Celery 5.4.0).

   Die lokale Datei bleibt - sie ist genau dann die einzige Auskunft, wenn Rippy
   nicht erreichbar ist.

2. DAS TRAY ZEIGT, WAS LAEUFT. Vorher stand dort "laeuft" oder "gestoppt" - auf
   einer Maschine, die stundenlang an einem Film rechnet, ist das keine Auskunft.
   Jetzt Titel, Prozent und Restzeit, geholt von Rippys /jobs. Bewusst dieselbe
   Quelle wie das Dashboard, damit im Tray nicht eine zweite, abweichende
   Schaetzung steht.

   Dazu: Windows schlaeft nicht mehr mitten im Encode ein
   (SetThreadExecutionState, ohne ES_DISPLAY_REQUIRED - der Bildschirm darf
   ausgehen). Die Sperre wird zurueckgenommen, sobald nichts laeuft, und auch bei
   einem harten Ende des Trays - sonst schlaeft der PC nie wieder ein und niemand
   weiss warum.

3. MEHRERE ENCODES GLEICHZEITIG. Der Worker lief fest mit --pool=solo und nahm
   genau EINEN Auftrag an. Der Installer fragt die Zahl jetzt (GUI: Feld neben
   dem Namen, mit der erkannten Kernzahl daneben), Vorbelegung ab 12 Kernen
   zwei, sonst einer: HandBrake nutzt schon alle Kerne, aber x265 skaliert nicht
   linear. Auf Windows gibt es keinen prefork-Pool (kein fork) - deshalb
   --pool=threads, was hier passt, weil die Arbeit ein Kind-Prozess ist und der
   Thread nur wartet.

GUI-Layout headless gerendert und angesehen (nichts ueberlappt, 16 Kerne
korrekt erkannt), beide .ps1 mit echtem PowerShell 5.1 geprueft, BOM und CRLF
erhalten, .exe neu gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:17:14 +02:00
Hitonabi be04a9772e fix(auswurf): CDROMEJECT meldete Erfolg und tat nichts - erst entriegeln
Ampel / ampel (push) Successful in 29s
Commander-Meldung: "Den Button gibt es in den Settings, aber es passiert nicht,
das Laufwerk geht nicht auf." Am laufenden System nachgestellt, mit der Disc, die
gerade drin lag:

  wirf_disc_aus("/dev/sr0")      -> True
  CDROM_DRIVE_STATUS danach      -> 4 (Disc drin)

Das ioctl wird also ANGENOMMEN und tut nichts. Ursache: MakeMKV verriegelt
waehrend des Rips die Laufwerkstuer (CDROM_LOCKDOOR 1) und entriegelt sie nicht
wieder. Ein verriegeltes Laufwerk quittiert den Auswurf trotzdem mit Erfolg.
Gegenprobe an derselben Disc:

  CDROM_LOCKDOOR 0 + CDROMEJECT  -> Status 2 (SCHUBLADE OFFEN)

Genau das macht das Werkzeug `eject` immer: erst entriegeln, dann auswerfen.

Zweite Haelfte des Fixes, und die wichtigere: Das Ergebnis wird GEPRUEFT statt
geglaubt. Bisher gab wirf_disc_aus True zurueck, sobald das ioctl nicht geworfen
hatte - und ins Log kam "Disc ausgeworfen", waehrend die Schublade zu blieb.
Deshalb ist der Fehler in v3.14 durchgerutscht: Dort wurde richtig festgestellt,
dass die Einstellung von niemandem gelesen wurde, und danach WURDE sie gelesen -
ausgeworfen wurde weiterhin nicht. Jetzt wird das Laufwerk gefragt (bis zu 5 s,
die Schublade braucht ein bis zwei), und "kein Datentraeger" zaehlt mit, weil ein
Slot-Laufwerk keine Schublade hat.

Dieselbe Luecke steckte im Auswurf-Knopf der API (devices.eject) - dort mit
Klartext-Fehler, wenn die Disc drin bleibt.

Der Auswurf sitzt uebrigens schon an der richtigen Stelle: nach dem Rip, VOR dem
Einreihen der Kompression. Und das Laufwerk ist waehrend `transcoding` frei -
has_active_job blockiert nur bei pending/running, der Worker laeuft mit 4 Slots.
Die zweite Disc parallel war also nur am nicht aufgehenden Laufwerk gescheitert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:03:10 +02:00
Hitonabi 2587fe63af docs(savepoint): v3.17 - neun Punkte zu, vier Bestandsfehler dazu
Ampel / ampel (push) Successful in 27s
Der Savepoint trennt wie immer gemessen von vermutet - und stellt zwei
Behauptungen aus v3.16 richtig, die sich als falsch erwiesen haben:

1. "Die Namen der Hardware-Presets sind auf der Rippy-VM nicht ermittelbar."
   Falsch - `--preset-list` nennt sie vollstaendig, auch ohne Hardware-Encoder.
   Der vermeintliche Blocker fuer Punkt 1 existierte nicht.
2. "Rohschnitt intakt -> 'Neu komprimieren' genuegt." Falsch - can_retry war
   false, den Knopf gab es nicht.

AGENTS.md bekommt zwei neue Lehren aus dieser Sitzung: Hintergrund-Schleifen
muessen ihre Fehler melden (ein stiller except hat eine Stunde gekostet), und
Netz-Pfade nie ungebremst anfassen - os.path.isdir haengt auf einem toten CIFS
im Kernel und laesst sich aus Python nicht abbrechen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:55:41 +02:00
Hitonabi 87537bf3e5 fix(mounts): Ergebnis pruefen statt glauben - "mount" meldet Erfolg und liefert nicht
Ampel / ampel (push) Successful in 28s
Nachtrag zum vorigen Commit, weil der die Freigabe noch nicht zurueckbrachte.
Dreimal reproduziert: Beim API-Start meldete `mount` Rueckgabewert 0, das Log
schrieb "rippy: eingehaengt", /proc/mounts zeigte GENAU EINE korrekt aussehende
Schicht mit den richtigen Optionen - und `timeout 6 ls /app/media/rippy` lief
trotzdem in die Zeitgrenze. Derselbe Ablauf ein zweites Mal, per POST
/storage-mounts/rippy/repair, stellte sie sofort her (30 s, danach erreichbar).

Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also gelegentlich
schief, ohne es zu melden. Ein Rueckgabewert von `mount` beweist deshalb nichts.

Jetzt: Nach dem Mount wird geprueft, ob die Freigabe ANTWORTET (ist_erreichbar,
harte Grenze). Wenn nicht, einmal loesen und neu mounten. Hilft auch das nicht,
fliegt ein Fehler mit Klartext - dann steht im Log "FEHLER" statt "eingehaengt",
was schlicht die Wahrheit ist, und der Nutzer bekommt den Hinweis auf die
Reparatur-Funktion statt eines Rips, der spaeter still scheitert.

Damit ist die Kette geschlossen: os.path.isdir kann nicht mehr im Kernel haengen
(voriger Commit), die Rohdaten-Schleife stirbt nicht mehr daran, und ein Mount
gilt erst als hergestellt, wenn er antwortet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:46:37 +02:00
Hitonabi a15623cc70 fix(mounts): Freigabe kam nach einem Rebuild nicht zurueck - Erreichbarkeit zuerst
Ampel / ampel (push) Successful in 28s
Bestandsfehler, beim Gegenpruefen dieser Sitzung aufgefallen und dreimal
reproduziert: Nach `docker compose up -d --build` war die CIFS-Freigabe TOT
(4 von 4 Zugriffen liefen in 10 s Timeout, /storage-mounts meldete
`reachable: false`) - und blieb es. Erst POST /storage-mounts/rippy/repair
stellte sie in einer Sekunde her.

Beim API-Start haette dasselbe passieren muessen. Warum nicht:

  if os.path.ismount(ziel):
      return schreibtest(ziel)

Der Mountpunkt existiert im NEUEN Container weiter (Bind-Mount vom Host), also
sagte ismount "ist schon da" und alle_remounten() brach genau hier ab. Dazu
oeffnet schreibtest() eine Datei OHNE Zeitgrenze - auf einem toten CIFS
blockiert das im Kernel, und der Start-Thread haengt dauerhaft (das waren die
zwei Threads im Zustand D, die diese Sitzung schon einmal gekostet haben).

Jetzt entscheidet ist_erreichbar() zuerst - das hat eine harte Grenze
(`timeout 3 ls`, seit 24.07. im Einsatz). Antwortet die Freigabe, sind ismount
und schreibtest danach gefahrlos. Antwortet sie nicht, faellt es durch auf
loesen + frisch mounten: derselbe Weg, den reparieren() geht, nur automatisch.

Fuer den Commander heisst das: Der NAS-Mount steht nach einem Deploy wieder von
selbst, statt still zu fehlen. Ohne das war jeder Rip auf die NAS nach einem
Update kaputt, ohne dass irgendwo etwas davon zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:40:58 +02:00
Hitonabi 8ccca90e3c fix(api): "konnte nicht nachsehen" ist nicht "ist weg"
Ampel / ampel (push) Successful in 28s
Gemessen nach dem letzten Deploy: Der ERSTE Zugriff auf die CIFS-Freigabe stallt
nach einem Container-Neustart mehrere Sekunden (die SMB-Sitzung wird neu
aufgebaut), danach antwortet sie in 0,01 s - zehn von zehn Versuchen, NAS per
Ping bei 0,47 ms. Die Freigabe ist also gesund, nur der erste Griff ist teuer.

In diesem Fenster lief die Pruefung in ihre Zeitgrenze, der Vorrat notierte
"keine Rohdaten", und der Knopf "Neu komprimieren" verschwand - obwohl 74 GB
dalagen. Der Nutzer haette daraus geschlossen, seine Daten seien weg. Genau
diese Sorte Fehlschluss ("aus einem Zustandswert auf einen Mechanismus") hat das
Projekt schon zweimal bezahlt.

Deshalb drei Antworten statt zwei: "da", "weg", "unklar". 124 ist der
Rueckgabewert von `timeout`, wenn es das Kind abgeschossen hat - das heisst
NICHT angesehen. Bleibt eine Pruefung unklar und der Vorrat hatte vorher einen
Treffer, wird die letzte bekannte Antwort gehalten statt Abwesenheit behauptet.

Wer eine Ja/Nein-Antwort braucht (verzeichnis_da), bekommt im Zweifel weiter
Nein - das ist die richtige Richtung fuer eine einzelne Abfrage.

Live davor geprueft: Die Schleife LEBT jetzt (alter_sekunden 30 -> 21 -> 12 -> 3
ueber 100 s) und heilt sich selbst - sobald die Freigabe antwortete, stand
mit_treffer=1 und can_retry=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:34:39 +02:00
Hitonabi 2554c2633b fix(api): os.path.isdir hing im Kernel und toetete die Schleife - harte Zeitgrenze
Ampel / ampel (push) Successful in 28s
Ursache gefunden, nicht geraten. Der neue Diagnose-Endpunkt sagte
`rohdaten.alter_sekunden: null` - die Funktion war NIE EINMAL fertig geworden,
und kein Fehler war gemeldet. Der Blick auf die Threads des API-Prozesses zeigte
zwei im Zustand **D** (uninterruptible sleep, im Kernel blockiert):

  tid=1600034 name=uvicorn state=D
  tid=1600037 name=uvicorn state=D

Der Mechanismus: Die Schleife startete ihren ersten Durchlauf, waehrend Rippy
beim Container-Start die CIFS-Freigabe neu einhaengte. Ihr `os.path.isdir` blieb
im Kernel stecken, `asyncio.to_thread` kam nie zurueck, die Schleife erreichte
ihr `sleep` nie - und war damit fuer immer tot. Sichtbar war nur, dass
can_retry dauerhaft false blieb.

Ein Timeout um den Aufruf haette nichts geholfen: Ein im Kernel haengender
Thread laesst sich aus Python nicht abbrechen, jeder Versuch haette einen
weiteren Thread verbrannt, bis der Pool leer ist.

Ein Kind-PROZESS laesst sich abbrechen. Geprueft wird jetzt mit
`timeout 4 ls -d <pfad>` - dasselbe Werkzeug, das mounts.ist_erreichbar seit dem
24.07.2026 fuer genau dieses Problem benutzt (dort fuer den toten NAS-Mount).
Laeuft es in die Zeitgrenze, gilt das Verzeichnis als "nicht da": Ein Ort, den
man nicht in Sekunden ansehen kann, ist fuer einen Rip ohnehin unbrauchbar.

os.listdir bleibt fuer /app/media selbst - das ist ein lokales Verzeichnis, die
Freigaben sind Unterordner davon. Und os.path.isdir bleibt fuer den Rueckfall
auf /app/temp/raw: ein Docker-Volume, dort kann nichts haengen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:28:02 +02:00
Hitonabi 0d8426d353 diag(api): Vorrats-Schleifen pruefbar machen - der stille except hat gekostet
Ampel / ampel (push) Successful in 28s
Live-Befund: /jobs/<id>/rohdaten findet die 74,1 GB, aber can_retry blieb ueber
80 Sekunden false - der Vorrat der Hintergrund-Schleife war leer. Direkt im
Container aufgerufen fuellt die Funktion ihn korrekt. Warum die Schleife im
Server-Prozess nichts tat, war NICHT zu sehen: Der `except Exception: pass`
verschluckte jeden Grund.

Zwei Konsequenzen, unabhaengig von der Ursache:

1. Die Schleife MELDET ihren Fehler jetzt (print in den Container-Log) statt ihn
   zu verschlucken. Ein Hintergrund-Prozess, der still scheitert, ist schlimmer
   als einer, der laut scheitert.

2. Neuer Diagnose-Endpunkt GET /health/vorraete: nennt fuer Ping- und
   Rohdaten-Vorrat, wie ALT der letzte Durchlauf ist. Bleibt so ein Vorrat leer,
   ist am Endpunkt selbst naemlich nichts zu sehen - er antwortet nur dauerhaft
   "nichts gefunden".

Die naheliegende Erklaerung (asyncio-Tasks ohne Referenz werden vom GC geholt)
ist widerlegt: Die aeltere Ping-Schleife laeuft nach demselben Muster und lebt -
ueber 72 Sekunden blieb jeder /capabilities-Aufruf unter 10 ms, waehrend ein
toter Ping-Vorrat je 30 s einen synchronen Nachping von ~1 s gekostet haette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:23:32 +02:00
Hitonabi 9fbaed4af8 fix(api): Rohdaten-Suche in den Hintergrund - /jobs darf nie am NAS haengen
Ampel / ampel (push) Successful in 28s
Bei der Live-Gegenprobe gemessen: Waehrend der CIFS-Mount nach einem
Container-Neustart hochkam, brauchten /jobs und /jobs/<id>/rohdaten jeweils
10,0 Sekunden - genau der CIFS-Timeout. Das Dashboard fragt /jobs alle vier
Sekunden ab; ein schlafendes NAS haette es damit dauerhaft eingefroren.

Dieselbe Loesung wie beim Celery-Ping in /capabilities (v3.15): eine
Hintergrund-Schleife im 30-Sekunden-Takt sieht nach, wo Rohdaten liegen, und
/jobs liest nur noch ab. Kennt der Vorrat einen Job noch nicht (frischer
Fehlschlag), zaehlt der billige lokale Ort auf der Container-Platte - der
antwortet immer sofort.

Live geprueft, nachdem der Mount stand: /jobs 0,022 s, /rohdaten 0,014 s,
can_retry = true, 79.604.951.639 Bytes (74,1 GB) gefunden. Der 80-GB-Rohschnitt
des Commanders ist damit erstmals ueber das UI wieder erreichbar.

Der leere Treffer davor war KEIN Codefehler: der Mount stand in dem Moment
schlicht noch nicht. Beleg nachgeliefert - im Container gemessen findet die
Suche den Pfad in 0,000 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:15:42 +02:00
Hitonabi 108368d583 fix(jobs): Rohdaten werden gesucht statt geraten - "Neu komprimieren" ging nicht
Ampel / ampel (push) Successful in 28s
Bei der Live-Gegenprobe des neuen /rohdaten-Endpunkts aufgefallen, und der Fund
ist groesser als der Endpunkt: Job 95afdc89 hatte `can_retry = false`, obwohl
79,6 GB intakter Rohschnitt unter /app/media/rippy/<id> lagen. Der Knopf
"Neu komprimieren" existierte gar nicht.

Der SAVEPOINT v3.16 schrieb dazu: "Rohschnitt 79,6 GB intakt -> 'Neu
komprimieren' genuegt, kein Neu-Rip." Das war falsch, und zwar doppelt:

  _kann_neu_komprimieren  suchte in /app/temp/raw/<id> und unter dem AKTUELLEN
                          workDir -> Knopf erschien nicht
  retry_transcode         berechnete raw_dir aus demselben aktuellen workDir
                          -> haette am falschen Ort gesucht

Ursache in beiden Faellen: Der Rip war mit einer Wahl NUR FUER DIESEN RIP auf
die NAS gelegt worden (gibt es seit v3.15), die Einstellung selbst stand auf
leer. Damit zeigte nichts mehr auf die Datei. Wieder derselbe Fehler, den dieses
Projekt schon mehrfach bezahlt hat: aus einem Zustandswert (der heutigen
Einstellung) auf einen Mechanismus (wohin damals gerippt wurde) geschlossen,
statt nachzusehen.

Neues Modul rohdaten.py sucht jetzt an allen Orten, die ueberhaupt in Frage
kommen: Container-Standard, eingestelltes Arbeitsverzeichnis und jedes
Ablageziel unter /app/media. Der Suchraum ist geschlossen, weil
_arbeitsverzeichnis() im Worker nur diese zulaesst; verwechseln kann man nichts,
weil Roh-Verzeichnisse exakt wie die Job-ID heissen (vollstaendige UUID) und
fertige Ablagen "Titel (Jahr) [kurz-id]".

Nicht in die Job-Zeile geschrieben, obwohl das sauberer waere: create_all legt
nur fehlende TABELLEN an, keine Spalten - und Bestandsjobs (genau dieser Fall)
haetten den Wert ohnehin nicht.

11 Tests, darunter der echte Fall, ein toter CIFS-Mount und der Klassiker
"/app/media-boese".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:09:44 +02:00
Hitonabi 843c54cd1a fix(test): Erwartung korrigiert - ohne Quelle ist die Confidence 0,3, nicht 0,0
Ampel / ampel (push) Successful in 27s
Meine eigene Testannahme war falsch, nicht der Code: Antwortet keine
Metadaten-Quelle, setzt _scan_video bewusst 0,3 mit `type: unknown` und behaelt
den Disc-Titel. Der Rip laeuft dann trotzdem, die Datei heisst nur wie das
Disc-Label. Genau dieser Zweig ist Bestand und richtig.

Gefunden von der Ampel (Lauf 140) - lokal laeuft dieses Modul nicht, es braucht
fcntl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:01:34 +02:00
Hitonabi ee123f2a90 fix(install): Paketbefehl je Distribution statt stur apt
Ampel / ampel (push) Failing after 29s
Commander-Frage: "Was wenn der Unterbau nicht Debian ist?" Berechtigt - bei
fehlendem Compose nannte install.sh stur `sudo apt install
docker-compose-plugin`. Auf Arch, Fedora oder openSUSE ist das ein Befehl, den
es nicht gibt.

Erkannt wird ueber das VORHANDENE Paketwerkzeug (apt-get/dnf/yum/pacman/zypper/
apk), nicht ueber /etc/os-release: Ein Derivat kann sich anders nennen als sein
Unterbau, sein Paketwerkzeug liegt aber im PATH. Findet sich keines, wird nichts
behauptet. Auf der Rippy-VM gegengeprueft (Ubuntu 26.04 -> apt), unter Git Bash
gegengeprueft (kein Treffer -> keine Behauptung).

Dazu steht IMMER der Weg da, der auf jeder Distribution funktioniert:
get.docker.com bringt das Compose-Plugin mit. Paketnamen wandern, dieses Skript
nicht.

README stellt jetzt oben klar, dass die Distribution gleichgueltig ist - Rippy
bringt MakeMKV, HandBrake und abcde in seinen eigenen Containern mit, vom Host
braucht es nur Docker und einen Kernel, der das Laufwerk sieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:42 +02:00
Hitonabi 0c269a97ee fix(metadaten): exakt schlaegt unscharf - Jikan beendet die Kette nur bei Treffer
Der SAVEPOINT v3.16 vermutete, das Aehnlichkeits-Gate 0,55 sei "grosszuegig" und
Jikan stehe in der Kette zu frueh (vor OMDb). Nachgerechnet - titel_aehnlichkeit
ist eine reine Funktion - ergibt sich ein anderes Bild als vermutet:

  "Alien"           vs. "Alien 9"           -> 0,83  TRIFFT
  "Inception"        vs. "Deception"         -> 0,78  TRIFFT
  "Hero"             vs. "Heroman"           -> 0,73  TRIFFT
  "The Dark Knight"  vs. "Dark Knight Rises" -> 0,69  TRIFFT

Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten mit Confidence
0,85 - und OMDb, das ihn kennt, wurde nie gefragt.

Bemerkenswert dabei, und das widerlegt die Vermutung "einfach zu niedrig": Fuer
den Fall, fuer den Jikan ueberhaupt eingebaut wurde, ist das Gate sogar zu HOCH.
"Evangelion 2.22" gegen den MAL-Titel "Evangelion: 2.0 You Can (Not) Advance"
ergibt 0,51 und faellt durch. Eine einzelne Zahl kann beides nicht leisten.

Deshalb zwei Schwellen statt Umsortieren (Reihenfolge bleibt, AGENTS Regel B):
Nur ein praktisch exakter Titel (>= 0,9) beendet die Kette. Ein unscharfer
Treffer wird GEMERKT, dann wird OMDb gefragt - und erst wenn OMDb nichts hat,
kommt er als VORSCHLAG mit Confidence 0,6 zum Zug. Damit gewinnt "exakt" immer
gegen "unscharf", egal aus welcher Quelle.

Antwort auf die Commander-Frage "JIKAN ist drin - wird das genutzt?": ja, und ab
jetzt an der richtigen Stelle.

Ehrlicher Vorbehalt: Live gegengeprueft ist das nicht - MyAnimeList war waehrend
dieser Sitzung durchweg weg (Jikan antwortete HTTP 504 auf jede Anfrage). Die
Arithmetik des Gates ist davon unberuehrt und in test_jikan_helpers.py
festgehalten, die Kettenlogik in test_prescan_helpers.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:26 +02:00
Hitonabi 6db64230eb fix(dashboard): zwei Placebos raus, Restzeit rein - und Loeschen sagt die Zahl
Commander: "Der Server Status muss dringend ueberarbeitet werden, das Dashboard
soll ja quasi alles auf einen Blick zeigen." Berechtigt - die Karte hiess "Echte
Live-Daten" und enthielt drei Angaben, von denen zwei erfunden waren:

1. "Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der Fortschritt des
   Jobs - bzw. eine feste 15 bzw. 5, wenn keiner lief. Rippy misst nirgends
   CPU-Last, also wird sie auch nicht behauptet.
2. Die Verlaufskurve daneben war Math.random(). Reine Dekoration, die wie eine
   Messung aussah.
3. "N Worker Online" zaehlte die registrierten Eintraege aus /system/info - auch
   Leichen alter Container-Rebuilds. Die echte Erreichbarkeit steht in
   /capabilities (Celery-Ping) und wird jetzt von dort geholt.

Statt dessen: laufende Phase im Klartext mit RESTZEIT, jeder erreichbare Worker
mit Kernen/Vektorbefehlen/extern, freier Platz mit Warnung, wenn er nicht mehr
fuer eine Disc reicht. Und wenn kein Worker antwortet, steht das rot da statt
"1 Worker Online".

Restzeit auch im aktiven Rip-Banner und in der Job-Tabelle. Solange die
Datenlage duenn ist, steht dort "Restzeit wird gemessen" - keine erfundene Zahl.

Job entfernen fragt jetzt vorher nach den Rohdaten und nennt die GB, die daneben
liegen bleiben und danach nicht mehr erreichbar sind - auf Wunsch loescht es sie
mit. Der Fall aus v3.14, bei dem 75 GB unsichtbar verwaisten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:08 +02:00
Hitonabi 638edc6a9f feat(ui): Presets vom Worker, Warnung vor unerreichbaren Pfaden, Browser weg
Vier Commander-Punkte auf einmal, alle am selben Ort.

Presets (Punkt 1 + 3b): Die drei Auswahllisten in Einstellungen kommen jetzt
vom Worker statt aus dieser Datei, gefiltert auf die passende Aufloesung - die
4K-Liste bietet keine 1080p-Presets mehr als Normalfall an (genau dieser Griff
rechnete in v3.12 eine 4K-UHD auf 1080p herunter; bewusstes Verkleinern steht
jetzt in einer eigenen, benannten Gruppe). Knopf "Bestes waehlen" uebernimmt die
Empfehlung fuer alle drei Disc-Typen, und unter jeder Liste steht, WARUM. Ein
gespeicherter Wert, den der Worker nicht kennt, wird benannt statt verschluckt -
so faellt eine falsche Bestandseinstellung ueberhaupt auf.
Der Wizard entscheidet nicht mehr selbst, sondern nimmt dieselbe Empfehlung.
Damit gibt es nur noch EINE Stelle mit dieser Logik, und die hat Tests.

Warnung vor dem Start (Punkt 6 des Savepoints): Waehlt man einen externen
Encoder, der Quelle oder Ziel nicht erreicht, steht das JETZT im Rip-Dialog -
nicht erst nach einer Stunde Rip. Geprueft wird mit derselben Regel, die
pfad_lokal() im Worker anwendet. Bei "Automatisch" wird genannt, welcher Worker
die Aufgabe kaputtmachen koennte: die geteilte Queue nimmt den ersten freien.

Datei-Browser (Punkt 7): "Warum wird hier der Datei Browser noch angezeigt - das
ist doch quatsch." Er ist nicht wirklich redundant (nur ueber ihn geht ein
freier Zielordner fuer EINEN Rip), aber er ueberschrieb die Schnellwahl
stillschweigend. Jetzt eingeklappt - wer ihn aufklappt, entscheidet bewusst, und
/browse wird erst dann geholt. Nebenbefund: browseFiles wurde geladen und NIE
angezeigt, samt ungenutztem File-Icon - beides raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:53 +02:00
Hitonabi aea26493db feat(installer): der Windows-Installer holt die Freigabe jetzt selbst von Rippy
DER BLOCKER aus dem SAVEPOINT v3.16 ist zu. Beide Installer (Kommandozeile und
GUI) fragen GET /worker-setup/pfad-map, pruefen mit Test-Path, ob DIESER PC die
Freigabe wirklich erreicht, und schreiben `set RIPPY_PATH_MAP=...` in
start-tray.bat und start-worker.bat.

Der Commander muss dafuer nichts ueber Container-Pfade wissen - das Feld bleibt
leer, der Installer holt den Wert. Von Hand geht es trotzdem (Knopf "Von Rippy
holen" bzw. -PfadMap), falls dieser PC die Freigabe anders erreicht.

Ist nichts erreichbar, steht das als Klartext im Log samt dem haeufigsten Grund
(fehlende Zugangsdaten - Freigabe einmal im Explorer oeffnen). Die Installation
laeuft weiter: ein Worker, der sich meldet und ehrlich scheitert, ist besser als
einer, der nicht existiert. Ein LEERES RIPPY_PATH_MAP wird bewusst nicht
gesetzt - pfad_lokal() liest das als "kein Mapping", und die Fehlermeldung im
Worker unterscheidet genau diese beiden Faelle.

GUI: neues Feld samt Knopf, Fenster 560->648 px. Layout headless gerendert und
angesehen (nichts ueberlappt, Umlaute korrekt), beide Dateien mit echtem
PowerShell 5.1 auf Parser-Fehler geprueft, UTF-8-BOM erhalten. .exe neu gebaut.
install.ps1 hatte durch ein Werkzeug LF statt CRLF bekommen - zurueckgedreht.

remote-transcode-worker.yml richtiggestellt: Dort stand "mount -t nfs
<rippy-host>:/srv/rippy" - das geht NICHT, auf der Rippy-Maschine laeuft kein
NFS- und kein Samba-Server, sie ist selbst nur Client der NAS. Der Weg, der
funktioniert: dieselbe Freigabe einhaengen, die auch Rippy nutzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:23 +02:00
Hitonabi 8b7bdfe798 feat(api): drei Endpunkte, die das Raten beenden - plus Restzeit in /jobs
GET /worker-setup/pfad-map - was ein externer Worker fuer RIPPY_PATH_MAP
eintragen muss, abgeleitet aus Rippys eigenen Mounts. DAS war der stille
Blocker: /app/media/... sind Container-Pfade, ein externer Worker sieht sie nur
uebersetzt, und dieses Mapping setzte NIEMAND. Externes Encoden konnte deshalb
nie funktionieren, obwohl das Celery-Routing einwandfrei arbeitete (Job
95afdc89: angenommen, 182 ms spaeter abgelehnt). Hat Rippy keine Freigabe, sagt
der Endpunkt das im Klartext samt Abhilfe - statt ein leeres Mapping zu liefern.

GET /presets - Preset-Namen der Worker plus Empfehlung MIT Begruendung. Die
Namen standen bisher fest verdrahtet an vier Stellen im UI.

GET /jobs/{id}/rohdaten + DELETE /jobs/{id}?rohdaten=true - was liegen bleibt,
wenn ein Job aus der Liste fliegt. Grund (v3.14): Entfernen loescht bewusst
keine Dateien, aber Job und Rohdaten haengen nur an der Job-ID - der Rohschnitt
ist danach UNERREICHBAR. Damals verwaisten so 75 GB unsichtbar, gefunden erst
per SSH.

/jobs liefert jetzt eta_sekunden + eta_text. Die Schaetzung entsteht in der API
und nicht im Browser, aus drei Gruenden: ein Seitenwechsel setzte die Messreihe
zurueck, zwei offene Tabs zeigten verschiedene Zahlen, und fuer einen externen
Encoder-Worker gaebe es gar keine - genau dort wollte der Commander sie sehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:06 +02:00
Hitonabi 4195854bf8 feat(bausteine): Preset-Liste, Pfad-Vorschlag und Restzeit - alles gemessen
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy
bisher geraten oder gar nicht gestellt hat.

1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers
   wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit
   endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein
   erfundener Name laesst die Kompression scheitern.
   Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die
   Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
   Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/`
   steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt
   zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren
   Encoder. Zwei Fragen, zwei Quellen.

2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer
   RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe
   selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus
   Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht
   ableitbar (AGENTS Regel D).
   Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join
   baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still
   wirkungslos geblieben. _mountpoint nutzt jetzt posixpath.

3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne
   Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen
   wird der erste, den der Worker kennt. Hardware nur, wenn die Familie
   wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte
   unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt
   es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar.

4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und
   Kompression haben nichts miteinander zu tun), Stillstand verlaengert die
   Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es
   ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min
   landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den
   50-Stunden-Lauf unsichtbar machte.

Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI
nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war
daraus nicht zu erraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:57:51 +02:00
Hitonabi a468e4b6a4 docs(savepoint): Commander-Feedback als Landkarte - neun Punkte, neun Staende
Ampel / ampel (push) Successful in 27s
Rueckfrage des Commanders: "Was ist mit diesen ganzen Sachen?" - er wollte
wissen, ob seine Liste aus der ersten Selbst-Einrichtung vollstaendig erfasst
ist. Nachgeprueft: alle neun Punkte kommen im v3.16-Block vor.

Einer war aber nur IMPLIZIT zu finden: dass seine RX 9070 XT wirklich
encodieren soll, musste man aus zwei anderen Punkten erschliessen (Pfad-Mapping
und Preset-Namen). Genau so faellt etwas durch.

Jetzt steht seine Liste woertlich als Tabelle im Savepoint, jede Zeile mit
Stand und Verweis auf die Prioritaetenliste. Drei erledigt, eine Frage
beantwortet, fuenf offen.

Aufgeteilt wo noetig: Punkt 3 war eigentlich drei Forderungen - Vektorbefehle
auslesen (erledigt), das Beste empfehlen (offen), und Warnung von orange auf
gruen (erledigt, wirkt fuer seinen PC ab dem Deploy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:10:59 +02:00
Hitonabi 4c78914529 docs(savepoint): v3.16 Uebergabe - externes Encoden konnte nie funktionieren
Ampel / ampel (push) Successful in 27s
Diese Sitzung endete wegen vollem Kontext. Der Savepoint ist so geschrieben,
dass die naechste ohne Nachfragen weitermachen kann.

ZUSTAND, den man kennen MUSS: Repo fd1feaa mit gruener Ampel, aber die VM laeuft
noch 61c38a0 - die letzten zwei Commits sind NICHT deployt, und der
Windows-Worker des Commanders laeuft mit altem Code. Der 80-GB-Rohschnitt liegt
intakt auf der NAS, "Neu komprimieren" genuegt also, sobald der Blocker faellt.

DER KERNBEFUND aus dem fehlgeschlagenen Test-Rip, mit Messung statt Vermutung:
Das gezielte Routing funktionierte einwandfrei - sein PC nahm die Aufgabe an und
lehnte sie 182 ms spaeter ab. Drei Ursachen griffen ineinander, zwei sind
behoben, eine bleibt der Blocker (RIPPY_PATH_MAP wird von niemandem gesetzt).

Die offenen Punkte stehen nach Prioritaet, jeweils MIT dem Kontext, der zum
Weiterarbeiten fehlt: Format von pfad_lokal fuer Punkt 1, warum Punkt 2 nicht
ohne die Preset-Liste vom Worker geht (AGENTS Regel D - Namen erfinden hat das
Projekt zweimal teuer bezahlt), welcher Messpunkt sich fuer die ETA bewaehrt hat
(/proc/<pid>/fdinfo statt Fortschrittsanzeige), und welche Datei jeweils dran ist.

Dazu ein eigener Abschnitt "Fallen, die diese Sitzung gekostet haben" - deutsche
Anfuehrungszeichen in doppelt gequoteten Python-Strings (dreimal), Code in
f-String-Einsetzungen, docker exec ohne sh -c verschluckt Globs, /proc-Suche
trifft die eigene Shell, und pydantic-core springt lokal wiederholt zurueck.
Alles auch in den Memory-Notizen, damit es nicht wieder entdeckt werden muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:06:32 +02:00
66 changed files with 9338 additions and 1217 deletions
+84 -1
View File
@@ -55,7 +55,7 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
## Aktueller Stand (25.07.2026)
## Aktueller Stand (26.07.2026)
-**E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
-**Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
@@ -73,6 +73,25 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
-**Etappe 20 (v3.15):** Aufräum-Runde — `/capabilities` 1,010 s → 0,003 s
(Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
-**Etappe 21 (v3.17):** Der Blocker externes Encoden ist zu —
`RIPPY_PATH_MAP` leitet Rippy aus seinen eigenen Mounts ab, der Installer holt
es selbst; Presets kommen vom Worker statt aus dem Quelltext; Dashboard ohne
Placebos, mit Restzeit. Dazu vier Bestandsfehler, alle live gemessen (u. a.
„Neu komprimieren" ging nie, und `os.path.isdir` hing im Kernel)
-**Etappe 22 (v3.18):** Auswurf wirkt endlich (MakeMKV verriegelt die Tür —
erst entriegeln, dann prüfen statt glauben), externer Worker meldet sein Log
nach Rippy, zeigt den laufenden Job und nimmt mehrere Aufträge an. Dazu **die
Mount-Ursache**: Die CIFS-Verbindung lebt in der Netz-Namespace des
api-Containers und stirbt mit ihm — eine Wache heilt das jetzt selbst
-**Etappe 23 (v3.19):** Sprachwahl vor dem Rip (Ton + Untertitel, Automatik
einstellbar), Auswurf wirkt wirklich (MakeMKV verriegelt die Tür), externer
Worker mit Verwaltungsfenster/Deinstaller/Slots, Schlüssel-Automatik für 4K,
Mount-Wiederanbindung 202 s → 8 s, Weitergabe an einem frischen Klon geprüft
-**Etappe 24 (v3.20):** Rippy bremste sich selbst aus — das Rate-Limit lag
unter der eigenen Last (100/min gegen 123/min), hinter dem Proxy teilten alle
Clients einen Eimer, und ein abgewiesener Abruf leerte das UI. Dazu: der
„Neu"-Knopf kennt jetzt die Phase (Rip oder Kompression) und fragt, wo er es
nicht weiß; zwei Fehler im Deploy-Weg behoben
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
## Was diese Sitzungen wiederholt gekostet hat
@@ -87,3 +106,67 @@ Jedes Mal hätte eine Messung von unter einer Minute gereicht.
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
vorherzusagen.
**Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der
laut scheitert** (26.07.2026). Ein `except Exception: pass` in einer
Vorrats-Schleife hat eine Stunde gekostet: Der Vorrat blieb leer, die Funktion
lief direkt aufgerufen einwandfrei, und der Grund stand nirgends. Gefunden erst
über die Thread-Zustände (`/proc/<pid>/task/*/stat`, Zustand `D` = im Kernel
blockiert). Jede Hintergrund-Schleife MELDET ihren Fehler, und wer einen Vorrat
anlegt, macht sein Alter abfragbar (`GET /health/vorraete`) — sonst ist am
Endpunkt selbst nichts zu sehen.
**Ein Rückgabewert ist kein Beweis, wo die Wirkung prüfbar ist** (26.07.2026,
zweimal am selben Abend). `CDROMEJECT` quittiert Erfolg auf einem verriegelten
Laufwerk und wirft nichts aus; `mount` quittiert Erfolg auf einer Verbindung, die
Sekunden später stirbt. Beide Fehler waren monatelang unsichtbar, weil der Code
dem Rückgabewert glaubte. Nach einer Aktion den ZUSTAND fragen — und wenn er
flattert, zweimal mit Abstand.
**Bei „zu langsam" die DAUER je Schritt messbar machen, nicht die plausibelste
Ursache beheben** (26.07.2026). Eine Mount-Wiederanbindung brauchte 150 s; die
erste, sehr plausible Erklärung war falsch, und die Änderung machte es langsamer
(202 s). Erst Zeitstempel im Log zeigten die Stelle: ein `os.makedirs` in Zeile
eins, das drei Minuten im Kernel hing. Danach 8 s. Wer eine Dauer nicht
aufschlüsselt, optimiert die falsche Stelle.
**Netz-Pfade nie ungebremst anfassen.** `os.path.isdir`/`open` auf einem toten
CIFS-Mount blockieren im Kernel und lassen sich aus Python NICHT abbrechen. Ein
Kind-Prozess lässt sich abbrechen: `timeout N ls -d <pfad>` (Muster in
`mounts.ist_erreichbar` und `rohdaten.verzeichnis_da`). Und „konnte nicht
nachsehen" ist etwas anderes als „ist nicht da" — beides zu vermischen erzeugt
falsche Aussagen im UI.
**Ein verpasster Abruf ist keine Nachricht über die Welt** (26.07.2026). Im UI
stand fünfmal `catch(() => [])`: Jeder fehlgeschlagene Abruf hieß damit „es gibt
keine Jobs, keine Laufwerke, keine Ablagen" — die Liste leerte sich für einen
Takt und füllte sich vier Sekunden später wieder. Der Commander meldete das als
„wird oft neu geladen", und die Ursache war unsichtbar, weil der Fehlerzweig
nichts protokollierte. Wer nichts Neues weiß, behält, was er wusste: bei
Fehlschlag `null` und den alten Stand stehen lassen — nie einen leeren Wert, der
als Aussage gelesen wird.
**Eine eigene Schutzbremse gegen die eigene Last rechnen** (26.07.2026). Das
Rate-Limit stand auf 100 Anfragen/min, während ein einziger offener Tab 111/min
verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12). Rippy bremste sich
also permanent selbst aus, und niemand sah es: Der 429 stand in keinem Log, und
das UI verbuchte ihn als Leermeldung. Dazu der zweite Fehler — hinter einem
Reverse-Proxy ist `request.client.host` IMMER der Proxy, also hatten Browser,
zweiter Tab und Windows-Tray EINEN gemeinsamen Eimer (812 von 876 Anfragen kamen
scheinbar von einer IP). Wer eine Grenze setzt, rechnet die eigene Grundlast vor,
schreibt sie als Kommentar dazu und lässt jedes Greifen protokollieren.
**Eine geschluckte Warnung ist eine Falle** (26.07.2026). `cp "$ENV_SRC" .env
2>/dev/null || echo "WARNUNG: …"` scheiterte auf der Ziel-VM bei JEDEM Deploy,
weil die .env dort anders lag. Die Zeile scrollte im Build-Rauschen vorbei,
gebaut wurde still mit einer zwei Tage alten Kopie — mit einem toten
Download-Notbehelf darin, an dem jeder worker-Build abbrach. Entweder abbrechen
oder so laut werden, dass es nicht zu übersehen ist (Dateidatum, Kandidatenliste);
ein `|| echo` in einem 200-Zeilen-Log ist keins von beidem.
**Wenn der Commander eine Korrelation nennt, ist das eine Spur.** „Wenn der
Worker installiert ist, wird der Bereich oft neu geladen" klang nach Bauchgefühl
und war exakt richtig: `tray.py` fragt `/api/jobs` über Port 80, landet damit im
Rate-Limit-Eimer des Browsers und drückt ihn über die Grenze. Dieselbe Lehre wie
bei „auf Windows ginge das sofort" (Disc-Schlüssel) — die Beobachtung ernst
nehmen, auch wenn die vermutete Erklärung („Celery-Ping?") daneben liegt.
+17 -1
View File
@@ -36,7 +36,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
| Rate-Limiting pro Client-IP~~100/min~~ **600/min** (26.07.2026, siehe Abweichungen) | ✔ M | | |
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
| React-UI (statisch via Nginx) | ✔ M | | |
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
@@ -183,6 +183,22 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
- **26.07.2026 — Rate-Limit von 100/min auf 600/min:** Das Muss-Feature
(„Rate-Limiting pro Client-IP") bleibt unverändert, nur die Zahl ändert sich.
Begründung: 100/min lag UNTER Rippys eigener Last. Nachgerechnet an den
Taktgebern im UI verursacht ein einziger offener Tab 111 Anfragen pro Minute
(Dashboard 75 + Log-Kasten 24 + Laufwerks-Suche 12), ein installierter
Windows-Worker weitere 12. Die Bremse griff also im Normalbetrieb permanent —
97 Antworten mit HTTP 429 im nginx-Log —, und weil das UI einen abgewiesenen
Abruf als „nichts da" verbuchte, leerte sich die Job-Liste im Sekundentakt.
Genau das hat der Commander als „wird oft neu geladen" gemeldet. 600/min =
10 Anfragen pro Sekunde: Luft für mehrere Tabs und Worker, während der Zweck
der Bremse (ein Skript in einer Endlosschleife, Hunderte pro Sekunde) weiter
erfüllt ist. Die Rechnung steht als Kommentar in `ratelimit.py` und ist durch
`test_grenze_deckt_die_eigene_last_ab` festgehalten — wer die Zahl senkt, muss
dort vorbei. Zweiter Teil derselben Reparatur: Hinter dem nginx ist
`request.client.host` immer der Proxy, alle Clients teilten sich also EINEN
Eimer; jetzt gilt `X-Real-IP`.
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
+6
View File
@@ -11,6 +11,12 @@ in Docker, komplett lokal — keine Cloud, keine Telemetrie.
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
**Welche Distribution, ist gleichgültig** — Debian, Ubuntu, Arch, Fedora,
openSUSE, Alpine. Rippy bringt alles Nötige (MakeMKV, HandBrake, abcde) in
seinen eigenen Containern mit; vom Host braucht es nur Docker und einen Kernel,
der das Laufwerk sieht. `install.sh` erkennt das Paketwerkzeug selbst und nennt
dir den passenden Befehl, falls etwas fehlt.
```bash
git clone <repo-url> rippy && cd rippy
sudo ./install.sh
+1193 -1
View File
File diff suppressed because it is too large Load Diff
+54 -2
View File
@@ -34,7 +34,34 @@ set -euo pipefail
REPO_URL="$1"
ENV_SRC="${2:-$HOME/rippy/.env}"
CLONE="${3:-$HOME/notfall-rippy}"
DIENST="$4"
# ⚠️ ${4:-}, nicht "$4" (Befund 26.07.2026): Leere Argumente ueberleben den Weg
# durch ssh nicht — die Gegenseite bekommt die Befehlszeile als EINEN String und
# parst sie neu, wobei "" ersatzlos verschwindet. Wer also ohne RIPPY_ENV_SRC,
# RIPPY_CLONE und ohne Dienst-Argument deployte (der dokumentierte Normalfall!),
# bekam von 'set -u' nur ein "line 5: $4: unbound variable" und kein Deploy.
DIENST="${4:-}"
# ⚠️ NICHT DEPLOYEN, WÄHREND EIN JOB LÄUFT (Vorfall 26.07.2026, selbst verursacht)
#
# `docker compose up -d --build` baut den worker-Container neu — und tötet damit
# einen laufenden Rip. Genau das ist passiert: mitten in einem Blu-ray-Rip, bei
# 12 %, nach 5,1 GB. Die Warnung stand im SAVEPOINT und half nichts, weil sie
# niemand las und nichts sie prüfte. Jetzt prüft es das Skript.
#
# Übersteuern mit RIPPY_TROTZDEM=1 — dann ist es eine Entscheidung und kein
# Versehen.
if [ -z "${RIPPY_TROTZDEM:-}" ]; then
ANTWORT="$(curl -s -m 5 http://localhost:8000/health/arbeit 2>/dev/null || true)"
case "$ANTWORT" in
*'"arbeit":true'*|*'"arbeit": true'*)
echo "ABBRUCH: Auf dieser Maschine läuft gerade ein Job." >&2
echo " Ein Rebuild des worker-Containers würde ihn töten." >&2
echo "$ANTWORT" | tr ',' '\n' | grep -E '"(status|titel|progress)"' >&2 || true
echo " Warten, oder bewusst überstimmen: RIPPY_TROTZDEM=1 ./deploy.sh" >&2
exit 1
;;
esac
fi
if [ ! -d "$CLONE/.git" ]; then
git clone --branch main "$REPO_URL" "$CLONE"
@@ -42,7 +69,32 @@ fi
cd "$CLONE"
git fetch origin main
git reset --hard origin/main
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
# ⚠️ Eine fehlende .env-Quelle war bisher eine geschluckte Warnung — und damit
# eine Falle (Befund 26.07.2026). Auf der Ziel-VM lag die echte .env unter
# ~/projects/rippy/.env, der Default zeigte auf ~/rippy/.env. Das `cp` schlug
# also jedes Mal fehl, die Zeile scrollte im Build-Ausgabe-Rauschen vorbei, und
# gebaut wurde mit der Kopie im Klon: zwei Tage alt, mit einem inzwischen toten
# MAKEMKV_URL_BASE-Notbehelf und einem JWT_SECRET_KEY aus der Zeit vor dem
# Auth-Rueckbau. Ergebnis: jeder worker-Build brach ab, und die Ursache stand
# nirgends.
if [ -f "$ENV_SRC" ]; then
cp "$ENV_SRC" .env
echo "OK: .env aus $ENV_SRC uebernommen."
elif [ -f .env ]; then
echo "WARNUNG: $ENV_SRC gibt es nicht — es gilt die .env IM KLON ($CLONE/.env)." >&2
echo " Stand dieser Datei: $(date -r .env '+%d.%m.%Y %H:%M')" >&2
echo " Ist das nicht gewollt: RIPPY_ENV_SRC=<pfad> setzen." >&2
ls -1 "$HOME"/*/.env "$HOME"/*/*/.env 2>/dev/null | head -5 | sed 's/^/ Kandidat: /' >&2 || true
else
echo "ABBRUCH: Keine .env — weder unter $ENV_SRC noch im Klon." >&2
echo " Ohne sie baut compose mit leeren Werten (kein DB-Passwort," >&2
echo " keine API-Schluessel). RIPPY_ENV_SRC=<pfad> setzen." >&2
exit 1
fi
sed -i '/^RIPPY_VERSION=/d' .env
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
docker compose -p rippy up -d --build $DIENST
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
REMOTE
+36 -12
View File
@@ -1,17 +1,36 @@
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz.
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz (Linux).
# Für Windows gibt es den nativen Weg: RippyWorkerSetup.exe (Einstellungen → Worker).
#
# ══ DAS ENTSCHEIDENDE: der Worker muss die Dateien SEHEN ══════════════════════
# Rippy schickt ihm Pfade wie /app/media/rippy/<job>/title_t00.mkv — Pfade
# INNERHALB des Rippy-Containers. Dieser Container muss dieselben Daten unter
# GENAU DEMSELBEN Pfad haben, sonst nimmt er die Aufgabe an und lehnt sie
# Millisekunden später ab („Keine Roh-MKVs gefunden" — genau so passiert am
# 26.07.2026, Job 95afdc89).
#
# Richtigstellung 26.07.2026: Hier stand vorher „mount -t nfs
# <rippy-host>:/srv/rippy /mnt/rippy". Das geht NICHT — auf der Rippy-Maschine
# läuft kein NFS- und kein Samba-Server, sie ist selbst nur Client der NAS.
# Der Weg, der funktioniert: DIESELBE Freigabe einhängen, die auch Rippy nutzt.
#
# Auf der GPU-Maschine:
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
# 2. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
# 3. RIPPY_HOST unten setzen und starten:
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
# 2. Die Freigabe einhängen, die Rippy als Arbeitsverzeichnis UND Ablage
# benutzt (Einstellungen → Ripping). Heißt sie in Rippy „rippy", liegt
# sie dort unter /app/media/rippy — also z. B.:
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
# 3. Unten RIPPY_HOST setzen, den Freigabe-Pfad im volumes-Block anpassen
# und starten:
# RIPPY_HOST=<LAN-IP-der-Rippy-Maschine> docker compose \
# -f deploy/remote-transcode-worker.yml up -d --build
#
# Passt der Pfad im Container nicht (weil die Freigabe woanders hängt), gibt es
# als Ausweg RIPPY_PATH_MAP — dieselbe Übersetzung, die der Windows-Worker
# nutzt, Format: /app/media/rippy=/mnt/anderer-pfad (Paare per „;").
# Den fertigen Wert nennt GET /worker-setup/pfad-map.
#
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
# unter Einstellungen → Verarbeitung auf. Er bedient NUR die transcode-Queue;
# unter Einstellungen → Worker auf. Er bedient NUR die transcode-Queue;
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
services:
@@ -30,10 +49,15 @@ services:
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
- API_URL=http://${RIPPY_HOST}:8000
# Nur nötig, wenn die Freigabe hier NICHT unter demselben Pfad liegt wie
# in Rippy. Fertigen Wert holen: curl http://${RIPPY_HOST}/api/worker-setup/pfad-map
- RIPPY_PATH_MAP=${RIPPY_PATH_MAP:-}
volumes:
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
- /mnt/rippy/media:/app/media
- /mnt/rippy/temp:/app/temp
# LINKS der Pfad auf DIESER Maschine, RECHTS der Pfad, den Rippy nennt.
# Rechts muss stehen, was in Rippy als Speicherziel heißt: ein Ziel
# namens „rippy" ist dort /app/media/rippy. Prüfen mit
# curl http://<rippy-host>/api/worker-setup/pfad-map
- /mnt/rippy-freigabe:/app/media/rippy
devices:
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
- /dev/dri:/dev/dri
Binary file not shown.
-68
View File
@@ -1,68 +0,0 @@
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
#
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
# keine Versionen ein.
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
Set-Location $PSScriptRoot
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
$bmp = New-Object System.Drawing.Bitmap 256, 256
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = "AntiAlias"
$g.Clear([System.Drawing.Color]::Transparent)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$png = $ms.ToArray()
$icoStream = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter $icoStream
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
$bw.Write($png); $bw.Flush()
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
if (-not (Get-Module -ListAvailable ps2exe)) {
Write-Host "Bootstrappe NuGet + PSGallery ..."
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
Write-Host "Installiere ps2exe ..."
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
}
Import-Module ps2exe
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
#
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
# Installer und besser als ein Fehlschlag mitten im Lauf).
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
} else {
Write-Host "FEHLER: keine .exe erzeugt"
}
-426
View File
@@ -1,426 +0,0 @@
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Python 3.10+ auf der Maschine - kein Docker, kein git.
#
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
# ohne BOM: "Größe" + Unerwartetes Token
# mit BOM: "Größe" + laeuft
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
param(
[string]$RippyHost = "",
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
# trotzdem "C:\Program Files".
#
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
$InstallDir = $StandardZiel
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
# damit der Installer nicht wie ein Fremdkörper wirkt.
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
# --- Fenster ---------------------------------------------------------------
$form = New-Object System.Windows.Forms.Form
$form.Text = "Rippy Encoding-Worker - Installation"
$form.ClientSize = New-Object System.Drawing.Size(560, 560)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.BackColor = $cBg
$form.ForeColor = $cText
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
try {
$ibmp = New-Object System.Drawing.Bitmap 32, 32
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
$ig.SmoothingMode = "AntiAlias"
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
} catch {}
# --- Kopfbereich mit Farbverlauf --------------------------------------------
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
$kopf = New-Object System.Windows.Forms.Panel
$kopf.Dock = "Top"
$kopf.Height = 104
$form.Controls.Add($kopf)
$kopf.Add_Paint({
param($absender, $e)
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
$mischung.Positions = @(0.0, 0.45, 1.0)
$pinsel.InterpolationColors = $mischung
$e.Graphics.FillRectangle($pinsel, $flaeche)
$pinsel.Dispose()
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
$e.Graphics.SmoothingMode = "AntiAlias"
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
$weiss.Dispose()
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
[System.Drawing.Brushes]::White, 88, 26)
$e.Graphics.DrawString(
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
$fTitel.Dispose(); $fUnter.Dispose()
})
# Rippy-Adresse
$lblHost = New-Object System.Windows.Forms.Label
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
$lblHost.ForeColor = $cGedaempft
$form.Controls.Add($lblHost)
$txtHost = New-Object System.Windows.Forms.TextBox
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
$txtHost.Text = $RippyHost
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
$txtHost.BackColor = $cFeld
$txtHost.ForeColor = $cText
$txtHost.BorderStyle = "FixedSingle"
$form.Controls.Add($txtHost)
# Worker-Name
$lblName = New-Object System.Windows.Forms.Label
$lblName.Text = "Name dieses Workers (frei wählbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 176)
$lblName.Size = New-Object System.Drawing.Size(500, 18)
$lblName.ForeColor = $cGedaempft
$form.Controls.Add($lblName)
$txtName = New-Object System.Windows.Forms.TextBox
$txtName.Location = New-Object System.Drawing.Point(24, 196)
$txtName.Size = New-Object System.Drawing.Size(300, 26)
$txtName.Text = $WorkerName
$txtName.BackColor = $cFeld
$txtName.ForeColor = $cText
$txtName.BorderStyle = "FixedSingle"
$form.Controls.Add($txtName)
# Zielverzeichnis - frei wählbar, Standard "Programme"
$lblPfad = New-Object System.Windows.Forms.Label
$lblPfad.Text = "Installieren nach:"
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
$lblPfad.ForeColor = $cGedaempft
$form.Controls.Add($lblPfad)
$txtPfad = New-Object System.Windows.Forms.TextBox
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
$txtPfad.Size = New-Object System.Drawing.Size(390, 26)
$txtPfad.Text = $StandardZiel
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtPfad.BackColor = $cFeld
$txtPfad.ForeColor = $cText
$txtPfad.BorderStyle = "FixedSingle"
$form.Controls.Add($txtPfad)
$btnPfad = New-Object System.Windows.Forms.Button
$btnPfad.Text = "Ändern ..."
$btnPfad.Location = New-Object System.Drawing.Point(424, 251)
$btnPfad.Size = New-Object System.Drawing.Size(100, 28)
$btnPfad.FlatStyle = "Flat"
$btnPfad.BackColor = $cFeld
$btnPfad.ForeColor = $cText
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
$form.Controls.Add($btnPfad)
$btnPfad.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
$dlg.ShowNewFolderButton = $true
if ($dlg.ShowDialog() -eq "OK") {
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
}
})
# Autostart
$chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
$chkAuto.Location = New-Object System.Drawing.Point(24, 290)
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
$chkAuto.Checked = $true
$chkAuto.ForeColor = $cText
$form.Controls.Add($chkAuto)
# Log-Bereich
$log = New-Object System.Windows.Forms.TextBox
$log.Location = New-Object System.Drawing.Point(24, 322)
$log.Size = New-Object System.Drawing.Size(500, 150)
$log.Multiline = $true
$log.ReadOnly = $true
$log.ScrollBars = "Vertical"
$log.BackColor = $cPanel
$log.ForeColor = $cText
$log.BorderStyle = "FixedSingle"
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
$form.Controls.Add($log)
# Knöpfe
$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Installieren"
$btnInstall.Location = New-Object System.Drawing.Point(24, 486)
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
$btnInstall.BackColor = $cAmber
$btnInstall.ForeColor = $cBg
$btnInstall.FlatStyle = "Flat"
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$form.Controls.Add($btnInstall)
$btnStart = New-Object System.Windows.Forms.Button
$btnStart.Text = "Worker starten"
$btnStart.Location = New-Object System.Drawing.Point(190, 486)
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
$btnStart.BackColor = $cEmerald
$btnStart.ForeColor = [System.Drawing.Color]::White
$btnStart.FlatStyle = "Flat"
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnStart.Visible = $false
$form.Controls.Add($btnStart)
function Log($text) {
$log.AppendText($text + "`r`n")
[System.Windows.Forms.Application]::DoEvents()
}
# --- Installations-Logik ---------------------------------------------------
function Do-Install {
$rHost = $txtHost.Text.Trim()
$wName = $txtName.Text.Trim()
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
# und die Abschlussmeldung denselben Pfad brauchen.
$zielEingabe = $txtPfad.Text.Trim()
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
[System.Windows.Forms.MessageBox]::Show(
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
"Pfad unvollständig")
return
}
$script:InstallDir = $zielEingabe
$btnInstall.Enabled = $false
$log.Clear()
Log "Ziel: $InstallDir"
try {
# 1. Python finden
$python = $null
foreach ($k in @("py -3", "python")) {
try {
$v = Invoke-Expression "$k --version" 2>&1
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
} catch {}
}
if (-not $python) {
Log "FEHLER: Python 3.10+ nicht gefunden."
Log "Installieren mit: winget install Python.Python.3.12"
Log "Danach diesen Installer erneut starten."
$btnInstall.Enabled = $true
return
}
Log "Python gefunden: $python"
# 2. Ordner + Worker-Code
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
try {
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
$probe = Join-Path $InstallDir ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
Log ""
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
Log "Administratorrechte. Zwei Wege:"
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
Log " ausführen' starten (der empfohlene Weg), ODER"
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
$btnInstall.Enabled = $true
return
}
Set-Location $InstallDir
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
} catch {}
Log "Lade Worker-Code von $rHost ..."
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Log "Abhängigkeiten installiert."
# 4. HandBrakeCLI
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
Log "HandBrake bereit."
# 5. Start-Skripte + Deinstaller erzeugen
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
$uninstall = @"
param([switch]`$Force)
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
Add-Type -AssemblyName System.Windows.Forms
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
Set-Location (Split-Path `$PSScriptRoot -Parent)
Remove-Item -Recurse -Force `$PSScriptRoot
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
"@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
# 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
#
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
# Aufgabenplanung an, und das verlangt Administratorrechte. Der
# Installer läuft normal ohne. schtasks schrieb deshalb
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
# GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
# irreführenden Tipp, die IP könne falsch sein.
#
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
# Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
if ($chkAuto.Checked) {
try {
# Liegt das Programm unter "Programme", ist es eine Installation
# für die ganze Maschine - dann gehört der Autostart in den
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
# der Installer läuft dafür ohnehin erhöht, und bei einer
# Erhöhung über ein FREMDES Administratorkonto wäre der
# benutzereigene Ordner der des Admins - also der falsche.
# Bei einer Installation ins eigene Profil bleibt es persönlich.
$programme = [Environment]::GetFolderPath("ProgramFiles")
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
$autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup")
} else {
[Environment]::GetFolderPath("Startup")
}
$lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($lnkPfad)
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
$lnk.WorkingDirectory = $InstallDir
$lnk.Description = "Rippy Encoding-Worker"
$lnk.Save()
if ($maschinenweit) {
Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
} else {
Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
}
} catch {
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
Log " Der Worker ist trotzdem fertig installiert und läuft."
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
Log " (Windows-Taste + R, dann shell:startup eingeben)."
}
}
Log ""
Log "FERTIG! Ordner: $InstallDir"
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
$btnStart.Visible = $true
$btnInstall.Text = "Neu installieren"
$btnInstall.Enabled = $true
} catch {
Log ""
Log "FEHLER: $_"
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
$btnInstall.Enabled = $true
}
}
$btnInstall.Add_Click({ Do-Install })
$btnStart.Add_Click({
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
$form.Close()
})
[void]$form.ShowDialog()
-211
View File
@@ -1,211 +0,0 @@
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
#
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
#
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
#
# Aufruf (PowerShell):
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
#
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
param(
[Parameter(Mandatory = $true)] [string]$RippyHost,
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
[switch]$Autostart,
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
# eigenen Profil angeben.
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
)
$ErrorActionPreference = "Stop"
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
# 1. Python finden (py-Launcher oder python im PATH)
$python = $null
foreach ($kandidat in @("py -3", "python")) {
try {
$version = Invoke-Expression "$kandidat --version" 2>&1
if ("$version" -match "Python 3\.(\d+)") {
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
}
} catch {}
}
if (-not $python) {
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
exit 1
}
Write-Host "Python gefunden: $python"
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
$ziel = $InstallDir
try {
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
$probe = Join-Path $ziel ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Write-Host ""
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
exit 1
}
Write-Host "Ziel: $ziel"
Set-Location $ziel
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) {
Write-Host "Lege Python-Umgebung an ..."
Invoke-Expression "$python -m venv venv"
}
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Write-Host "Python-Abhängigkeiten installiert."
# 4. HandBrakeCLI besorgen (falls nötig)
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
exit 1
}
Write-Host "HandBrakeCLI bereit."
# 5. Start-Skript erzeugen
$bat = @"
@echo off
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName
set PATH=%~dp0;%PATH%
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
"@
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
$trayBat = @"
@echo off
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName
set RIPPY_TRAY_HOST=$RippyHost
set PATH=%~dp0;%PATH%
start "" venv\Scripts\pythonw.exe tray.py
"@
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
Write-Host "start-worker.bat + start-tray.bat erzeugt."
# 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
$uninstall = @"
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
param([switch]`$Force)
if (-not `$Force) {
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
}
Get-CimInstance Win32_Process | Where-Object {
`$_.ExecutablePath -like "`$PSScriptRoot*"
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try {
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
Write-Host "Worker-Eintrag in Rippy entfernt."
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
Set-Location (Split-Path `$PSScriptRoot -Parent)
Remove-Item -Recurse -Force `$PSScriptRoot
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
"@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
Write-Host "uninstall.ps1 erzeugt."
# 8. Optional: Autostart bei Anmeldung (mit Tray)
#
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
if ($Autostart) {
try {
# Unter "Programme" = Installation für die Maschine, also Autostart für
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
$programme = [Environment]::GetFolderPath("ProgramFiles")
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
$autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup")
} else {
[Environment]::GetFolderPath("Startup")
}
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
$lnk.WorkingDirectory = $ziel
$lnk.Description = "Rippy Encoding-Worker"
$lnk.Save()
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
} catch {
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "FERTIG." -ForegroundColor Green
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
+412
View File
@@ -0,0 +1,412 @@
import flet as ft
import os
import subprocess
import threading
import urllib.request
import json
import zipfile
def main(page: ft.Page):
page.title = "Rippy Encoding-Worker Installation"
page.window_width = 800
page.window_height = 650
page.bgcolor = "#0f172a"
page.theme_mode = ft.ThemeMode.DARK
page.scroll = ft.ScrollMode.AUTO
# Colors
c_panel = "#1e293b"
c_text = "#e2e8f0"
c_muted = "#94a3b8"
c_accent = "#6366f1"
c_success = "#10b981"
# State
std_ziel = r"C:\Program Files\Rippy Worker"
# UI Fields
txt_host = ft.TextField(
label="LAN-IP der Rippy-Maschine (Docker-Host)",
width=400,
bgcolor=c_panel,
color=c_text,
)
txt_name = ft.TextField(
label="Name dieses Workers (frei wählbar)",
width=250,
value=os.environ.get("COMPUTERNAME", "worker").lower(),
bgcolor=c_panel,
color=c_text,
)
txt_slots = ft.Dropdown(
label="Kerne (Gleichzeitig)",
width=150,
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)],
value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
bgcolor=c_panel,
color=c_text,
)
txt_pfad = ft.TextField(
label="Installieren nach",
value=std_ziel,
expand=True,
bgcolor=c_panel,
color=c_text,
)
txt_map = ft.TextField(
label="Netzwerk-Freigabe mit den Rohdaten",
expand=True,
bgcolor=c_panel,
color=c_text,
)
chk_auto = ft.Checkbox(
label="Beim Anmelden automatisch starten (Hintergrund)",
value=True,
fill_color=c_accent,
)
chk_desktop = ft.Checkbox(
label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent
)
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def log(msg: str):
log_view.controls.append(
ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
)
try:
page.update()
except Exception:
pass
def check_map(e):
r_host = txt_host.value.strip()
if not r_host:
page.snack_bar = ft.SnackBar(
ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
)
page.snack_bar.open = True
page.update()
return
try:
log(f"Frage Rippy nach Freigabe ({r_host})...")
with urllib.request.urlopen(
f"http://{r_host}/api/worker-setup/pfad-map", timeout=5
) as r:
data = json.loads(r.read().decode())
if data.get("hinweis"):
log(data["hinweis"])
if data.get("mapping"):
txt_map.value = data["mapping"]
log(f"Erfolgreich geholt: {data['mapping']}")
page.update()
except Exception as ex:
log(f"Fehler: {ex}")
def do_install(e):
btn_install.disabled = True
page.update()
threading.Thread(target=run_installation, daemon=True).start()
def run_installation():
r_host = txt_host.value.strip()
w_name = txt_name.value.strip()
install_dir = txt_pfad.value.strip()
if not r_host:
log("FEHLER: Rippy LAN-IP fehlt!")
btn_install.disabled = False
page.update()
return
log(f"Zielverzeichnis: {install_dir}")
# Check Python
python_exe = None
for cmd in ["py -3", "python"]:
try:
res = subprocess.run(
f"{cmd} --version", shell=True, capture_output=True, text=True
)
if "Python 3." in res.stdout:
python_exe = cmd
break
except Exception:
pass
if not python_exe:
log("FEHLER: Python 3.10+ nicht gefunden.")
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
btn_install.disabled = False
page.update()
return
log(f"Python gefunden: {python_exe}")
try:
os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test")
with open(test_file, "w") as f:
f.write("x")
os.remove(test_file)
except Exception:
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.")
btn_install.disabled = False
page.update()
return
# Download worker package
log(f"Lade Worker-Code von {r_host}...")
try:
zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(
f"http://{r_host}/api/worker-setup/paket", zip_path
)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(install_dir)
os.remove(zip_path)
except Exception as ex:
log(f"FEHLER beim Download: {ex}")
btn_install.disabled = False
page.update()
return
# Setup venv — --clear loescht einen alten venv komplett, damit keine
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...")
subprocess.run(
f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
)
log("Installiere Abhaengigkeiten (pip)...")
subprocess.run(
r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
cwd=install_dir,
shell=True,
)
subprocess.run(
r"venv\Scripts\pip.exe install --quiet -r requirements.txt",
cwd=install_dir,
shell=True,
)
subprocess.run(
r"venv\Scripts\pip.exe install --quiet requests pillow",
cwd=install_dir,
shell=True,
)
# Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
if not os.path.exists(hb_path):
log("Lade HandBrakeCLI herunter...")
try:
hb_ver = "1.7.3" # Fallback if github fails
try:
with urllib.request.urlopen(
"https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
timeout=5,
) as r:
hb_data = json.loads(r.read().decode())
if hb_data.get("tag_name"):
hb_ver = hb_data["tag_name"].lstrip("v")
except Exception:
pass
hb_zip = os.path.join(install_dir, "hb.zip")
urllib.request.urlretrieve(
f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip",
hb_zip,
)
with zipfile.ZipFile(hb_zip, "r") as zip_ref:
zip_ref.extractall(install_dir)
os.remove(hb_zip)
except Exception as ex:
log(f"Warnung bei HandBrake-Download: {ex}")
log("Erstelle Start-Skripte...")
slots = txt_slots.value
pool_arg = (
"--pool=solo"
if int(slots) <= 1
else f"--pool=threads --concurrency={slots}"
)
map_zeile = (
f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n"
if txt_map.value.strip()
else ""
)
gui_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart "" venv\\Scripts\\pythonw.exe gui.py'
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f:
f.write(gui_bat)
work_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h'
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
f.write(work_bat)
# Uninstaller
uninstall_ps1 = f"""param([switch]$Force)
Add-Type -AssemblyName System.Windows.Forms
if (-not $Force) {{
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
if ($antwort -ne "Yes") {{ exit }}
}}
Get-WmiObject Win32_Process | Where-Object {{
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
Start-Sleep 3
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
schtasks /delete /tn "RippyWorker" /f *>$null
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
"""
with open(
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
) as f:
f.write(uninstall_ps1)
# Autostart
if chk_auto.value:
log("Richte Autostart (Task Scheduler) ein...")
bat_path = os.path.join(install_dir, "start-gui.bat")
subprocess.run(
f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f',
shell=True,
capture_output=True,
)
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im
# PyInstaller-Bundle nicht enthalten ist.
if chk_desktop.value:
try:
public_desktop = os.path.join(
os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"
)
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop")
desktop = (
public_desktop
if "Program Files" in install_dir and os.path.isdir(public_desktop)
else user_desktop
)
lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
bat_target = os.path.join(install_dir, "start-gui.bat")
ico_path = os.path.join(install_dir, "rippy.ico")
ps_cmd = (
f"$ws = New-Object -ComObject WScript.Shell; "
f'$sc = $ws.CreateShortcut("{lnk_path}"); '
f'$sc.TargetPath = "{bat_target}"; '
f'$sc.WorkingDirectory = "{install_dir}"; '
f'$sc.IconLocation = "{ico_path}"; '
f"$sc.WindowStyle = 7; "
f"$sc.Save()"
)
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True,
)
log("Desktop-Verknüpfung erstellt.")
except Exception as e:
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
log("FERTIG!")
btn_start.visible = True
btn_install.text = "Neu installieren"
btn_install.disabled = False
page.update()
def start_worker(e):
install_dir = txt_pfad.value.strip()
bat_path = os.path.join(install_dir, "start-gui.bat")
# os.startfile startet die .bat ueber die Shell-Verknuepfung —
# CREATE_NO_WINDOW wuerde sie stumm schlucken.
os.startfile(bat_path)
page.window_close()
btn_install = ft.ElevatedButton(
"Installieren",
on_click=do_install,
bgcolor=c_accent,
color=ft.colors.WHITE,
width=200,
height=40,
)
btn_start = ft.ElevatedButton(
"Worker starten",
on_click=start_worker,
bgcolor=c_success,
color=ft.colors.WHITE,
width=200,
height=40,
visible=False,
)
def get_dir(e: ft.FilePickerResultEvent):
if e.path:
txt_pfad.value = e.path
page.update()
dir_picker = ft.FilePicker(on_result=get_dir)
page.overlay.append(dir_picker)
page.add(
ft.Row(
[
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent),
ft.Text(
"Rippy Encoding-Worker",
size=28,
weight=ft.FontWeight.BOLD,
color=c_text,
),
]
),
ft.Text(
"Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.",
color=c_muted,
),
ft.Divider(color="#334155"),
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START),
ft.Row(
[
txt_pfad,
ft.ElevatedButton(
"Durchsuchen ...",
on_click=lambda _: dir_picker.get_directory_path(
"Installationsordner wählen"
),
bgcolor=c_panel,
color=c_text,
),
],
alignment=ft.MainAxisAlignment.START,
),
ft.Row(
[
txt_map,
ft.ElevatedButton(
"Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text
),
],
alignment=ft.MainAxisAlignment.START,
),
chk_auto,
chk_desktop,
ft.Container(
content=log_view,
bgcolor="#020617",
padding=10,
border_radius=5,
border=ft.border.all(1, "#334155"),
),
ft.Row([btn_install, btn_start]),
)
if __name__ == "__main__":
ft.app(target=main)
+2
View File
@@ -21,6 +21,7 @@ services:
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
- MAKEMKV_DATA_DIR=/app/makemkv-data
- LOG_LEVEL=INFO
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
interval: 30s
@@ -102,6 +103,7 @@ services:
# Episoden-Laufzeiten der Staffel (TMDB).
- API_URL=http://api:8000
- LOG_LEVEL=INFO
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
volumes:
- type: bind
source: /srv/rippy/media
+6 -1
View File
@@ -21,7 +21,12 @@ COPY docker/api/ .
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
COPY deploy/worker-windows/installer.py worker_dist/
# 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
# Verknüpfung das Standard-Batch-Symbol — und niemand fände sie zwischen den
# anderen Icons wieder.
COPY deploy/worker-windows/rippy.ico worker_dist/
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
+12
View File
@@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float:
class JikanClient:
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
MINDEST_AEHNLICHKEIT = 0.55
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
#
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
SICHER_AEHNLICHKEIT = 0.9
def __init__(self):
self.session = requests.Session()
+18
View File
@@ -210,6 +210,24 @@ def delete_worker(name: str) -> None:
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:
+35 -1
View File
@@ -20,13 +20,47 @@ from detection import (
# 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 (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
"""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)
+136
View File
@@ -0,0 +1,136 @@
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
**ETA** im Dashboard und beim externen Worker.
## Warum hier und nicht im Browser
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
gehört also dorthin, wo der Fortschritt ankommt.
## Warum nicht einfach „vergangene Zeit / Prozent"
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
## Ehrlichkeit vor Zahl
Lieber „wird noch geschätzt" als eine erfundene Minute:
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
langsamer, die frühen Werte lügen dann.
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
nicht.
"""
MINDEST_PUNKTE = 2
MINDEST_SPANNE_SEKUNDEN = 60
MINDEST_FORTSCHRITT = 1
FENSTER = 10
OBERGRENZE_SEKUNDEN = 48 * 3600
# Status, für die eine Restzeit überhaupt Sinn hat.
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
Kompression haben nichts miteinander zu tun).
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
"""
alt = reihe or {}
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
if not punkte or punkte[-1][1] != progress:
punkte.append([jetzt, progress])
return {"status": status, "punkte": punkte[-FENSTER:]}
def restzeit_sekunden(reihe, jetzt: float) -> int:
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
-1 statt None, damit der Wert unverändert durch JSON und das
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
Worker, wo -1 „keine Angabe" heißt).
"""
punkte = (reihe or {}).get("punkte") or []
if len(punkte) < MINDEST_PUNKTE:
return -1
erste_zeit, erster_prozent = punkte[0]
letzte_zeit, letzter_prozent = punkte[-1]
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
# stehender Job dauerhaft die Rate von vor drei Stunden.
spanne = max(jetzt, letzte_zeit) - erste_zeit
gewachsen = letzter_prozent - erster_prozent
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
return -1
rest_prozent = 100 - letzter_prozent
if rest_prozent <= 0:
return 0
pro_prozent = spanne / gewachsen
return int(rest_prozent * pro_prozent)
def formatiere_restzeit(sekunden: int) -> str:
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
if sekunden is None or sekunden < 0:
return ""
if sekunden > OBERGRENZE_SEKUNDEN:
return "mehr als 2 Tage"
if sekunden < 60:
return "unter einer Minute"
minuten = sekunden // 60
if minuten < 60:
return f"noch ca. {minuten} min"
stunden, rest_minuten = divmod(minuten, 60)
if stunden < 24:
return f"noch ca. {stunden} h {rest_minuten:02d} min"
tage, rest_stunden = divmod(stunden, 24)
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
def schluessel(job_id: str) -> str:
return f"eta:{job_id}"
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
jetzt: float, cache_get, cache_set) -> dict:
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
keine ETA, aber nichts scheitert.
"""
if status not in LAUFENDE_STATUS or progress <= 0:
return {"sekunden": -1, "text": ""}
k = schluessel(job_id)
try:
reihe = cache_get(k)
except Exception:
reihe = None
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
try:
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
cache_set(k, reihe, expire=86400)
except Exception:
pass
sekunden = restzeit_sekunden(reihe, jetzt)
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
+594 -22
View File
@@ -6,22 +6,33 @@ from typing import List, Optional, Dict
import asyncio
import json
import os
import posixpath
import shutil
import time
import uuid
import db
import devices as device_discovery
import eta
import makemkv_daten
import makemkv_key
import mounts as mount_verwaltung
import notify
import phasen
import presets as preset_auswahl
import rohdaten
from celery_client import celery_client, start_rip
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
from config_validation import validate_config, ConfigValidationError
from cache import init_cache
from ratelimit import check_rate_limit, get_rate_limit_remaining
from cache import get as cache_get, init_cache, set as cache_set
from ratelimit import (
MAX_REQUESTS_PER_MINUTE,
check_rate_limit,
client_kennung,
darf_melden,
get_rate_limit_remaining,
)
from prescan import PreScan
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
@@ -65,6 +76,13 @@ async def startup_event():
# Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst
# kostet JEDER Aufruf von /capabilities eine ganze Sekunde.
asyncio.create_task(_ping_schleife())
# Aus demselben Grund im Hintergrund: nachsehen, wo Rohdaten liegen. Ein
# schlafendes NAS lässt os.path.isdir bis zum CIFS-Timeout hängen (hier
# 10 s gemessen) — und /jobs wird alle 4 Sekunden abgefragt.
asyncio.create_task(_rohdaten_schleife())
# Freigaben bewachen: Nach einem Rebuild ist die CIFS-Freigabe reproduzierbar
# tot, und der zweite Anlauf hilft (Begründung bei _mount_schleife).
asyncio.create_task(_mount_schleife())
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
@@ -214,10 +232,29 @@ async def disc_watcher():
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
"""Rate-Limiting Middleware."""
client_ip = request.client.host
# Nicht `request.client.host` allein: Der Browser spricht über den nginx im
# UI-Container, dessen Adresse sonst für ALLE Clients gälte (Herleitung in
# ratelimit.client_kennung).
client_ip = client_kennung(
request.client.host if request.client else "",
request.headers.get("x-real-ip") or request.headers.get("x-forwarded-for"),
)
# Rate Limit prüfen
if not check_rate_limit(client_ip):
# Ein 429 war lange unsichtbar: Das UI verbuchte ihn als „nichts da".
# Jetzt steht er im Log, damit die Bremse nicht wieder heimlich greift —
# gedrosselt, damit ein Amok-Skript nicht das Log-Fenster flutet.
try:
if darf_melden(client_ip):
db.add_log(
"warning", "api",
f"Rate-Limit erreicht für {client_ip} — Anfragen werden "
f"abgewiesen (Grenze: {MAX_REQUESTS_PER_MINUTE}/min). Läuft "
"dort ein Skript in einer Schleife?",
)
except Exception:
pass
return Response(
content=json.dumps({"error": "Rate limit exceeded"}),
status_code=429,
@@ -252,7 +289,15 @@ class Job(BaseModel):
title: Optional[str] = None
error: Optional[str] = None
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
# Welche Wiederholung passt zu DIESEM Fehlschlag? "transcode" | "rip" |
# "unklar" | "" — Herleitung und Begründung in phasen.py. Ohne dieses Feld
# hieß der Knopf nur „Neu" und komprimierte immer, auch ein Rip-Bruchstück.
retry_art: str = ""
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
# Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" —
# bewusst ehrlich statt einer erfundenen Minutenzahl.
eta_sekunden: int = -1
eta_text: str = ""
class Device(BaseModel):
id: str
@@ -293,6 +338,65 @@ async def health_check():
return {"status": "ok", "service": "api"}
@app.get("/health/arbeit")
async def health_arbeit():
"""Läuft gerade ein Job? — die Frage VOR einem Deploy.
⚠️ Warum es das gibt (26.07.2026, selbst verursacht): Ein
`docker compose up -d --build` baut den worker-Container neu und tötet damit
einen laufenden Rip. Genau das ist passiert — mitten in einem Blu-ray-Rip,
bei 12 %, nach 5,1 GB. Im SAVEPOINT stand die Warnung „nicht deployen,
während ein Rip läuft" schon; sie half nichts, weil niemand sie las und
nichts sie prüfte.
Jetzt fragt `deploy.sh` hier nach und bricht ab. Ein Satz Code gegen eine
verlorene Stunde.
"""
def sammle():
laufend = [
{"id": z["id"], "status": z["status"], "progress": z.get("progress") or 0,
"titel": z.get("title") or ""}
for z in db.list_jobs()
if z.get("status") in ("pending", "running", "ripping",
"transcoding", "canceling")
]
return {"arbeit": bool(laufend), "jobs": laufend}
return await asyncio.to_thread(sammle)
@app.get("/health/vorraete")
async def health_vorraete():
"""Laufen die Hintergrund-Schleifen wirklich? (Diagnose, kein UI-Endpunkt)
Zwei Endpunkte hängen an einem Vorrat, den eine Hintergrund-Schleife füllt:
/capabilities am Celery-Ping, /jobs an der Rohdaten-Suche. Bleibt so ein
Vorrat leer, ist am Endpunkt selbst NICHTS zu sehen — er antwortet nur
dauerhaft „nichts gefunden". Genau daran ging am 26.07.2026 eine Stunde
verloren. `alter_sekunden` sagt, wann die Schleife das letzte Mal
durchgelaufen ist; wächst der Wert über das Intervall, läuft sie nicht mehr.
"""
jetzt = time.monotonic()
return {
"ping": {
"knoten": _PING["knoten"],
"alter_sekunden": None if _PING["stand"] < 0 else round(jetzt - _PING["stand"], 1),
"intervall": PING_INTERVALL_SEKUNDEN,
},
"rohdaten": {
"jobs_im_vorrat": len(_ROHDATEN["treffer"]),
"mit_treffer": sum(1 for v in _ROHDATEN["treffer"].values() if v),
"alter_sekunden": None if _ROHDATEN["stand"] is None
else round(jetzt - _ROHDATEN["stand"], 1),
"intervall": ROHDATEN_INTERVALL_SEKUNDEN,
},
"mounts": {
"stand": _MOUNT_STAND,
"intervall": MOUNT_WACHE_INTERVALL_SEKUNDEN,
},
}
@app.get("/")
async def root():
return {
@@ -302,42 +406,294 @@ async def root():
}
def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)
Geprüft wird mit `rohdaten.verzeichnis_da` und NICHT mit os.path.isdir:
Letzteres hing am 26.07.2026 unbegrenzt im Kernel, während Rippy die
CIFS-Freigabe neu einhängte — und nahm die Hintergrund-Schleife dauerhaft
mit. Ausführliche Begründung dort.
Bleibt trotzdem der langsame Weg (mehrere Kind-Prozesse, im schlechtesten
Fall je vier Sekunden). Für die Job-Liste, die das Dashboard alle vier
Sekunden abfragt, gibt es deshalb den Vorrat unten.
"""
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
# Dashboard sonst für 10 Sekunden je Aufruf eingefroren.
_ROHDATEN = {"treffer": {}, "stand": None}
ROHDATEN_INTERVALL_SEKUNDEN = 30
# --- Mount-Wache ------------------------------------------------------------
#
# ⚠️ Warum es die braucht (26.07.2026, DREIMAL in Folge reproduziert): Nach
# `docker compose up -d --build` ist die CIFS-Freigabe tot. `mount` meldet
# Rückgabewert 0, /proc/mounts zeigt genau eine korrekt aussehende Schicht, die
# Erreichbarkeits-Probe antwortet direkt nach dem Mount sogar — und Sekunden
# später läuft jeder Zugriff in die Zeitgrenze. Derselbe Ablauf ein zweites Mal,
# ein bis zwei Minuten später, stellt sie zuverlässig her.
#
# ## DIE URSACHE, gemessen am 26.07.2026
#
# /proc/fs/cifs/DebugData → Net namespace: 4026532653
# api-Container → net:[4026532653] ← dieselbe
# worker-Container → net:[4026532540] ← andere
#
# Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS — hier wird
# sie eingehängt (nur dieser Container hat CAP_SYS_ADMIN). Wird der Container neu
# gebaut, stirbt sein Netz-Namespace und damit der Socket. Der Mount steht danach
# weiter in /proc/mounts (er ist per rshared auf den Host propagiert) und sieht
# vollkommen gesund aus — aber jeder Zugriff läuft in den CIFS-Timeout.
#
# Deshalb passiert es nach JEDEM Deploy, deshalb sieht `mount` gesund aus, und
# deshalb hilft nur ein echtes Neu-Verbinden aus dem neuen Container heraus.
#
# Und deshalb ist der api-Container die einzige Stelle, die die NAS-Verbindung
# hält: Startet er mitten in einem Rip neu, verliert auch der Worker sein Ziel.
# Das strukturell zu lösen (Mount auf dem HOST statt im Container) wäre ein
# eigener Umbau und widerspräche „Speicherziele über das UI einhängen".
MOUNT_WACHE_INTERVALL_SEKUNDEN = 60
# Erste Prüfung fast sofort: Genau nach einem Deploy ist die Lage kaputt, und
# jede Sekunde Wartezeit ist eine Sekunde, in der Rippy sein Ziel nicht sieht.
MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 3
_MOUNT_STAND = {}
async def _mount_schleife():
"""Sieht nach, ob die Freigaben antworten, und verbindet sie sonst neu."""
await asyncio.sleep(MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN)
while True:
try:
await asyncio.to_thread(_mounts_nachsehen)
except Exception as e: # darf nie sterben
print(f"Mount-Wache fehlgeschlagen: {type(e).__name__}: {e}")
await asyncio.sleep(MOUNT_WACHE_INTERVALL_SEKUNDEN)
def _mounts_nachsehen() -> None:
"""Unerreichbare Freigaben neu verbinden — NIE während ein Job läuft.
Neu verbinden heißt `umount -l`; mitten in einem Rip oder Encode wäre das
ein Datenverlust. Deshalb steht die Wache still, solange irgendein Job nicht
durch ist — auch bei einem wartenden, der jeden Moment anlaufen kann.
"""
eintraege = db.list_mounts()
if not eintraege or db.hat_arbeit():
return
for eintrag in eintraege:
name = eintrag["name"]
# Zum ERKENNEN genügt die einfache, schnelle Probe: Ein toter Mount
# antwortet gar nicht, nicht nur manchmal. Die Doppelprobe steckt dort,
# wo sie hingehört — in `mounten()`, direkt nach einem frischen Mount, wo
# der Wettlauf mit dem lazy umount lauert. Hier kostete sie nur jede
# Minute drei Sekunden Warten für nichts.
erreichbar = mount_verwaltung.ist_erreichbar(name)
vorher = _MOUNT_STAND.get(name)
_MOUNT_STAND[name] = erreichbar
if erreichbar:
if vorher is False:
db.add_log("success", "mounts", f"{name}: antwortet wieder")
continue
# Nur beim ÜBERGANG meckern, nicht jede Minute: Ist das NAS
# ausgeschaltet, wäre das sonst ein Log-Wasserfall.
if vorher is not False:
db.add_log("warning", "mounts",
f"{name}: antwortet nicht — wird neu verbunden")
begonnen = time.monotonic()
try:
mount_verwaltung.reparieren(
name, eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
_MOUNT_STAND[name] = True
# Die DAUER mit ins Log: Sie war die entscheidende Spur, als eine
# Wiederanbindung drei Minuten brauchte (ein `stat` auf dem toten
# Mount). Steht sie da, muss man sie nicht erst rekonstruieren.
db.add_log("success", "mounts",
f"{name}: neu verbunden ({time.monotonic() - begonnen:.1f}s)")
except Exception as e:
db.add_log(
"warning", "mounts",
f"{name}: Neuverbinden fehlgeschlagen nach "
f"{time.monotonic() - begonnen:.1f}s — {e}")
async def _rohdaten_schleife():
"""Hält den Rohdaten-Vorrat frisch. Darf nie sterben.
Der Fehler wird GEMELDET, nicht verschluckt: Ein `except Exception: pass`
stand hier zuerst, und als der Vorrat leer blieb, war nicht zu sehen, warum
(Sitzung 26.07.2026 — eine Stunde Rätselraten für eine Zeile Log).
"""
while True:
try:
await asyncio.to_thread(_rohdaten_vorrat_auffrischen)
except Exception as e: # DB/NAS weg → beim nächsten Durchlauf erneut
print(f"Rohdaten-Vorrat fehlgeschlagen: {type(e).__name__}: {e}")
await asyncio.sleep(ROHDATEN_INTERVALL_SEKUNDEN)
def _rohdaten_vorrat_auffrischen() -> None:
"""Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen.
„Konnte nicht nachsehen" behält die letzte bekannte Antwort: Nach einem
Container-Neustart stallt der erste Zugriff auf die CIFS-Freigabe mehrere
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
"""
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
alt = _ROHDATEN["treffer"]
treffer = {}
for zeile in db.list_jobs():
if zeile.get("status") != "failed":
continue
job_id = zeile["id"]
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
else:
treffer[job_id] = ergebnis["pfade"]
_ROHDATEN["treffer"] = treffer
_ROHDATEN["stand"] = time.monotonic()
def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
"""Nur wenn Rohdaten wirklich noch daliegen — der „Neu komprimieren"-Knopf
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.)."""
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.).
⚠️ Reparatur 26.07.2026, an der laufenden Instanz gemessen: Gesucht wurde
nur im Container-Standard und unter dem AKTUELLEN `workDir`. Der Rip von Job
95afdc89 lag aber auf der NAS, weil beim Start eine Wahl NUR FÜR DIESEN RIP
getroffen worden war (gibt es seit v3.15) — und die Einstellung selbst stand
auf leer. Ergebnis: `can_retry` war `false`, obwohl 79,6 GB intakt dalagen.
Der SAVEPOINT v3.16 behauptete „‚Neu komprimieren' genügt" — den Knopf gab
es nicht. Jetzt wird an allen möglichen Orten nachgesehen.
Gelesen wird aus dem Vorrat, nicht live: Diese Funktion hängt an /jobs, und
das fragt das Dashboard alle 4 Sekunden. Solange der Vorrat einen Job noch
nicht kennt (frischer Fehlschlag), zählt der billige lokale Ort — der liegt
auf der Container-Platte und antwortet immer sofort.
"""
if job.get("status") != "failed":
return False
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
return True
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
vorrat = _ROHDATEN["treffer"]
if job["id"] in vorrat:
return bool(vorrat[job["id"]])
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
@app.get("/jobs", response_model=List[Job])
async def get_jobs():
"""Holt alle Jobs aus der Datenbank (neueste zuerst)."""
"""Holt alle Jobs aus der Datenbank (neueste zuerst) — inkl. Restzeit.
Die ETA entsteht HIER, weil hier der Fortschritt vorbeikommt: jeder Aufruf
schreibt die Messreihe im Cache fort (eta.py). Im Browser zu rechnen wäre
einfacher gewesen und dreifach schlechter — ein Seitenwechsel setzte die
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
"""
def sammle():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
jetzt = time.monotonic()
modelle = []
for z in db.list_jobs():
modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modell.retry_art = phasen.retry_art(z)
schaetzung = eta.aktualisiere_und_schaetze(
z["id"], z.get("status") or "", z.get("progress") or 0,
jetzt, cache_get, cache_set,
)
modell.eta_sekunden = schaetzung["sekunden"]
modell.eta_text = schaetzung["text"]
modelle.append(modell)
return modelle
return await asyncio.to_thread(sammle)
def _rohdaten_groesse(pfade: list) -> tuple:
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
Hier bleibt os.* stehen: Diese Funktion wird nur aufgerufen, NACHDEM
`verzeichnis_da` den Pfad innerhalb von Sekunden bestätigt hat — der Mount
antwortet also. Und sie läuft nur, wenn ein Mensch auf die Zahl wartet
(/rohdaten, Löschen), nie in der Hintergrund-Schleife.
"""
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
@app.get("/jobs/{job_id}/rohdaten")
async def job_rohdaten(job_id: str):
"""Was bleibt liegen, wenn dieser Job aus der Liste fliegt?
⚠️ Der Grund für diesen Endpunkt (Vorfall 25.07.2026, v3.14): „Job aus der
Liste entfernen" löscht bewusst keine Dateien — richtig, aber der Rohschnitt
ist danach UNERREICHBAR, weil Job und Dateien nur über die Job-ID verbunden
sind. Ein 75-GB-Rohschnitt verwaiste so unsichtbar auf der Platte: kein
Eintrag zeigte mehr darauf, „Neu komprimieren" war unmöglich, und im UI war
nichts davon zu sehen. Erst ein Blick per SSH brachte es zutage.
Jetzt fragt das UI vor dem Entfernen hier nach und sagt die Zahl.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
def sammle():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
pfade = _rohdaten_suchen(job_id, work_dir)
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
return {
"pfade": pfade,
"dateien": dateien,
"bytes": bytes_gesamt,
"gb": round(bytes_gesamt / 1024**3, 1),
}
return await asyncio.to_thread(sammle)
@app.delete("/jobs/{job_id}")
async def delete_job(job_id: str):
"""Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen)."""
async def delete_job(job_id: str, rohdaten: bool = False):
"""Entfernt einen erledigten Job aus der Liste.
`rohdaten=true` löscht zusätzlich die Roh-MKVs. Ohne den Schalter bleiben
sie liegen (Standard wie bisher) — das UI nennt vorher die Größe, damit die
Entscheidung bewusst fällt und kein Rohschnitt unsichtbar verwaist.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] not in ("completed", "failed"):
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
geloescht_gb = 0.0
if rohdaten:
def raeume():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
pfade = _rohdaten_suchen(job_id, work_dir)
bytes_gesamt, _ = _rohdaten_groesse(pfade)
for pfad in pfade:
shutil.rmtree(pfad, ignore_errors=True)
return round(bytes_gesamt / 1024**3, 1)
geloescht_gb = await asyncio.to_thread(raeume)
await asyncio.to_thread(db.delete_job, job_id)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt")
return {"status": "deleted"}
vermerk = f" (inkl. {geloescht_gb} GB Rohdaten gelöscht)" if rohdaten else ""
await asyncio.to_thread(
db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt{vermerk}"
)
return {"status": "deleted", "rohdaten_geloescht_gb": geloescht_gb}
@app.delete("/jobs")
@@ -360,6 +716,12 @@ class JobCreateRequest(BaseModel):
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
# Sprachauswahl für DIESEN Rip (ISO-639-2, z. B. ["deu","eng"]).
# Leer = alles behalten. Greift bei der Kompression, nicht beim Rippen —
# der Rip bleibt vollständig und verlustfrei (Begründung in
# worker/ripping.build_handbrake_cmd).
audio_sprachen: Optional[List[str]] = None
untertitel_sprachen: Optional[List[str]] = None
# Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026:
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
@@ -415,6 +777,18 @@ async def create_job(request: JobCreateRequest):
if request.series and request.series.strip():
meta_dict["series"] = request.series.strip()
meta_dict["season"] = max(1, int(request.season or 1))
# TMDB-Laufzeiten der Episoden holen (für die Erkennung im Worker)
if meta_dict.get("id") and meta_dict.get("type") == "tv":
try:
from clients.tmdb import TMDBClient
staffel = TMDBClient().get_tv_season(meta_dict["id"], meta_dict["season"])
if staffel and "episodes" in staffel:
meta_dict["episode_runtimes"] = [
ep.get("runtime") for ep in staffel["episodes"] if ep.get("runtime")
]
except Exception as e:
print(f"Fehler beim Holen der Staffel-Laufzeiten: {e}")
if request.main_feature_only is not None:
meta_dict["main_feature_only"] = request.main_feature_only
if request.titles:
@@ -423,6 +797,14 @@ async def create_job(request: JobCreateRequest):
meta_dict["titles"] = titel_liste
if request.transcode_node:
meta_dict["transcode_node"] = request.transcode_node
# Sprachauswahl dieses Rips. Nur schreiben, wenn wirklich gewählt wurde —
# eine leere Liste würde im Worker als „alles" gelesen, was derselbe Fall
# ist, aber die Absicht verschleiert.
for feld, wert in (("audio_sprachen", request.audio_sprachen),
("untertitel_sprachen", request.untertitel_sprachen)):
sauber = [str(s).strip().lower() for s in (wert or []) if str(s).strip()]
if sauber:
meta_dict[feld] = sauber
# Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
# irgendwohin in den Container schreibt.
@@ -451,6 +833,7 @@ async def get_job_detail(job_id: str):
detail = _job_row_to_model(job).dict()
detail["target_dir"] = job.get("target_dir")
detail["output_path"] = job.get("output_path")
detail["retry_art"] = phasen.retry_art(job)
try:
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
except ValueError:
@@ -635,8 +1018,15 @@ async def scan_tracks_ergebnis(name: str):
async def retry_transcode(job_id: str):
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
Voraussetzung: die Rohdateien liegen noch in /app/temp/raw/<job_id>
(bei Kompressions-Fehlschlägen bleiben sie dort absichtlich erhalten).
Voraussetzung: die Rohdateien liegen noch irgendwo (bei
Kompressions-Fehlschlägen bleiben sie absichtlich erhalten).
⚠️ Reparatur 26.07.2026: Das Roh-Verzeichnis wurde hier aus dem AKTUELLEN
Wert von `workDir` errechnet. Wer beim Rip-Start eine andere Ablage gewählt
hatte (gibt es seit v3.15), bekam damit einen Pfad, an dem nichts liegt —
und der Worker brach mit „Verzeichnis erreichbar, enthält aber keine
MKV-Datei" ab. Beim Job 95afdc89 lagen 79,6 GB auf der NAS, gesucht wurde in
/app/temp/raw. Jetzt wird nachgesehen statt gerechnet (rohdaten.py).
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
@@ -644,12 +1034,19 @@ async def retry_transcode(job_id: str):
if job["status"] in ("running", "pending"):
raise HTTPException(status_code=409, detail="Job rippt noch")
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
raw_dir = f"{raw_basis}/{job_id}"
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
if not gefunden:
raise HTTPException(
status_code=409,
detail=(
"Keine Rohdaten zu diesem Job gefunden — weder unter "
"/app/temp/raw noch in einem der Ablageziele. Ohne sie muss die "
"Disc neu gerippt werden. (Gelöscht? Freigabe nicht eingehängt?)"
),
)
raw_dir = gefunden[0]
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
@@ -671,6 +1068,86 @@ async def retry_transcode(job_id: str):
return {"id": job_id, "status": "transcoding"}
@app.post("/jobs/{job_id}/retry-rip", status_code=201)
async def retry_rip(job_id: str):
"""Rippt die Disc dieses Jobs NOCH EINMAL — als frischer Job.
Der Gegenpart zu `retry-transcode`. Nötig, weil es für einen mitten im Rip
gestorbenen Job vorher überhaupt keinen richtigen Knopf gab: Das UI bot nur
„Neu" an, und das war immer die Kompression. Am 26.07.2026 hätte das aus
5,1 GB Bruchstück brav einen Film gemacht, der bei 12 % aufhört.
Bewusst ein NEUER Job mit neuer ID, nicht ein Wiederbeleben des alten:
* Das Roh-Verzeichnis heißt <Arbeitsverzeichnis>/<job_id>. Bei gleicher ID
läge das alte Bruchstück im neuen Verzeichnis, und die Kompression
sammelt am Ende ALLE MKV-Dateien darin ein — sie würde das Bruchstück
mitverarbeiten.
* Der Fehlschlag bleibt in der Liste nachlesbar, statt überschrieben zu
werden.
Übernommen werden Titel, Ablageziel und alle Metadaten des alten Jobs
(Jahr/Poster, Titel-Auswahl, Sprachwunsch, Arbeitsverzeichnis, gewählter
Encoder-Worker) — der Nutzer soll seine Wahl nicht neu treffen müssen. Die
Phasen-Marke wird NICHT übernommen; die setzt der Rip selbst.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] in ("running", "pending", "transcoding", "canceling"):
raise HTTPException(status_code=409, detail="Dieser Job läuft noch")
device_path = job.get("device")
vorhandene = device_discovery.list_optical_devices()
if not device_path or device_path not in vorhandene:
raise HTTPException(
status_code=409,
detail=(
f"Das Laufwerk dieses Jobs ({device_path or 'unbekannt'}) ist nicht "
'mehr da. Disc in ein vorhandenes Laufwerk legen und den Rip über '
'„Rip starten" neu anstoßen.'
),
)
try:
status = await asyncio.to_thread(drive_status, device_path)
except OSError as e:
raise HTTPException(status_code=409, detail=f"Laufwerk {device_path} antwortet nicht: {e}")
if status != CDS_DISC_OK:
raise HTTPException(
status_code=409,
detail=(
f'Es liegt keine (lesbare) Disc in {device_path}. Ein neuer Rip '
'braucht die Disc — sie wurde nach dem Fehlschlag vermutlich '
'ausgeworfen. Disc einlegen, dann noch einmal.'
),
)
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
if not isinstance(meta, dict):
meta = {}
meta.pop(phasen.RIP_FERTIG, None)
meta_json = json.dumps(meta) if meta else None
neue_id = str(uuid.uuid4())
ziel = job.get("target_dir")
await asyncio.to_thread(
db.insert_job, neue_id, device_path, job.get("disc_type"),
job.get("title"), ziel, meta_json,
)
await asyncio.to_thread(
db.add_log, "info", "api",
f"Job {neue_id} ist der Neu-Rip von {job_id} "
f"({job.get('title') or 'ohne Titel'}) auf {device_path}. "
"Das unvollständige Rohmaterial des alten Jobs bleibt liegen — es kann "
"über den Papierkorb am alten Job mitgelöscht werden.",
)
start_rip(device_path, neue_id, ziel)
return {"id": neue_id, "status": "pending", "device": device_path, "vorher": job_id}
@app.post("/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
@@ -765,7 +1242,28 @@ async def capabilities():
zeile["node"] = node
return zeilen
return {"workers": await asyncio.to_thread(sammle)}
return {
"workers": await asyncio.to_thread(sammle),
"server_version": os.getenv("RIPPY_VERSION", "dev")
}
@app.get("/presets")
async def preset_uebersicht():
"""Welche HandBrake-Presets es WIRKLICH gibt — und welches das beste ist.
Die Namen kommen von den Workern selbst (`HandBrakeCLI --preset-list`, siehe
worker/caps.py), die Staffelung aus presets.py. Damit endet das Raten:
vorher standen die Namen fest verdrahtet im UI, und ein Name, den das
jeweilige HandBrake nicht kennt, ließ die Kompression scheitern.
Commander-Anforderung 26.07.2026: „Bei den Presets soll IMMER das Beste
ausgewählt werden" — die Begründung steht bei jeder Empfehlung mit dabei.
"""
def sammle():
return preset_auswahl.uebersicht(db.list_workers())
return await asyncio.to_thread(sammle)
@app.delete("/workers/{name}")
@@ -808,8 +1306,8 @@ class MediaServerRefreshRequest(BaseModel):
@app.post("/mediaserver/refresh")
async def mediaserver_refresh(request: MediaServerRefreshRequest):
"""Bibliotheks-Scan von Jellyfin/Emby anstoßen — auch als Verbindungs-Test
aus den Einstellungen (POST /Library/Refresh, Header X-Emby-Token)."""
"""Bibliotheks-Scan von Jellyfin/Emby/Kodi anstoßen — auch als Verbindungs-Test
aus den Einstellungen."""
import requests as _requests
url = request.url.strip().rstrip("/")
@@ -817,6 +1315,24 @@ async def mediaserver_refresh(request: MediaServerRefreshRequest):
raise HTTPException(status_code=422, detail="Server-URL muss mit http(s):// beginnen")
def anstossen():
# Fallback für die reine API-Test-Funktion, die den Server-Typ nicht kennt:
# Wir versuchen Kodi (JSON-RPC) und danach Emby/Jellyfin.
# Wenn der User Kodi auswählt, hat er oft Basic Auth konfiguriert.
auth = None
if ":" in request.api_key:
user, pw = request.api_key.split(":", 1)
auth = (user, pw)
try:
# Kodi JSON-RPC
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
res = _requests.post(url + "/jsonrpc", json=payload, auth=auth, timeout=5)
if res.status_code < 300:
return res
except Exception:
pass
# Emby / Jellyfin
return _requests.post(
url + "/Library/Refresh",
headers={"X-Emby-Token": request.api_key.strip()}, timeout=15,
@@ -1201,6 +1717,51 @@ async def worker_setup_windows_exe():
)
@app.get("/worker-setup/pfad-map")
async def worker_setup_pfad_map():
"""Was ein externer Worker für `RIPPY_PATH_MAP` eintragen muss.
DER fehlende Anschluss (Befund 26.07.2026): Ein Windows-Worker bekommt von
Rippy Container-Pfade (`/app/media/rippy/<job>`). Ohne Übersetzung auf eine
Freigabe sieht er sie nicht — und das Mapping setzte niemand. Externes
Encoden konnte deshalb nie funktionieren, obwohl das Celery-Routing
einwandfrei arbeitete (Job 95afdc89: angenommen, 182 ms später abgelehnt).
Geraten wird hier nichts: Rippy hat die Freigabe selbst eingehängt und
kennt ihre Quelle. Der Installer holt den Vorschlag von hier und schreibt
ihn in start-tray.bat — der Nutzer muss nichts über Container-Pfade wissen.
"""
def sammle():
vorschlaege = mount_verwaltung.pfad_map_vorschlag(db.list_mounts())
mapping = mount_verwaltung.pfad_map_zeile(vorschlaege)
if mapping:
hinweis = (
"Diese Freigabe(n) hängt Rippy selbst ein — der Worker erreicht "
"sie unter demselben Namen im Netzwerk. Wichtig: Arbeits"
"verzeichnis UND Ablage müssen darunter liegen, sonst kann der "
"Worker lesen, aber nicht schreiben (oder umgekehrt)."
)
elif vorschlaege:
hinweis = (
'Es sind nur NFS-Ziele eingehängt. Windows kann NFS zwar über '
'die Funktion „Client für NFS" einbinden, die Pfad-Schreibweise '
'lässt sich aber nicht zuverlässig ableiten — bitte selbst '
'eintragen, Format: /app/media/<name>=Z:\\ (oder UNC-Pfad).'
)
else:
hinweis = (
"Rippy hat keine Netzwerk-Freigabe eingehängt. Ein externer "
"Worker kann dann NICHTS komprimieren: Rohdaten und Ziel liegen "
"auf der Platte der Rippy-Maschine, und dorthin gibt es keinen "
"Netzwerk-Zugang. Abhilfe: unter Einstellungen → Speicherziele "
"eine Freigabe (NAS oder PC) einhängen und sie als "
"Arbeitsverzeichnis UND Ablage wählen."
)
return {"mapping": mapping, "eintraege": vorschlaege, "hinweis": hinweis}
return await asyncio.to_thread(sammle)
@app.get("/worker-setup/paket")
async def worker_setup_paket():
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
@@ -1219,8 +1780,13 @@ async def worker_setup_paket():
for name in sorted(os.listdir("worker_dist")):
if name.endswith(".py") and not name.startswith(("test_", "conftest")):
z.write(os.path.join("worker_dist", name), name)
elif name == "requirements.txt":
# requirements.txt für die venv, rippy.ico für die Verknüpfung
elif name in ("requirements.txt", "rippy.ico"):
z.write(os.path.join("worker_dist", name), name)
# Write RIPPY_VERSION to version.txt
version_str = os.getenv("RIPPY_VERSION", "dev")
z.writestr("version.txt", version_str)
return puffer.getvalue()
inhalt = await asyncio.to_thread(baue)
@@ -1232,6 +1798,12 @@ async def worker_setup_paket():
)
@app.get("/system/version")
async def system_version():
"""Gibt die aktuelle Rippy-Version des Servers zurück (aus deploy.sh)."""
return {"version": os.getenv("RIPPY_VERSION", "dev")}
@app.get("/system/info")
async def system_info():
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
+202 -15
View File
@@ -11,6 +11,7 @@ sondern über eine temporäre credentials-Datei).
"""
import os
import posixpath
import re
import subprocess
import tempfile
@@ -22,7 +23,16 @@ NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
def _mountpoint(name: str) -> str:
return os.path.join(MEDIA_ROOT, name)
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix das Mapping
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
Worker (v3.14), diesmal vom Test gefunden statt live.
"""
return posixpath.join(MEDIA_ROOT, name)
def validiere_name(name: str) -> bool:
@@ -57,6 +67,60 @@ def ist_erreichbar(name: str) -> bool:
return False
def pfad_lage(ziel: str) -> str:
"""Existiert dieses Verzeichnis? „da" | „weg" | „unklar" — mit Zeitgrenze.
Der Grund, warum es das gibt (Befund 26.07.2026, an Zeitstempeln gemessen):
Eine Wiederanbindung brauchte **3 Minuten 15 Sekunden**, und die Zeit ging
nicht in die Mount-Versuche, sondern in die allererste Zeile von `mounten()`:
os.makedirs(ziel, exist_ok=True)
`exist_ok` prüft mit `os.path.isdir()`, und ein `stat` auf einen TOTEN
CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf,
der nur lege den Ordner an, falls er fehlt" bedeutet, hing also drei Minuten
bevor irgendeine der sorgfältig begrenzten Prüfungen überhaupt dran war.
Dritter Fund derselben Sorte an einem Tag: os-Aufruf auf einen Netzpfad ohne
Zeitgrenze.
Deshalb wird die Lage jetzt mit einem Kind-Prozess erfragt (`timeout 4 ls`),
der sich abbrechen lässt. unklar" heißt: existiert, antwortet aber nicht —
dann ist ein makedirs weder nötig noch möglich.
"""
try:
ergebnis = subprocess.run(
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
)
except (OSError, subprocess.TimeoutExpired):
return "unklar"
if ergebnis.returncode == 0:
return "da"
return "unklar" if ergebnis.returncode == 124 else "weg"
def wirklich_erreichbar(name: str, warten=None) -> bool:
"""Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft)
Warum zweimal (Befund 26.07.2026): Direkt nach einem frischen `mount`
antwortete die Freigabe reproduzierbar und Sekunden später lief jeder
Zugriff in die Zeitgrenze. Ursache ist ein Wettlauf beim Aufräumen:
`_stale_mounts_loesen` benutzt `umount -l`, und das ist LAZY es hängt den
Mount sofort aus der Sicht aus, der eigentliche Abbau passiert später. Fällt
dieser Abbau samt Propagation (rshared) hinter den neuen Mount, zeigt der
Pfad wieder auf die Leiche.
Eine einzige Probe kann das nicht sehen. Zwei mit Abstand schon.
"""
if warten is None:
import time
warten = time.sleep
if not ist_erreichbar(name):
return False
warten(3)
return ist_erreichbar(name)
def schreibtest(pfad: str) -> bool:
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
@@ -94,6 +158,18 @@ def _stale_mounts_loesen(ziel: str) -> int:
if ergebnis.returncode != 0:
break # nichts (mehr) gemountet
geloest += 1
if geloest:
# ⚠️ Kurz durchatmen, BEVOR neu gemountet wird (Befund 26.07.2026).
# `umount -l` ist lazy: Es hängt sofort aus der Sicht aus, der eigentliche
# Abbau samt Propagation (rshared) passiert später. Wer direkt danach
# mountet, riskiert, dass dieser Abbau HINTER dem neuen Mount landet und
# der Pfad wieder auf die Leiche zeigt. Genau das hat die erste
# Reparatur regelmäßig scheitern lassen — und der zweite Versuch mit
# seinen 30 s Timeout war der Grund, warum eine Wiederanbindung rund
# 150 Sekunden brauchte. Anderthalb Sekunden Warten sparen die.
import time
time.sleep(1.5)
return geloest
@@ -161,6 +237,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
return freigaben
def unc_aus_quelle(typ: str, quelle: str) -> str:
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
(optionale Funktion Client für NFS"), die Schreibweise ist aber eine
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen
raten verstößt gegen AGENTS Regel D.
"""
if typ != "cifs" or not quelle:
return ""
rest = quelle.strip()
if not rest.startswith("//"):
return ""
return "\\\\" + rest[2:].replace("/", "\\")
def pfad_map_vorschlag(eintraege: list) -> list:
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
eingehängt und kennt ihre Quelle (z. B. `//NAS/rippy`). Der Mountpunkt ist
`/app/media/<name>`, und beides zusammen IST das Mapping.
"""
vorschlaege = []
for eintrag in eintraege or []:
name = (eintrag.get("name") or "").strip()
if not name:
continue
typ = (eintrag.get("typ") or "").strip()
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
vorschlaege.append({
"name": name,
"typ": typ,
"quelle": eintrag.get("quelle") or "",
"container": _mountpoint(name),
"unc": unc,
"gemountet": ist_gemountet(name),
})
return vorschlaege
def pfad_map_zeile(vorschlaege: list) -> str:
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
weg ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
beim ersten passenden Präfix aufhört.
"""
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
return ";".join(paare)
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
username: str = "", passwort: str = "") -> bool:
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
@@ -169,17 +304,40 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
Mount-Fehlern.
"""
ziel = _mountpoint(name)
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
if pfad_lage(ziel) == "weg":
try:
os.makedirs(ziel, exist_ok=True)
except FileExistsError:
pass
except OSError:
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
#
# Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`.
# Das hatte zwei Folgen, beide am selben Nachmittag gemessen:
#
# 1. Nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
# Zugriffen liefen in 10 s Timeout, /storage-mounts meldete
# `reachable: false`) — und blieb es. Der Mountpunkt existierte im neuen
# Container weiter (Bind-Mount vom Host), `ismount` sagte also „ist schon
# da" und das Wiederherstellen brach genau hier ab. Die Reparatur per
# POST /storage-mounts/rippy/repair stellte sie in einer Sekunde her —
# beim Start hätte dasselbe passieren müssen.
# 2. `schreibtest()` öffnet eine Datei OHNE Zeitgrenze. Auf einem toten CIFS
# blockiert das im Kernel (Thread-Zustand D), und `alle_remounten()`
# hängt beim API-Start dauerhaft in einem Hintergrund-Thread.
#
# `ist_erreichbar()` hat eine harte Grenze (`timeout 3 ls`). Antwortet die
# Freigabe, sind ismount und schreibtest danach gefahrlos. Antwortet sie
# nicht, fällt es unten durch: lösen, frisch mounten. Das ist genau der Weg,
# den `reparieren()` geht — nur eben automatisch.
if ist_erreichbar(name):
try:
if os.path.ismount(ziel):
return schreibtest(ziel)
except OSError:
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
pass # unten erst lösen, dann frisch mounten (kein Stapeln)
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
@@ -209,6 +367,19 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
else:
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
# gelegentlich schief, ohne es zu melden.
#
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
# „FEHLER" statt „eingehängt", was schlicht die Wahrheit ist.
for versuch in (1, 2):
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
@@ -220,7 +391,19 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
"NAS meist ablehnen."
)
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
# Zweimal mit Abstand prüfen — eine einzige Probe direkt nach dem
# Mount sieht den Wettlauf mit dem lazy umount nicht (siehe
# wirklich_erreichbar).
if wirklich_erreichbar(name):
return schreibtest(ziel)
if versuch == 1:
_lazy_umount(ziel)
raise RuntimeError(
f"{quelle} wurde eingehängt, antwortet aber nicht dauerhaft (zwei "
"Versuche). Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft "
"meist ein erneutes Einhängen über Einstellungen → Speicherziele → "
"Reparieren."
)
finally:
if creds_datei:
try:
@@ -237,18 +420,21 @@ def _lazy_umount(ziel: str) -> None:
def aushaengen(name: str) -> None:
"""Freigabe abhängen und den Mountpunkt aufräumen.
Ohne `os.path.ismount`/`os.rmdir` als erste Schritte: Beide `stat`en den
Pfad, und auf einem toten CIFS-Mount blockiert das im Kernel (siehe
pfad_lage). Ein `umount` auf einen Pfad ohne Mount kostet dagegen nichts.
"""
ziel = _mountpoint(name)
try:
noch_mount = os.path.ismount(ziel)
except OSError:
noch_mount = True # stale
if noch_mount:
ergebnis = subprocess.run(
["umount", ziel], capture_output=True, text=True, timeout=30
)
if ergebnis.returncode != 0:
# Toter/beschäftigter Mount → lazy detach (klappt immer)
_lazy_umount(ziel)
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
if pfad_lage(ziel) == "da":
try:
os.rmdir(ziel)
except OSError:
@@ -261,12 +447,13 @@ def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) das
normale mounten() würde am ist schon Mountpoint" hängenbleiben.
`os.path.ismount` stand hier vorher als Vorbedingung auf einem toten
CIFS-Mount blockiert dieses `stat` im Kernel (siehe pfad_lage). Gefragt wird
es gar nicht mehr: `umount -l` auf einen Pfad, an dem nichts hängt, kostet
nichts und meldet nur einen Rückgabewert, den hier niemand braucht.
"""
ziel = _mountpoint(name)
try:
if os.path.ismount(ziel):
_lazy_umount(ziel)
except OSError:
_lazy_umount(ziel)
return mounten(name, typ, quelle, optionen, username, passwort)
+76
View File
@@ -0,0 +1,76 @@
"""In welcher Phase ist ein Job gestorben — und was hilft danach wirklich?
Commander-Befund 26.07.2026: Hier gibt es den Button neu' aber WAS wird dann
gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
eine Option anbieten."
Die Frage war berechtigt und der Knopf war falsch: Er hieß nur Neu" und rief
immer `POST /jobs/{id}/retry-transcode` also immer die KOMPRESSION. Für einen
abgebrochenen Transcode ist das goldrichtig (die Roh-MKV liegt vollständig da,
man spart eine Stunde Rippen). Für einen abgebrochenen RIP ist es Unsinn: Am
26.07.2026 lagen 5,1 GB von rund 40 GB da, und Neu" hätte daraus brav einen
Film komprimiert, der bei 12 % aufhört.
## Warum es dafür eine MARKE braucht
Sobald `status = "failed"` steht, ist der vorherige Zustand fort die Spalte
hat nur einen Wert. `zombies.war_im_rip()` im Worker kann die Phase noch sehen,
weil sie dort im Moment des Aufräumens vorliegt; die API sieht später nur noch
failed". Deshalb schreibt der Worker die Phase in die Job-Metadaten:
* Rip-Start `rip_fertig = false`
* Rip fertig (Übergabe an die Kompression) `rip_fertig = true`
* Start eines Transcodes `rip_fertig = true` (heilt Bestandsjobs mit)
## Drei Antworten, nicht zwei
Die Marke FEHLT bei jedem Job, der vor dieser Änderung lief. Diese Jobs
rip" zu nennen wäre geraten — und würde dem Commander an einem
Bestandsjob mit 79,6 GB intakter Rohdaten das Neu komprimieren" wegnehmen,
das dort genau richtig ist. Wer die Phase nicht kennt, sagt das: `unklar`
lässt das UI beide Wege anbieten, jeden mit einem ehrlichen Satz.
"""
import json
# Schlüssel in den Job-Metadaten. Muss identisch in worker/tasks.py stehen —
# es gibt kein geteiltes Paket zwischen den Containern.
RIP_FERTIG = "rip_fertig"
# Die drei möglichen Antworten.
NEU_KOMPRIMIEREN = "transcode"
NEU_RIPPEN = "rip"
UNKLAR = "unklar"
NICHTS = ""
def _meta(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
if isinstance(meta_json, dict):
return meta_json
if not meta_json:
return {}
try:
gelesen = json.loads(meta_json)
except (ValueError, TypeError):
return {}
return gelesen if isinstance(gelesen, dict) else {}
def retry_art(job) -> str:
"""Was soll der Knopf an DIESEM Job anbieten?
* `NICHTS` der Job ist nicht fehlgeschlagen, es gibt nichts zu
wiederholen.
* `NEU_KOMPRIMIEREN` der Rip war fertig, nur die Kompression starb.
* `NEU_RIPPEN` der Rip selbst starb; alles Rohe ist ein Bruchstück.
* `UNKLAR` kein Vermerk (Bestandsjob), beide Wege anbieten.
"""
if (job.get("status") or "") != "failed":
return NICHTS
marke = _meta(job.get("meta")).get(RIP_FERTIG)
if marke is True:
return NEU_KOMPRIMIEREN
if marke is False:
return NEU_RIPPEN
return UNKLAR
+39 -3
View File
@@ -19,7 +19,6 @@ from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set
from cache.keys import generate_prescan_key
def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion).
@@ -442,14 +441,42 @@ class PreScan:
break
# 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.
#
# NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der
# Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55
# sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran.
# Das ist zu großzügig, und zwar messbar (offline gerechnet mit
# titel_aehnlichkeit, echte MyAnimeList-Titel):
#
# „Alien" vs. „Alien 9" → 0,83 TRIFFT
# „Inception" vs. „Deception" → 0,78 TRIFFT
# „Hero" vs. „Heroman" → 0,73 TRIFFT
# „The Dark Knight" vs. „Dark Knight Rises" → 0,69 TRIFFT
#
# Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten — und
# OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das
# widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu
# niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist
# es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can
# (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann
# beides nicht leisten.
#
# Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer
# Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts
# hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug.
jikan_unscharf = None
if not matched:
for kandidat in kandidaten:
jikan_treffer = self.jikan.lookup(kandidat)
if jikan_treffer:
if not jikan_treffer:
continue
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
confidence = 0.85
metadata = jikan_treffer
matched = True
else:
jikan_unscharf = jikan_treffer
break
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
@@ -464,6 +491,15 @@ class PreScan:
matched = True
break
# Fallback 2b: der unscharfe Jikan-Treffer — besser als nichts, aber
# ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein
# TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an
# und der Nutzer kann korrigieren).
if not matched and jikan_unscharf:
confidence = 0.6
metadata = jikan_unscharf
matched = True
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
if not matched and movies:
+245
View File
@@ -0,0 +1,245 @@
"""Welches HandBrake-Preset passt zu welcher Disc — und was ist das BESTE.
Commander-Anforderung (26.07.2026): *Bei den Presets soll IMMER das Beste
ausgewählt werden"* und *„wenn der Worker AV1 oder noch besseres kann, immer
diesem empfehlen"*.
## Warum das hier liegt und nicht im UI
Die Preset-Namen standen bis dahin fest verdrahtet im UI (drei Listen in
Settings.tsx, eine vierte im Wizard). Zwei Probleme: die Namen unterscheiden
sich zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
scheitern das Projekt hat genau das zweimal teuer bezahlt (AGENTS Regel D).
Jetzt meldet jeder Worker seine echte Liste (`worker/caps.py`,
`HandBrakeCLI --preset-list`), und die Auswahl entsteht hier: als reine
Funktionen, die die CI-Ampel prüft. Das UI zeigt nur noch an.
## Es wird nicht bewertet, sondern gestaffelt
Kein Punktesystem. Für jede Lage gibt es eine feste Reihenfolge von Namen, und
genommen wird der erste, den der Worker WIRKLICH kennt. Jeder Name unten ist am
26.07.2026 aus `--preset-list` des Worker-Images abgenommen (HandBrake 1.6.1).
Ein Punktesystem hätte über Namen geurteilt, die es vielleicht nicht gibt.
## Zwei Quellen, zwei Fragen
- `--preset-list` sagt, welche Presets es GIBT. Die Kategorie `Hardware/` steht
dort auch auf Maschinen ohne Hardware-Encoder.
- `--help` sagt, welche Encoder LAUFEN.
Deshalb wird ein Hardware-Preset nur empfohlen, wenn ein Worker die passende
Familie wirklich meldet. (Richtigstellung zum SAVEPOINT v3.16: dort galt es als
unmöglich, die Hardware-Preset-Namen auf der VM zu ermitteln die Messung sagt
das Gegenteil, siehe caps.parse_preset_liste.)
"""
# Reservewert: „diesen Disc-Typ NICHT komprimieren". Gleichlautend in
# worker/ripping.PRESET_KEINE und ui/src/lib/encoder.ts — es gibt kein
# geteiltes Paket zwischen den drei Seiten.
PRESET_KEINE = "keine"
# Auflösungs-Token je Disc-Typ, so wie HandBrake sie in die Preset-Namen
# schreibt. Damit bietet die 4K-Auswahl keine 1080p-Presets als Normalfall an —
# genau dieser Griff hat in v3.12 eine 4K-UHD auf 1080p heruntergerechnet.
AUFLOESUNG_JE_TYP = {
"uhd": ("2160p",),
"bluray": ("1080p",),
"dvd": ("576p", "480p"),
}
# Hardware-Familie im ENCODER-Namen (aus `--help`) → Kürzel im PRESET-Namen.
# Gemessen: die Presets heißen „H.265 VCN 2160p 4K", der Encoder dazu aber
# `vce_h265`. Ohne diese Zuordnung bekäme eine AMD-Karte kein AMD-Preset.
# Reihenfolge = Vorzug, wenn eine Maschine mehrere Familien meldet.
HW_PRESET_KUERZEL = (
("qsv", "QSV"), # Intel QuickSync — in 1.6.1 die einzige mit AV1-Preset
("nvenc", "NVENC"), # NVIDIA
("vce", "VCN"), # AMD (Encoder heißt vce, Preset heißt VCN)
)
# Absichtlich NICHT dabei:
# - `vaapi`: HandBrake 1.6.1 liefert kein VAAPI-Preset mit (Kategorie
# `Hardware/` kennt nur QSV/NVENC/VCN/MF). Ein VAAPI-Worker fällt daher auf
# Software zurück, statt einen Namen zu bekommen, den es nicht gibt.
# - `MF` (Media Foundation): die Presets existieren, aber ob diese Maschine sie
# nutzen kann, ist aus der Encoder-Liste nicht ablesbar. Empfohlen wird MF
# deshalb nie; in der Auswahlliste steht es trotzdem.
# Vektorbefehls-Stufen, mit denen Software-Encoding brauchbar schnell ist.
# Gleichlautend in ui/src/lib/encoder.ts (SIMD_SCHNELL).
SIMD_SCHNELL = ("avx512f", "avx2")
# Rückfall, wenn KEIN Worker eine Preset-Liste meldet (alter Worker-Stand, oder
# gar kein Worker online). Alle vier Namen sind im Worker-Image gegengeprüft.
RUECKFALL_PRESETS = (
"H.265 MKV 2160p60 4K",
"H.265 MKV 1080p30",
"H.265 MKV 576p25",
"H.265 MKV 480p30",
"HQ 2160p60 4K HEVC Surround",
"HQ 1080p30 Surround",
"HQ 576p25 Surround",
"Super HQ 2160p60 4K HEVC Surround",
"Super HQ 1080p30 Surround",
)
def verfuegbare_presets(workers) -> list:
"""Alle Preset-Namen, die die gemeldeten Worker kennen (Vereinigung).
Vereinigung und nicht Schnittmenge: Die Kompression kann gezielt an EINEN
Worker geroutet werden (worker_direct), es muss also nicht jeder alles
können. Ob der gewählte Worker das Preset kennt, entscheidet er selbst
und meldet es als Klartext-Fehler.
"""
gefunden = []
for w in workers or []:
for name in ((w.get("info") or {}).get("presets") or []):
if name and name not in gefunden:
gefunden.append(name)
return gefunden or list(RUECKFALL_PRESETS)
def hardware_kuerzel(workers) -> list:
"""Preset-Kürzel der Hardware-Encoder, die WIRKLICH gemeldet sind."""
vorhanden = set()
for w in workers or []:
for encoder in (w.get("encoders") or []):
vorhanden.add(str(encoder).lower())
kuerzel = []
for familie, kurz in HW_PRESET_KUERZEL:
if any(e == familie or e.startswith(familie + "-") for e in vorhanden):
kuerzel.append(kurz)
return kuerzel
def simd_schnell(workers) -> bool:
"""Kann mindestens ein Worker Software-Encoding brauchbar schnell?
Bei unbekannter Stufe wird NICHT geraten (ein Windows-Worker ohne die
Erkennung aus v3.16 meldet `unbekannt`) dann zählt er hier nicht mit,
bremst aber auch niemanden aus.
"""
for w in workers or []:
if ((w.get("info") or {}).get("cpu_simd") or "") in SIMD_SCHNELL:
return True
return False
def _kandidaten(disc_type: str, kuerzel: list, schnell: bool) -> list:
"""Namens-Staffel für diese Lage — bester Kandidat zuerst.
Die Software-Zweige sind bewusst dieselben, die der Wizard seit v3.15
vorschlägt (dort schon geprüft): schwache CPU 4K verlustfrei behalten und
H.264 für den Rest, starke CPU H.265 durchgehend. Neu ist nur, dass
Hardware-Presets davor kommen.
"""
liste = []
# 1. Hardware zuerst — sie ist um Größenordnungen schneller. Auf der
# Rippy-VM brauchte Software-4K gemessene 28-55 Stunden pro Film.
for kurz in kuerzel:
if disc_type == "uhd":
liste += [f"AV1 {kurz} 2160p 4K", f"H.265 {kurz} 2160p 4K"]
elif disc_type == "bluray":
liste += [f"H.265 {kurz} 1080p"]
# Für DVD-Auflösungen liefert HandBrake 1.6.1 keine Hardware-Presets —
# eine DVD ist auch in Software in Minuten fertig.
# 2. Software.
if disc_type == "uhd":
liste += ["H.265 MKV 2160p60 4K"] if schnell else [PRESET_KEINE]
elif disc_type == "bluray":
liste += ["H.265 MKV 1080p30"] if schnell else ["HQ 1080p30 Surround"]
elif disc_type == "dvd":
liste += ["H.265 MKV 576p25"] if schnell else ["HQ 576p25 Surround"]
return liste
def _grund(preset: str, disc_type: str, kuerzel: list, schnell: bool) -> str:
"""Ein Satz, WARUM das die Empfehlung ist — der Commander liest das."""
if preset == PRESET_KEINE:
return (
"Keiner der gemeldeten Worker hat AVX2 oder einen Hardware-Encoder. "
"4K in Software dauert auf so einer Maschine gemessene 28-55 Stunden "
"pro Film — die verlustfreie Datei zu behalten ist hier die ehrliche "
"Wahl (kostet 20-100 GB)."
)
if any(f" {k} " in f" {preset} " for k in kuerzel):
return (
f"Nutzt den gemeldeten Hardware-Encoder ({', '.join(kuerzel)}) — "
"um Größenordnungen schneller als die CPU. Software-x265 wäre bei "
"gleicher Dateigröße etwas sauberer, dauert aber Stunden bis Tage."
)
if schnell:
return (
"H.265 in Software: kleinste Dateien bei sehr guter Qualität. Ein "
"Worker mit AVX2 ist dafür schnell genug (gemessen)."
)
return (
"H.264 statt H.265: ohne AVX2 ist H.265 sehr langsam. Die Datei wird "
"etwas größer, der Encode dafür um ein Mehrfaches schneller."
)
def empfehlung(disc_type: str, workers) -> dict:
"""Bestes Preset für diesen Disc-Typ — nur Namen, die es wirklich gibt.
Rückgabe: {"preset": str, "grund": str, "gefunden": bool}. `gefunden` ist
False, wenn keiner der bekannten Namen in der Liste des Workers steht (z. B.
eine HandBrake-Version mit anderer Benennung) dann muss der Nutzer selbst
wählen, und das UI sagt es ihm, statt still etwas Falsches einzustellen.
"""
vorhanden = verfuegbare_presets(workers)
kuerzel = hardware_kuerzel(workers)
schnell = simd_schnell(workers)
for kandidat in _kandidaten(disc_type, kuerzel, schnell):
if kandidat == PRESET_KEINE or kandidat in vorhanden:
return {
"preset": kandidat,
"grund": _grund(kandidat, disc_type, kuerzel, schnell),
"gefunden": True,
}
return {
"preset": "",
"grund": (
"Dieses HandBrake kennt keines der Presets, die Rippy vorschlagen "
"kann — die Namen unterscheiden sich zwischen HandBrake-Versionen. "
"Bitte unten selbst eines aus der Liste des Workers wählen."
),
"gefunden": False,
}
def auswahl(disc_type: str, workers) -> list:
"""Die Presets, die für diesen Disc-Typ zur Wahl stehen — sortiert.
Gefiltert auf die passende Auflösung: Bei 4K sollen keine 1080p-Presets als
Normalfall in der Liste stehen, sonst passiert wieder, was in v3.12 passiert
ist (4K-Rip auf 1080p heruntergerechnet, weil ein globales Preset galt).
Das bewusste Verkleinern bleibt möglich es steht am Ende, mit Hinweis.
"""
vorhanden = verfuegbare_presets(workers)
tokens = AUFLOESUNG_JE_TYP.get(disc_type, ())
passend = [p for p in vorhanden if any(t in p for t in tokens)]
liste = [{"name": p, "verkleinert": False} for p in sorted(passend)]
if disc_type == "uhd":
# Bewusstes Verkleinern auf 1080p — eigene Gruppe, damit niemand aus
# Versehen dort landet.
kleiner = [p for p in vorhanden if "1080p" in p]
liste += [{"name": p, "verkleinert": True} for p in sorted(kleiner)]
return liste
def uebersicht(workers) -> dict:
"""Alles, was das UI für die Preset-Auswahl braucht — in einem Aufruf."""
typen = ("uhd", "bluray", "dvd")
return {
"quelle": "worker" if verfuegbare_presets(workers) != list(RUECKFALL_PRESETS)
else "rueckfall",
"hardware": hardware_kuerzel(workers),
"simd_schnell": simd_schnell(workers),
"typen": {
t: {"empfehlung": empfehlung(t, workers), "auswahl": auswahl(t, workers)}
for t in typen
},
}
+87 -2
View File
@@ -2,19 +2,104 @@
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
(Commander-Entscheid 24.07.2026) entfernt Heimnetz-only, siehe KONZEPT §10.
## Was diese Bremse ist — und was nicht
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht und die
Folgen waren dem Commander als wird oft neu geladen" aufgefallen.
"""
import ipaddress
import time
from collections import defaultdict
from typing import Dict
# Default Rate Limit
MAX_REQUESTS_PER_MINUTE = 100
# Wie viele Anfragen pro Minute und Client durchgehen.
#
# ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard
# braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI:
#
# Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min
# Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min
# Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min
# Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker
# ─────────
# EIN offener Tab plus ein Worker 123/min
#
# Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede
# vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt
# keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs
# hätten es verdoppelt.
#
# 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während
# eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird.
MAX_REQUESTS_PER_MINUTE = 600
# In-Memory Rate Limit Store (in Produktion mit Redis)
rate_limit_store: Dict[str, list] = defaultdict(list)
def _ist_privat(adresse: str) -> bool:
"""Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?"""
try:
ip = ipaddress.ip_address(adresse)
except ValueError:
return False
return ip.is_private or ip.is_loopback
def client_kennung(peer: str, weitergegeben: str = None) -> str:
"""Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar)
`peer` ist der direkte Absender, `weitergegeben` der Inhalt von
`X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf).
Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über
den nginx für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein
zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard,
obwohl es drei unabhängige Clients sind.
Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten
Netz kommt also unser eigener Proxy. Das ist keine Härtung gegen
Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass
eine beliebige Kopfzeile die Bremse aushebelt.
"""
peer = (peer or "").strip()
kandidat = (weitergegeben or "").split(",")[0].strip()
if kandidat and _ist_privat(peer) and _ist_privat(kandidat):
return kandidat
return peer or "unbekannt"
# Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben?
_letzte_meldung: Dict[str, float] = {}
# Abstand zwischen zwei Meldungen pro Client.
MELDE_ABSTAND_SEKUNDEN = 60
def darf_melden(client_id: str, jetzt: float = None) -> bool:
"""Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute.
Ein 429 war bisher völlig unsichtbar das UI verbuchte ihn als nichts
da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu
protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die
Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das
Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute.
"""
if jetzt is None:
jetzt = time.time()
vorher = _letzte_meldung.get(client_id, 0.0)
if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN:
return False
_letzte_meldung[client_id] = jetzt
return True
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
"""Prüfe ob Client rate-limited ist."""
current_time = time.time()
+218
View File
@@ -0,0 +1,218 @@
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
Rohschnitt (`/app/media/rippy/95afdc89-/title_t00.mkv`). Der SAVEPOINT v3.16
schrieb dazu: Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
laufenden Instanz sagt: **`can_retry` war `false`** den Knopf gab es gar nicht.
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten im
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
workDir (Einstellung) = "" geprüft wurde nur /app/temp/raw
tatsächlicher Ort = /app/media/rippy/<id>
Ergebnis 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
damals gerippt wurde) geschlossen, statt nachzusehen.
## Warum gesucht und nicht gespeichert wird
Den Ort in die Job-Zeile zu schreiben wäre sauberer aber die jobs-Tabelle
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
hätten den Wert ohnehin nicht.
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
anzusehen die oberste Ebene von `/app/media`, ohne Rekursion.
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]`.
"""
import posixpath
import subprocess
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media"
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
PRUEF_TIMEOUT_SEKUNDEN = 4
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
(Ablageziele inkl. eingehängter Freigaben) die Liste kommt vom Aufrufer,
damit diese Funktion ohne Dateisystem testbar bleibt.
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
Windows Backslashes daraus, und dann greift keine Prüfung mehr dieselbe
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
"""
if not job_id:
return []
orte = [posixpath.join(RAW_STANDARD, job_id)]
wahl = (work_dir or "").strip().rstrip("/")
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
orte.append(posixpath.join(wahl, job_id))
for name in media_unterordner or []:
if name:
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
gesehen, eindeutig = set(), []
for ort in orte:
if ort not in gesehen:
gesehen.add(ort)
eindeutig.append(ort)
return eindeutig
def pruefen(pfad: str, laufen=None) -> str:
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
Drei Antworten statt zwei, weil ich konnte nicht nachsehen" etwas anderes
ist als es ist nicht da". Gemessen am 26.07.2026: Nach einem
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
0,01 s zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
diesem Fenster der Knopf Neu komprimieren", und der Nutzer schließt daraus,
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
zweimal bezahlt.
Begründung der Technik siehe verzeichnis_da.
"""
if not pfad:
return "weg"
starten = laufen or subprocess.run
try:
ergebnis = starten(
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
capture_output=True,
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
)
except (OSError, subprocess.TimeoutExpired):
return "unklar"
if ergebnis.returncode == 0:
return "da"
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
return "unklar" if ergebnis.returncode == 124 else "weg"
def verzeichnis_da(pfad: str, laufen=None) -> bool:
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
## Warum nicht os.path.isdir
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
(Prozess-Zustand D, uninterruptible sleep"). Genau das ist am 26.07.2026
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
Schleife erreichte ihr `sleep` nie und war damit für immer tot. Sichtbar
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
Zustand D.
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
weiteren Thread verbrannt, bis der Pool leer ist.
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>`
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
die Zeitgrenze, gilt das Verzeichnis als nicht da": Ein Ort, den man nicht
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
Für den Fall konnte nicht nachsehen" gibt es `pruefen()` mit drei
Antworten. Hier gilt nur da" als ja — wer eine Ja/Nein-Antwort braucht,
soll im Zweifel Nein bekommen.
"""
return pruefen(pfad, laufen) == "da"
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
"""Die Orte, an denen wirklich etwas liegt.
`listdir` und `isdir` werden übergeben statt importiert so ist die Suche
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
das ist ein lokales Verzeichnis die Freigaben sind Unterordner davon.
"""
try:
unterordner = sorted(listdir(MEDIA_ROOT))
except OSError:
unterordner = []
gefunden = []
for ort in kandidaten(job_id, work_dir, unterordner):
try:
if isdir(ort):
gefunden.append(ort)
except OSError:
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
continue
return gefunden
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
Ort hat nicht geantwortet ein leeres `pfade` ist dann kein Beweis für
nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
"""
pruefe = pruefer or pruefen
try:
unterordner = sorted(listdir(MEDIA_ROOT))
except OSError:
unterordner = []
gefunden, unklar = [], False
for ort in kandidaten(job_id, work_dir, unterordner):
antwort = pruefe(ort)
if antwort == "da":
gefunden.append(ort)
elif antwort == "unklar":
unklar = True
return {"pfade": gefunden, "unklar": unklar}
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
Job-Verzeichnis, Unterordner entstehen dort nicht.
"""
bytes_gesamt, dateien = 0, 0
for pfad in pfade or []:
try:
namen = listdir(pfad)
except OSError:
continue
for name in namen:
voll = posixpath.join(pfad, name)
try:
if isfile(voll):
bytes_gesamt += getsize(voll)
dateien += 1
except OSError:
continue
return bytes_gesamt, dateien
+89
View File
@@ -157,3 +157,92 @@ def test_worker_setup_routen_die_gebraucht_werden_sind_da():
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
"/worker-setup/windows-exe"):
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
# --- Mount-Wache: heilt, was nach jedem Rebuild kaputt ist -------------------
#
# Hier statt in test_mounts_helpers.py, weil diese Tests `main` brauchen und
# main.py haengt an fcntl (Linux). Dieses Modul ueberspringt sich unter Windows
# selbst — es laeuft also genau da, wo auch die Ampel laeuft.
def test_wache_ruehrt_nichts_an_solange_ein_job_laeuft(monkeypatch):
"""Neu verbinden heisst `umount -l`. Mitten in einem Rip oder Encode waere
das ein Datenverlust - die Wache muss dann stillstehen."""
import main
monkeypatch.setattr(main.db, "list_mounts", lambda: [
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
monkeypatch.setattr(main.db, "hat_arbeit", lambda: True)
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
lambda name: (_ for _ in ()).throw(AssertionError("nicht anfassen!")))
main._mounts_nachsehen() # darf einfach nichts tun
def test_wache_verbindet_eine_stumme_freigabe_neu(monkeypatch):
import main
repariert, gelogged = [], []
monkeypatch.setattr(main.db, "list_mounts", lambda: [
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
monkeypatch.setattr(main.db, "add_log",
lambda lvl, src, msg: gelogged.append((lvl, msg)))
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
lambda *a, **k: repariert.append(a[0]))
main._MOUNT_STAND.clear()
main._mounts_nachsehen()
assert repariert == ["rippy"]
assert any("neu verbunden" in m for _, m in gelogged)
def test_wache_meckert_nicht_jede_minute(monkeypatch):
"""Ist das NAS ausgeschaltet, waere ein Log je Minute ein Wasserfall.
Gemeldet wird nur der UEBERGANG."""
import main
gelogged = []
monkeypatch.setattr(main.db, "list_mounts", lambda: [
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
monkeypatch.setattr(main.db, "add_log",
lambda lvl, src, msg: gelogged.append(msg))
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
def reparieren_scheitert(*a, **k):
raise RuntimeError("NAS aus")
monkeypatch.setattr(main.mount_verwaltung, "reparieren", reparieren_scheitert)
main._MOUNT_STAND.clear()
for _ in range(5):
main._mounts_nachsehen()
# "antwortet nicht" genau EINMAL (der Uebergang), die Fehlschlaege sind
# jeweils eigene Meldungen - aber kein wiederholtes "antwortet nicht".
assert len([m for m in gelogged if "antwortet nicht" in m]) == 1
def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
import main
gelogged = []
zustand = {"da": False}
monkeypatch.setattr(main.db, "list_mounts", lambda: [
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
monkeypatch.setattr(main.db, "add_log",
lambda lvl, src, msg: gelogged.append(msg))
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
lambda name: zustand["da"])
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
lambda *a, **k: None)
main._MOUNT_STAND.clear()
main._MOUNT_STAND["rippy"] = False
zustand["da"] = True
main._mounts_nachsehen()
assert any("antwortet wieder" in m for m in gelogged)
+159
View File
@@ -0,0 +1,159 @@
"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur.
Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der
4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im
Quellstrom gemessen, `/proc/<pid>/fdinfo/`) hochgerechnet 28-55 Stunden. Genau
dieser Fall muss eine ehrliche Antwort geben, statt gleich fertig" zu behaupten.
"""
import eta
def test_ohne_genug_punkte_keine_aussage():
assert eta.restzeit_sekunden(None, 100.0) == -1
assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1
einer = {"status": "transcoding", "punkte": [[0.0, 5]]}
assert eta.restzeit_sekunden(einer, 100.0) == -1
def test_zu_kurze_spanne_keine_aussage():
"""Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden
läuft MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung."""
reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]}
assert eta.restzeit_sekunden(reihe, 10.0) == -1
def test_kein_fortschritt_keine_aussage():
"""Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt,
nicht null. Ohne diese Sperre käme eine Division durch 0."""
reihe = {"status": "transcoding", "punkte": [[0.0, 3]]}
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0)
assert eta.restzeit_sekunden(reihe, 3600.0) == -1
def test_einfache_hochrechnung():
"""10 % in 10 Minuten → 90 % brauchen 90 Minuten."""
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]}
assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60
def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage():
"""Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung
TAGE landen das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am
25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer
1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet."""
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]}
sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0)
stunden = sekunden / 3600
assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h
assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h"
# Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der
# Größenordnung wäre jede Stundenangabe erfundene Genauigkeit.
langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]}
assert eta.formatiere_restzeit(
eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage"
def test_stillstand_verlaengert_die_schaetzung():
"""Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen —
sonst zeigt ein hängender Job stundenlang noch 5 Minuten"."""
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]}
frisch = eta.restzeit_sekunden(reihe, 600.0)
# ... eine Stunde später steht der Fortschritt immer noch bei 50 %
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0)
spaeter = eta.restzeit_sekunden(reihe, 4200.0)
assert spaeter > frisch * 5
def test_statuswechsel_verwirft_die_reihe():
"""Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun.
Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl."""
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]}
neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0)
assert neu["status"] == "transcoding"
assert neu["punkte"] == [[610.0, 2]]
assert eta.restzeit_sekunden(neu, 610.0) == -1
def test_nur_das_fenster_zaehlt():
"""Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer,
und dann lügt der Anfang der Messreihe."""
reihe = None
for i in range(20):
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60))
assert len(reihe["punkte"]) == eta.FENSTER
assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg
def test_gleicher_fortschritt_haengt_keinen_punkt_an():
reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0)
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0)
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0)
assert reihe["punkte"] == [[0.0, 7]]
def test_fertig_ist_null():
reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]}
assert eta.restzeit_sekunden(reihe, 600.0) == 0
assert eta.formatiere_restzeit(0) == "unter einer Minute"
# --- Textform ---------------------------------------------------------------
def test_textform_deckt_alle_groessenordnungen():
assert eta.formatiere_restzeit(-1) == ""
assert eta.formatiere_restzeit(None) == ""
assert eta.formatiere_restzeit(30) == "unter einer Minute"
assert eta.formatiere_restzeit(90) == "noch ca. 1 min"
assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min"
assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min"
assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h"
assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage"
# --- Der Weg über den Cache -------------------------------------------------
def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe():
"""So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf
schreibt die Reihe fort."""
speicher = {}
def hole(k):
return speicher.get(k)
def lege(k, wert, expire=None):
speicher[k] = wert
erst = eta.aktualisiere_und_schaetze(
"job1", "transcoding", 10, 0.0, hole, lege)
assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts
dann = eta.aktualisiere_und_schaetze(
"job1", "transcoding", 20, 600.0, hole, lege)
assert dann["sekunden"] == 80 * 60
assert dann["text"] == "noch ca. 1 h 20 min"
def test_kaputter_cache_bringt_nichts_zum_absturz():
"""Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren."""
def kaputt_holen(k):
raise RuntimeError("Redis weg")
def kaputt_legen(k, wert, expire=None):
raise RuntimeError("Redis weg")
ergebnis = eta.aktualisiere_und_schaetze(
"job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen)
assert ergebnis == {"sekunden": -1, "text": ""}
def test_fertige_und_wartende_jobs_bekommen_keine_eta():
speicher = {}
for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)):
ergebnis = eta.aktualisiere_und_schaetze(
"x", status, progress, 0.0,
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
assert ergebnis == {"sekunden": -1, "text": ""}
assert speicher == {} # nichts geschrieben
+78
View File
@@ -0,0 +1,78 @@
"""Was das Ähnlichkeits-Gate von Jikan wirklich durchlässt — gemessen.
Hintergrund (26.07.2026): Der SAVEPOINT v3.16 vermutete, das Gate 0,55 sei
großzügig" und Jikan stehe in der Metadaten-Kette zu früh (vor OMDb). Beides
war nachrechenbar, weil `titel_aehnlichkeit` eine reine Funktion ist und die
Rechnung ergab ein anderes Bild als die Vermutung:
- Für kurze Kinofilm-Titel ist das Gate WIRKLICH zu locker (Alien 0,83).
- Für den Fall, für den Jikan eingebaut wurde, ist es sogar zu STRENG
(Evangelion 2.22" gegen den MAL-Titel → 0,51, fällt durch).
Eine einzelne Zahl kann also beides nicht leisten. Die Folge steht in
prescan._scan_video: exakt schlägt unscharf" statt „erster gewinnt".
Die Anime-Titel unten existieren auf MyAnimeList; die Zahlen sind ausgerechnet,
nicht geschätzt. MyAnimeList selbst war bei dieser Messung nicht erreichbar
(Jikan antwortete durchweg HTTP 504) die Live-Abfrage steht damit weiter aus,
die Arithmetik des Gates ist davon aber unberührt.
"""
from clients.jikan import JikanClient, titel_aehnlichkeit
def test_kurze_kinofilm_titel_reissen_das_gate_0_55():
"""Diese vier würden ohne die Änderung Anime-Metadaten bekommen, obwohl
OMDb die Filme kennt es wurde nur nie gefragt."""
faelle = [
("Alien", "Alien 9", 0.83),
("Inception", "Deception", 0.78),
("Hero", "Heroman", 0.73),
("The Dark Knight", "Dark Knight Rises", 0.69),
]
for disc, anime, erwartet in faelle:
score = titel_aehnlichkeit(disc, anime)
assert round(score, 2) == erwartet, f"{disc} vs {anime}: {score}"
# ... liegt über dem Gate (Jikan gibt also einen Treffer zurück) ...
assert score >= JikanClient.MINDEST_AEHNLICHKEIT
# ... aber unter „sicher", darf die Kette also nicht beenden.
assert score < JikanClient.SICHER_AEHNLICHKEIT
def test_echter_treffer_gilt_weiter_als_sicher():
"""Exakter Titel → 1,0. Der Normalfall eines echten Anime-Fundes darf durch
die Verschärfung NICHT verlorengehen (AGENTS Regel B)."""
assert titel_aehnlichkeit("Monster", "Monster") == 1.0
assert titel_aehnlichkeit("Cowboy Bebop", "COWBOY BEBOP") == 1.0
# Interpunktion und Kleinschreibung sind egal — norm() wirft sie weg
assert titel_aehnlichkeit("Akira", "AKIRA!") == 1.0
for a, b in (("Monster", "Monster"), ("Akira", "AKIRA!")):
assert titel_aehnlichkeit(a, b) >= JikanClient.SICHER_AEHNLICHKEIT
def test_untertitel_zaehlt_noch_als_sicher_genug():
"""Discs tragen oft den Kurztitel, MAL den vollen. Das muss ein sicherer
Treffer bleiben, sonst hilft die Quelle bei Anime nicht mehr."""
assert titel_aehnlichkeit("Steins Gate", "Steins;Gate") >= JikanClient.SICHER_AEHNLICHKEIT
def test_der_fall_fuer_den_jikan_gebaut_wurde_faellt_heute_durch():
"""Gegenbeweis zur Vermutung „das Gate ist zu großzügig": Für Evangelion
2.22 ist es zu STRENG. Der Wert ist dokumentiert, damit ein späteres
Anheben des Gates nicht unbemerkt auch diesen Fall killt."""
score = titel_aehnlichkeit(
"Evangelion 2.22", "Evangelion: 2.0 You Can (Not) Advance"
)
assert round(score, 2) == 0.51
assert score < JikanClient.MINDEST_AEHNLICHKEIT
def test_voellig_anderer_titel_wird_verworfen():
score = titel_aehnlichkeit("Blade", "Blade of the Immortal")
assert score < JikanClient.MINDEST_AEHNLICHKEIT
def test_leere_titel_geben_null():
assert titel_aehnlichkeit("", "Monster") == 0.0
assert titel_aehnlichkeit("Monster", "") == 0.0
assert titel_aehnlichkeit("!!!", "???") == 0.0
+351 -1
View File
@@ -1,6 +1,12 @@
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
from mounts import uebersetze_smb_fehler, validiere_name
from mounts import (
pfad_map_vorschlag,
pfad_map_zeile,
uebersetze_smb_fehler,
unc_aus_quelle,
validiere_name,
)
def test_access_denied_ohne_credentials_erklaert_gastproblem():
@@ -69,3 +75,347 @@ def test_mounten_loest_stale_vor_dem_mount():
quelle = inspect.getsource(mounts.mounten)
assert "_stale_mounts_loesen(ziel)" in quelle
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
def test_unc_aus_quelle_uebersetzt_cifs():
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
def test_unc_aus_quelle_raet_bei_nfs_nicht():
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
assert unc_aus_quelle("cifs", "") == ""
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
def test_pfad_map_zeile_ohne_mounts_ist_leer():
assert pfad_map_zeile([]) == ""
assert pfad_map_zeile(None) == ""
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-/title_t00.mkv`; der
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
Funktion im Worker hier nachgebaut aufzurufen wäre wertlos, deshalb
wird sie über den Pfad importiert.
"""
import importlib.util
import os
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
mapping = pfad_map_zeile(pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
]))
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
)
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read()
assert "def pfad_lokal" in quelltext and spec is not None
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
anfang = quelltext.index("def pfad_lokal")
ende = quelltext.index("\nRAW_DIR", anfang)
umgebung = {"os": os}
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
pfad_lokal = umgebung["pfad_lokal"]
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
)
# --- Der Mount kam nach einem Rebuild nicht zurueck (Befund 26.07.2026) ------
def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch):
"""Regression. Vorher galt `os.path.ismount` als Beweis, dass alles steht -
und nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
Zugriffen 10 s Timeout), der Mountpunkt aber weiter vorhanden. Damit brach
das Wiederherstellen genau dort ab, und `schreibtest()` (kein Timeout!)
blockierte den Start-Thread im Kernel.
Antwortet die Freigabe nicht, muss geloest und frisch gemountet werden -
genau wie reparieren() es tut, nur automatisch. Der Test faengt das an der
WIRKUNG: ismount darf dann gar nicht mehr gefragt werden."""
import types
import mounts
ablauf = []
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
# pfad_lage gestubbt: Sie ruft selbst `timeout ls` auf, und hier geht es um
# die Reihenfolge von Loesen und Mounten.
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
# Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die
# Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet.
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
lambda ziel: ablauf.append("loesen"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
def fake_run(cmd, **kwargs):
ablauf.append(cmd[0])
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
# ismount darf hier gar nicht mehr gefragt werden
monkeypatch.setattr(mounts.os.path, "ismount",
lambda p: (_ for _ in ()).throw(AssertionError("zu frueh gefragt")))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
assert ablauf == ["loesen", "mount"]
def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch):
"""Befund 26.07.2026, dreimal reproduziert: `mount` meldete Erfolg, und die
Freigabe antwortete danach TROTZDEM nicht (eine einzige, korrekt aussehende
Schicht in /proc/mounts). Derselbe Ablauf ein zweites Mal stellte sie sofort
her. Also wird das Ergebnis geprueft statt geglaubt."""
import types
import mounts
ablauf = []
# nie erreichbar: vorher, nach Versuch 1, nach Versuch 2
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None)
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: ablauf.append("lazy"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: (ablauf.append(cmd[0]),
types.SimpleNamespace(returncode=0, stdout="", stderr=""))[1])
import pytest
with pytest.raises(RuntimeError) as fehler:
mounts.mounten("rippy", "cifs", "//nas/rippy")
# Zweimal gemountet, dazwischen einmal geloest
assert ablauf == ["mount", "lazy", "mount"]
# Und die Meldung sagt die Wahrheit statt "eingehaengt"
assert "antwortet aber nicht" in str(fehler.value)
def test_mounten_laesst_gesunden_mount_in_ruhe(monkeypatch):
"""Antwortet die Freigabe, bleibt sie unangetastet - kein Loesen, kein
zweites Mounten (das wuerde stapeln)."""
import mounts
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
monkeypatch.setattr(mounts.os.path, "ismount", lambda p: True)
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
lambda ziel: (_ for _ in ()).throw(AssertionError("nicht loesen!")))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
def test_wirklich_erreichbar_prueft_zweimal_mit_abstand(monkeypatch):
"""Befund 26.07.2026: Direkt nach einem frischen `mount` antwortete die
Freigabe - und Sekunden spaeter lief jeder Zugriff in die Zeitgrenze
(Wettlauf mit dem lazy umount, dessen Abbau hinter den neuen Mount fiel).
Eine EINZIGE Probe kann das nicht sehen."""
import mounts
antworten = iter([True, False]) # erst ja, dann nein
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [3]
def test_wirklich_erreichbar_bei_gesunder_freigabe(monkeypatch):
import mounts
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
assert mounts.wirklich_erreichbar("rippy", warten=lambda s: None) is True
def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt(monkeypatch):
import mounts
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [] # nicht drei Sekunden fuer nichts
def test_stale_loesen_wartet_den_lazy_abbau_ab(monkeypatch):
"""Der Grund fuer die 150 Sekunden (Befund 26.07.2026): `umount -l` ist lazy,
der Abbau passiert spaeter. Wer direkt danach mountet, riskiert, dass der
Abbau HINTER dem neuen Mount landet - der erste Reparaturversuch scheiterte
dadurch regelmaessig, und der zweite kostete 30 s Timeout."""
import time
import types
import mounts
gewartet = []
monkeypatch.setattr(time, "sleep", gewartet.append)
aufrufe = []
def fake_run(cmd, **kwargs):
aufrufe.append(cmd)
rc = 0 if len(aufrufe) <= 2 else 1
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
assert mounts._stale_mounts_loesen("/app/media/x") == 2
assert gewartet == [1.5]
def test_stale_loesen_wartet_nicht_wenn_nichts_zu_loesen_war(monkeypatch):
"""War kein Mount da, gibt es auch keinen Abbau abzuwarten - dann darf die
Reparatur nicht kuenstlich gebremst werden."""
import time
import types
import mounts
gewartet = []
monkeypatch.setattr(time, "sleep", gewartet.append)
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: types.SimpleNamespace(returncode=1, stdout=b"", stderr=b""))
assert mounts._stale_mounts_loesen("/app/media/x") == 0
assert gewartet == []
def test_pfad_lage_unterscheidet_da_weg_unklar(monkeypatch):
"""DER Fund vom 26.07.2026: Eine Wiederanbindung brauchte 3 min 15 s, und die
Zeit ging in die ERSTE Zeile von mounten() - `os.makedirs(ziel,
exist_ok=True)`. `exist_ok` prueft mit os.path.isdir, und ein `stat` auf einen
toten CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Deshalb wird die
Lage jetzt mit einem abbrechbaren Kind-Prozess erfragt."""
import types
import mounts
def antwort(rc):
return lambda cmd, **k: types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
monkeypatch.setattr(mounts.subprocess, "run", antwort(0))
assert mounts.pfad_lage("/app/media/rippy") == "da"
monkeypatch.setattr(mounts.subprocess, "run", antwort(2))
assert mounts.pfad_lage("/app/media/neu") == "weg"
# 124 = `timeout` hat abgeschossen: existiert, antwortet aber nicht
monkeypatch.setattr(mounts.subprocess, "run", antwort(124))
assert mounts.pfad_lage("/app/media/totes-nas") == "unklar"
def test_pfad_lage_nutzt_eine_zeitgrenze(monkeypatch):
import types
import mounts
gesehen = {}
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: (gesehen.update(cmd=cmd, kw=k),
types.SimpleNamespace(returncode=0))[1])
mounts.pfad_lage("/x")
assert gesehen["cmd"][0] == "timeout"
assert gesehen["kw"]["timeout"] is not None
def test_mounten_legt_den_ordner_nur_an_wenn_er_fehlt(monkeypatch):
"""Bei "unklar" (toter Mount) darf makedirs NICHT laufen - genau dort hing es
drei Minuten. Der Ordner ist dann ohnehin da."""
import types
import mounts
angelegt = []
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "unklar")
monkeypatch.setattr(mounts.os, "makedirs",
lambda *a, **k: angelegt.append(a[0] if a else ""))
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
assert angelegt == []
def test_mounten_legt_den_ordner_bei_erstinstallation_an(monkeypatch):
import types
import mounts
angelegt = []
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "weg")
monkeypatch.setattr(mounts.os, "makedirs",
lambda *a, **k: angelegt.append(a[0] if a else ""))
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
mounts.mounten("rippy", "cifs", "//nas/rippy")
assert angelegt == ["/app/media/rippy"]
def test_reparieren_fragt_ismount_nicht_mehr(monkeypatch):
"""os.path.ismount ist ein `stat` und blockiert auf einem toten CIFS-Mount.
Gebraucht wird es nicht: `umount -l` auf einen leeren Pfad kostet nichts."""
import mounts
monkeypatch.setattr(mounts.os.path, "ismount",
lambda p: (_ for _ in ()).throw(AssertionError("nicht fragen!")))
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: None)
monkeypatch.setattr(mounts, "mounten", lambda *a, **k: True)
assert mounts.reparieren("rippy", "cifs", "//nas/rippy") is True
+64
View File
@@ -0,0 +1,64 @@
"""Tests für die Phasen-Erkennung — was darf der „Neu"-Knopf anbieten?"""
import json
import phasen
def test_laufender_job_bietet_nichts():
assert phasen.retry_art({"status": "running"}) == phasen.NICHTS
assert phasen.retry_art({"status": "completed"}) == phasen.NICHTS
def test_toter_transcode_bietet_komprimieren():
job = {"status": "failed", "meta": json.dumps({"rip_fertig": True})}
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
def test_toter_rip_bietet_rippen():
"""Der Vorfall vom 26.07.2026: 5,1 GB von 40 GB. Komprimieren wäre falsch."""
job = {"status": "failed", "meta": json.dumps({"rip_fertig": False, "year": 1988})}
assert phasen.retry_art(job) == phasen.NEU_RIPPEN
def test_bestandsjob_ohne_marke_ist_unklar():
"""Jobs von VOR dieser Änderung dürfen nicht geraten werden."""
assert phasen.retry_art({"status": "failed", "meta": None}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed"}) == phasen.UNKLAR
assert phasen.retry_art(
{"status": "failed", "meta": json.dumps({"year": 1988})}
) == phasen.UNKLAR
def test_kaputtes_json_ist_unklar_statt_absturz():
"""/jobs darf an einer krummen meta-Zeile nicht scheitern."""
assert phasen.retry_art({"status": "failed", "meta": "{kein json"}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed", "meta": "[1,2]"}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed", "meta": 7}) == phasen.UNKLAR
def test_marke_wird_auch_als_dict_gelesen():
"""Der Detail-Endpunkt hat die Metadaten schon geparst."""
job = {"status": "failed", "meta": {"rip_fertig": True}}
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
def test_der_schluessel_heisst_im_worker_genauso():
"""Zwei Container, kein geteiltes Paket — die Marke muss zusammenpassen.
Ohne diese Prüfung ist ein Tippfehler in einer der beiden Dateien lautlos:
Der Worker schreibt `rip_fertig`, die API liest `ripFertig`, und JEDER Job
wäre für immer unklar". Genau diese Sorte Auseinanderdriften hat die
Zombie-Erkennung ein Release lang blind gemacht (running" vs. „ripping").
"""
import pathlib
import re
quelle = (
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
).read_text(encoding="utf-8")
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
# ankommen.
treffer = re.search(r'^RIP_FERTIG\s*=\s*"([^"]+)"', quelle, re.MULTILINE)
assert treffer, "worker/tasks.py definiert RIP_FERTIG nicht mehr"
assert treffer.group(1) == phasen.RIP_FERTIG
+97
View File
@@ -47,3 +47,100 @@ def test_omdb_jahr_parsing():
assert parse_year("20052012") == 2005 # Serien-Zeitspanne
assert parse_year("N/A") is None
assert parse_year("") is None
# --- Reihenfolge der Metadaten-Quellen: exakt schlägt unscharf ---------------
#
# Änderung vom 26.07.2026. Vorher galt der erste Jikan-Treffer über dem Gate
# 0,55 sofort als sicherer Fund, und OMDb kam nie dran — ein Kinofilm, den TMDB
# verpasst, bekam damit Anime-Metadaten. Die Messwerte dazu stehen in
# test_jikan_helpers.py („Alien" gegen „Alien 9" = 0,83).
class _StummerClient:
"""Ein Client, der auf jede Frage dasselbe antwortet — und mitzählt."""
def __init__(self, antwort=None):
self.antwort = antwort
self.aufrufe = 0
def lookup(self, *a, **k):
self.aufrufe += 1
return self.antwort
def search_movie(self, *a, **k):
return []
def search_tv(self, *a, **k):
return []
def get_movie_details(self, *a, **k):
return None
def find_by_imdb(self, *a, **k):
return None
def _scanner(monkeypatch, jikan_antwort, omdb_antwort):
"""PreScan ohne __init__ (das bräuchte DB + API-Keys), Cache stillgelegt."""
from prescan import prescan as modul
monkeypatch.setattr(modul, "get", lambda k: None)
monkeypatch.setattr(modul, "cache_set", lambda *a, **k: None)
scan = modul.PreScan.__new__(modul.PreScan)
scan.tmdb = _StummerClient()
scan.jikan = _StummerClient(jikan_antwort)
scan.omdb = _StummerClient(omdb_antwort)
return scan
def _jikan(score):
return {"type": "movie", "id": "mal-1", "title": "Alien 9",
"source": "jikan", "match_score": score}
def _omdb():
return {"type": "movie", "id": "tt0078748", "title": "Alien",
"source": "omdb", "year": 1979}
def test_unscharfer_jikan_treffer_laesst_omdb_zum_zug(monkeypatch):
"""Der eigentliche Fehler: „Alien" traf mit 0,83 auf den Anime „Alien 9",
und der Film wurde nie gesucht."""
scan = _scanner(monkeypatch, _jikan(0.83), _omdb())
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
assert ergebnis.metadata["source"] == "omdb"
assert ergebnis.confidence == 0.8
assert scan.omdb.aufrufe > 0
def test_exakter_jikan_treffer_gewinnt_sofort(monkeypatch):
"""Der Fall, für den Jikan gebaut wurde, darf NICHT verlorengehen
(AGENTS Regel B): Bei einem sicheren Treffer wird OMDb nicht mehr gefragt."""
scan = _scanner(monkeypatch, _jikan(1.0), _omdb())
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien 9", "disc_type": "Blu-ray"})
assert ergebnis.metadata["source"] == "jikan"
assert ergebnis.confidence == 0.85
assert scan.omdb.aufrufe == 0
def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch):
"""Hat OMDb nichts (kein Key, kein Treffer), ist der unscharfe Jikan-Treffer
besser als nichts aber er wird als VORSCHLAG ausgewiesen (0,6), nicht als
sicherer Fund."""
scan = _scanner(monkeypatch, _jikan(0.83), None)
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
assert ergebnis.metadata["source"] == "jikan"
assert ergebnis.confidence == 0.6
def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
"""Antwortet keine Quelle, bleibt es beim Disc-Titel mit Confidence 0,3 und
`type: unknown` der Bestand, unverändert. Der Rip laeuft trotzdem, die
Datei heisst dann nur wie das Disc-Label."""
scan = _scanner(monkeypatch, None, None)
ergebnis = scan._scan_video("/dev/sr0", {"title": "Nix Da", "disc_type": "DVD"})
assert ergebnis.confidence == 0.3
assert ergebnis.metadata["type"] == "unknown"
assert ergebnis.title == "Nix Da"
+212
View File
@@ -0,0 +1,212 @@
"""Tests der Preset-Auswahl — reine Funktionen, keine Infrastruktur.
Die Preset-Namen in den Testdaten sind ECHT: `HandBrakeCLI --preset-list` im
Worker-Image der Rippy-VM, 26.07.2026, HandBrake 1.6.1 (AGENTS Regel D).
Erfundene Namen hätten hier keinen Wert genau daran ist das Projekt zweimal
gescheitert.
"""
import presets
# Ausschnitt der echten Liste: die Kategorien, aus denen Rippy wählt.
PRESETS_ECHT = [
# General/
"Very Fast 2160p60 4K AV1", "Very Fast 1080p30",
"HQ 2160p60 4K HEVC Surround", "HQ 1080p30 Surround", "HQ 576p25 Surround",
"Super HQ 2160p60 4K HEVC Surround", "Super HQ 1080p30 Surround",
# Matroska/
"AV1 MKV 2160p60 4K",
"H.265 MKV 2160p60 4K", "H.265 MKV 1080p30", "H.265 MKV 576p25",
"H.265 MKV 480p30", "H.264 MKV 1080p30",
# Hardware/ — steht auch auf Maschinen OHNE Hardware-Encoder in der Liste
"AV1 QSV 2160p 4K",
"H.265 NVENC 2160p 4K", "H.265 NVENC 1080p",
"H.265 QSV 2160p 4K", "H.265 QSV 1080p",
"H.265 VCN 2160p 4K", "H.265 VCN 1080p",
"H.265 MF 2160p 4K", "H.265 MF 1080p",
]
def worker(name="w", encoders=None, simd="", presets_liste=None):
return {
"name": name,
"encoders": encoders if encoders is not None else ["cpu-x264", "cpu-x265"],
"info": {
"cpu_simd": simd,
"presets": presets_liste if presets_liste is not None else PRESETS_ECHT,
},
}
# --- Die Rippy-VM: sse4_2, 4 Kerne, kein Hardware-Encoder --------------------
def test_vm_ohne_avx2_bekommt_4k_verlustfrei():
"""Gemessen: 4K-HEVC in Software = 28-55 h auf dieser Maschine. Die
Empfehlung muss deshalb nicht komprimieren" lauten, nicht ein 4K-Preset."""
w = [worker(simd="sse4_2")]
e = presets.empfehlung("uhd", w)
assert e["preset"] == presets.PRESET_KEINE
assert "28-55 Stunden" in e["grund"]
def test_vm_ohne_avx2_bekommt_h264_fuer_bluray_und_dvd():
w = [worker(simd="sse4_2")]
assert presets.empfehlung("bluray", w)["preset"] == "HQ 1080p30 Surround"
assert presets.empfehlung("dvd", w)["preset"] == "HQ 576p25 Surround"
# --- Der PC des Commanders: avx512f, 16 Kerne, RX 9070 XT -------------------
def test_hardware_av1_wird_bevorzugt_wenn_die_familie_gemeldet_ist():
"""Commander-Vorgabe: „wenn der Worker AV1 oder noch besseres kann, immer
diesem empfehlen". QSV meldet AV1, also muss das AV1-Preset kommen."""
w = [worker(encoders=["cpu-x265", "qsv", "qsv-av1"], simd="avx512f")]
assert presets.empfehlung("uhd", w)["preset"] == "AV1 QSV 2160p 4K"
def test_amd_bekommt_das_amd_preset_nicht_das_intel():
"""Der Encoder heißt `vce`, das Preset heißt `VCN` — ohne diese Zuordnung
liefe die RX 9070 XT des Commanders unter einem Intel-Namen."""
w = [worker(encoders=["cpu-x265", "vce"], simd="avx512f")]
assert presets.empfehlung("uhd", w)["preset"] == "H.265 VCN 2160p 4K"
assert presets.empfehlung("bluray", w)["preset"] == "H.265 VCN 1080p"
def test_hardware_grund_nennt_den_nachteil_auch():
w = [worker(encoders=["vce"], simd="avx512f")]
grund = presets.empfehlung("uhd", w)["grund"]
assert "VCN" in grund
assert "sauberer" in grund # ehrlich: Software hätte etwas mehr Qualität
def test_starke_cpu_ohne_hardware_bekommt_h265():
w = [worker(encoders=["cpu-x264", "cpu-x265"], simd="avx2")]
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
assert presets.empfehlung("bluray", w)["preset"] == "H.265 MKV 1080p30"
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
def test_dvd_bekommt_nie_ein_hardware_preset():
"""HandBrake 1.6.1 hat keine Hardware-Presets in DVD-Auflösung — ein
erfundenes H.265 VCN 576p" würde die Kompression scheitern lassen."""
w = [worker(encoders=["vce", "qsv", "nvenc"], simd="avx512f")]
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
# --- Hardware, die KEIN passendes Preset hat --------------------------------
def test_vaapi_faellt_auf_software_zurueck_statt_zu_raten():
"""HandBrake 1.6.1 liefert kein VAAPI-Preset mit. Ein VAAPI-Worker darf
deshalb keinen Hardware-Namen bekommen (AGENTS Regel D)."""
w = [worker(encoders=["cpu-x265", "vaapi"], simd="avx2")]
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
def test_mf_wird_nie_empfohlen_steht_aber_zur_wahl():
"""Media-Foundation-Presets existieren, aber ob die Maschine sie nutzen
kann, ist aus der Encoder-Liste nicht ablesbar nicht empfehlen, nicht
verstecken."""
w = [worker(encoders=["cpu-x265"], simd="avx2")]
assert "MF" not in presets.empfehlung("uhd", w)["preset"]
namen = [a["name"] for a in presets.auswahl("uhd", w)]
assert "H.265 MF 2160p 4K" in namen
# --- Nur echte Namen, nie geratene ------------------------------------------
def test_unbekannte_handbrake_version_bekommt_keine_erfundene_empfehlung():
"""Meldet ein Worker nur Presets, die Rippy nicht kennt, wird ehrlich
selbst wählen" gesagt — statt einen Namen einzustellen, den dieses
HandBrake ablehnt."""
w = [worker(simd="avx2", presets_liste=["Irgendwas 4711p", "Noch was"])]
e = presets.empfehlung("uhd", w)
assert e["gefunden"] is False
assert e["preset"] == ""
assert "selbst" in e["grund"]
def test_ohne_worker_greift_der_rueckfall():
"""Kein Worker online (oder alter Worker-Stand ohne Preset-Meldung): die
Auswahl darf nicht leer sein, sonst ist die Seite unbenutzbar."""
assert presets.verfuegbare_presets([]) == list(presets.RUECKFALL_PRESETS)
assert presets.uebersicht([])["quelle"] == "rueckfall"
# ... und der Rückfall muss selbst brauchbar sein:
e = presets.empfehlung("bluray", [])
assert e["gefunden"] is True
assert e["preset"] in presets.RUECKFALL_PRESETS
def test_quelle_ist_worker_wenn_einer_meldet():
assert presets.uebersicht([worker(simd="avx2")])["quelle"] == "worker"
def test_verfuegbare_presets_vereinigt_ohne_doppelte():
a = worker("a", presets_liste=["H.265 MKV 1080p30", "HQ 1080p30 Surround"])
b = worker("b", presets_liste=["H.265 MKV 1080p30", "H.265 NVENC 1080p"])
assert presets.verfuegbare_presets([a, b]) == [
"H.265 MKV 1080p30", "HQ 1080p30 Surround", "H.265 NVENC 1080p",
]
# --- Auswahl-Listen: die 4K-Falle aus v3.12 darf nicht wiederkommen ---------
def test_4k_auswahl_zeigt_2160p_presets_zuerst():
w = [worker(simd="avx2")]
liste = presets.auswahl("uhd", w)
zuerst = [a["name"] for a in liste if not a["verkleinert"]]
assert all("2160p" in n for n in zuerst)
assert "H.265 MKV 2160p60 4K" in zuerst
def test_4k_auswahl_kennzeichnet_das_verkleinern():
"""Ein 1080p-Preset für eine 4K-Disc ist eine legitime Wahl, aber eine
BEWUSSTE in v3.12 passierte es aus Versehen und die 4K-Auflösung war weg."""
w = [worker(simd="avx2")]
verkleinert = [a["name"] for a in presets.auswahl("uhd", w) if a["verkleinert"]]
assert "H.265 MKV 1080p30" in verkleinert
assert all("1080p" in n for n in verkleinert)
def test_dvd_auswahl_nimmt_beide_pal_und_ntsc_aufloesungen():
w = [worker(simd="avx2")]
namen = [a["name"] for a in presets.auswahl("dvd", w)]
assert "H.265 MKV 576p25" in namen # PAL
assert "H.265 MKV 480p30" in namen # NTSC
assert "H.265 MKV 1080p30" not in namen
def test_bluray_auswahl_enthaelt_kein_4k():
w = [worker(simd="avx2")]
namen = [a["name"] for a in presets.auswahl("bluray", w)]
assert namen
assert not any("2160p" in n for n in namen)
# --- Erkennung der Maschinen-Stärke ----------------------------------------
def test_unbekannte_simd_stufe_gilt_nicht_als_schnell_und_nicht_als_langsam():
"""Ein Windows-Worker ohne die Erkennung aus v3.16 meldet `unbekannt`. Er
darf nichts behaupten aber auch keinen schnellen Worker ausbremsen."""
assert presets.simd_schnell([worker(simd="unbekannt")]) is False
assert presets.simd_schnell([worker(simd="unbekannt"), worker(simd="avx2")]) is True
def test_hardware_kuerzel_nur_bei_echter_meldung():
assert presets.hardware_kuerzel([worker(encoders=["cpu-x265"])]) == []
assert presets.hardware_kuerzel([worker(encoders=["nvenc", "nvenc-av1"])]) == ["NVENC"]
# Reihenfolge ist die Vorzugsreihenfolge, nicht die Meldereihenfolge
assert presets.hardware_kuerzel([worker(encoders=["vce", "qsv"])]) == ["QSV", "VCN"]
def test_uebersicht_liefert_alle_drei_disc_typen():
u = presets.uebersicht([worker(simd="avx2")])
assert set(u["typen"]) == {"uhd", "bluray", "dvd"}
for eintrag in u["typen"].values():
assert eintrag["empfehlung"]["preset"]
assert eintrag["auswahl"]
+90
View File
@@ -0,0 +1,90 @@
"""Tests für die Anfrage-Bremse.
Sie hatte bis zum 26.07.2026 keine und dabei war sie seit Wochen die Ursache
eines gemeldeten Fehlers (dieser Bereich wird oft neu geladen"): Das Limit lag
mit 100/min UNTER dem, was Rippys eigenes Dashboard verursacht, und weil hinter
dem nginx alle Clients dieselbe Adresse hatten, galt es auch noch gemeinsam.
"""
import ratelimit
def test_hinter_dem_proxy_zaehlt_der_echte_client():
"""Der ganze Grund für den Fehler: ein Eimer für alle."""
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.20") == "192.168.178.20"
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.99") == "192.168.178.99"
# Zwei Clients hinter demselben Proxy sind zwei Eimer.
assert (
ratelimit.client_kennung("172.19.0.6", "192.168.178.20")
!= ratelimit.client_kennung("172.19.0.6", "192.168.178.99")
)
def test_ohne_kopfzeile_gilt_der_direkte_absender():
assert ratelimit.client_kennung("192.168.178.20", None) == "192.168.178.20"
assert ratelimit.client_kennung("192.168.178.20", "") == "192.168.178.20"
def test_erste_adresse_der_kette_gewinnt():
"""X-Forwarded-For kann eine Liste sein — der Ursprung steht vorn."""
assert ratelimit.client_kennung(
"172.19.0.6", "192.168.178.20, 172.19.0.6"
) == "192.168.178.20"
def test_muell_in_der_kopfzeile_wird_ignoriert():
"""Sonst könnte ein Tippfehler beliebig viele Eimer aufmachen."""
assert ratelimit.client_kennung("172.19.0.6", "nicht-ip") == "172.19.0.6"
assert ratelimit.client_kennung("172.19.0.6", "<script>") == "172.19.0.6"
def test_oeffentliche_adresse_in_der_kopfzeile_wird_nicht_geglaubt():
"""Heimnetz-only: Was nicht aus dem privaten Netz kommt, zählt nicht.
Keine Härtung gegen Angreifer (dafür ist Rippy die falsche Stelle), aber die
Bremse soll sich nicht mit einer beliebigen Kopfzeile aushebeln lassen.
"""
assert ratelimit.client_kennung("172.19.0.6", "8.8.8.8") == "172.19.0.6"
def test_ohne_jede_angabe_bleibt_ein_eimer_uebrig():
assert ratelimit.client_kennung("", None) == "unbekannt"
def test_limit_greift_erst_nach_der_grenze():
ratelimit.reset_rate_limit("test-a")
for i in range(10):
assert ratelimit.check_rate_limit("test-a", max_requests=10), f"Anfrage {i}"
assert not ratelimit.check_rate_limit("test-a", max_requests=10)
def test_eimer_sind_getrennt():
ratelimit.reset_rate_limit("test-b")
ratelimit.reset_rate_limit("test-c")
for _ in range(5):
ratelimit.check_rate_limit("test-b", max_requests=5)
assert not ratelimit.check_rate_limit("test-b", max_requests=5)
assert ratelimit.check_rate_limit("test-c", max_requests=5)
def test_grenze_deckt_die_eigene_last_ab():
"""Die Bremse darf Rippy nicht selbst ausbremsen.
Nachgerechnet aus den Taktgebern im UI (Zahlen im Kopf von ratelimit.py):
Dashboard 75/min + Log-Kasten 24/min + Laufwerke 12/min + Tray 12/min je
Worker = 123/min für EINEN Tab. Mit 100 war das garantiert rot. Diese Prüfung
hält fest, dass die Grenze mit Luft darüber liegt wer den Wert senkt, muss
hier vorbei.
"""
eigene_last_pro_tab = 75 + 24 + 12
tray_pro_worker = 12
assert ratelimit.MAX_REQUESTS_PER_MINUTE >= 2 * eigene_last_pro_tab + 3 * tray_pro_worker
def test_meldung_wird_gedrosselt():
"""Ein Amok-Skript darf nicht das Log-Fenster fluten."""
ratelimit._letzte_meldung.pop("test-d", None)
assert ratelimit.darf_melden("test-d", jetzt=1000.0)
assert not ratelimit.darf_melden("test-d", jetzt=1001.0)
assert not ratelimit.darf_melden("test-d", jetzt=1059.0)
assert ratelimit.darf_melden("test-d", jetzt=1061.0)
+232
View File
@@ -0,0 +1,232 @@
"""Tests der Rohdaten-Suche — mit dem echten Fall, der sie nötig gemacht hat.
Job `95afdc89` (26.07.2026, an der laufenden Instanz gemessen): 79,6 GB
Rohschnitt unter /app/media/rippy/<id>, Einstellung `workDir` leer, `can_retry`
= false. Der Knopf Neu komprimieren" fehlte, obwohl die Datei intakt war.
"""
import rohdaten
JOB = "95afdc89-2426-4d44-829e-ad6ce1411905"
def test_der_echte_fall_wird_gefunden():
"""workDir ist LEER (so stand es live) und der Rohschnitt liegt trotzdem auf
der NAS weil beim Start eine Wahl nur für diesen Rip getroffen wurde."""
orte = rohdaten.kandidaten(JOB, "", ["movies", "music", "rippy", "series"])
assert f"/app/media/rippy/{JOB}" in orte
# Der Container-Standard bleibt der erste Kandidat (schnellster Treffer)
assert orte[0] == f"/app/temp/raw/{JOB}"
def test_suche_liefert_nur_was_existiert():
vorhanden = {f"/app/media/rippy/{JOB}"}
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["movies", "rippy"],
isdir=lambda p: p in vorhanden,
)
assert gefunden == [f"/app/media/rippy/{JOB}"]
def test_eingestelltes_arbeitsverzeichnis_kommt_vor_den_zielen():
orte = rohdaten.kandidaten(JOB, "/app/media/rippy", ["movies", "rippy"])
assert orte[1] == f"/app/media/rippy/{JOB}"
# ... und taucht nicht doppelt auf, obwohl „rippy" auch Ablageziel ist
assert orte.count(f"/app/media/rippy/{JOB}") == 1
def test_arbeitsverzeichnis_ausserhalb_media_wird_ignoriert():
"""Nur /app/media ist erlaubt (so entscheidet _arbeitsverzeichnis im
Worker) ein Pfad daneben darf hier nicht durchrutschen."""
orte = rohdaten.kandidaten(JOB, "/etc", [])
assert orte == [f"/app/temp/raw/{JOB}"]
# Der Klassiker: ein Pfad, der nur mit dem Präfix ANFÄNGT
orte = rohdaten.kandidaten(JOB, "/app/media-boese", [])
assert orte == [f"/app/temp/raw/{JOB}"]
def test_media_root_selbst_ist_erlaubt():
orte = rohdaten.kandidaten(JOB, "/app/media", [])
assert f"/app/media/{JOB}" in orte
def test_ohne_job_id_nichts():
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
def test_kaputter_mount_reisst_die_suche_nicht_mit():
"""Ein toter CIFS-Mount lässt isdir mit OSError fliegen. Das darf die
Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets)."""
def isdir_kaputt(p):
if "totes-nas" in p:
raise OSError("Stale file handle")
return p == f"/app/temp/raw/{JOB}"
gefunden = rohdaten.suche(
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
assert gefunden == [f"/app/temp/raw/{JOB}"]
def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
def listdir_kaputt(p):
raise OSError("kein /app/media")
gefunden = rohdaten.suche(
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
assert gefunden == [f"/app/temp/raw/{JOB}"]
# --- Größe ------------------------------------------------------------------
def test_groesse_zaehlt_nur_dateien():
dateien = {
f"/app/media/rippy/{JOB}/title_t00.mkv": 79604951639,
f"/app/media/rippy/{JOB}/title_t01.mkv": 1000,
}
bytes_gesamt, anzahl = rohdaten.groesse(
[f"/app/media/rippy/{JOB}"],
listdir=lambda p: ["title_t00.mkv", "title_t01.mkv", "unterordner"],
isfile=lambda p: p in dateien,
getsize=lambda p: dateien[p],
)
# Die echte Größe des Akira-Rohschnitts, plus eine zweite Datei
assert bytes_gesamt == 79604952639
assert anzahl == 2
assert round(bytes_gesamt / 1024**3, 1) == 74.1
def test_groesse_ohne_pfade_ist_null():
assert rohdaten.groesse([], lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
assert rohdaten.groesse(None, lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
def test_groesse_ueberspringt_unlesbares():
def getsize_kaputt(p):
raise OSError("weg")
bytes_gesamt, anzahl = rohdaten.groesse(
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
assert (bytes_gesamt, anzahl) == (0, 0)
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
#
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
class _Lauf:
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
def __init__(self, rc=0, wirf=None):
self.rc, self.wirf, self.cmd = rc, wirf, None
def __call__(self, cmd, **kwargs):
self.cmd = cmd
if self.wirf:
raise self.wirf
class E:
returncode = self.rc
return E()
def test_verzeichnis_da_nutzt_timeout_und_ls():
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
lauf = _Lauf(rc=0)
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
assert lauf.cmd[0] == "timeout"
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
def test_verzeichnis_da_nicht_vorhanden():
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
kann, ist fuer einen Rip ohnehin unbrauchbar."""
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
import subprocess as sp
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
assert rohdaten.verzeichnis_da(
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
def laufen(cmd, **kwargs):
pfad = cmd[-1]
class E:
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
return E()
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
)
assert gefunden == [f"/app/media/rippy/{JOB}"]
# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------
def test_pruefen_unterscheidet_drei_faelle():
assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da"
assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg"
# 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen
assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar"
assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar"
assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg"
def test_verzeichnis_da_bleibt_streng():
"""Wer Ja/Nein braucht, bekommt im Zweifel Nein."""
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False
def test_suche_mit_status_meldet_ungepruefte_orte():
"""Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer.
Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten."""
def pruefe(pfad):
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
assert e == {"pfade": [], "unklar": True}
def test_suche_mit_status_ohne_zweifel():
def pruefe(pfad):
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
"""Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt."""
def pruefe(pfad):
if pfad == f"/app/media/rippy/{JOB}":
return "da"
return "unklar" if "totes-nas" in pfad else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
assert e["unklar"] is True
+9
View File
@@ -14,6 +14,15 @@ server {
proxy_pass http://api:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
# ⚠️ OHNE DIESE ZEILEN TEILEN SICH ALLE CLIENTS EIN RATE-LIMIT
#
# Die API begrenzt Anfragen pro Client-IP (ratelimit.py). Ohne
# weitergegebene Adresse sah sie als Absender immer DIESEN Container
# Browser, zweiter Tab und der Windows-Tray landeten also in einem
# gemeinsamen Eimer. Gemessen am 26.07.2026: 812 von 876 Anfragen kamen
# scheinbar von 172.19.0.6, und das UI bekam laufend HTTP 429.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
proxy_buffering off;
proxy_read_timeout 3600s;
@@ -1,3 +1,4 @@
import { ReactNode } from 'react'
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
@@ -8,6 +9,9 @@ interface ConfirmDialogProps {
onConfirm: () => void
title: string
message: string
// Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten,
// die beim Entfernen liegen bleiben, samt Wahl „mitlöschen".
extra?: ReactNode
confirmText?: string
cancelText?: string
type?: 'warning' | 'info' | 'success'
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
onConfirm,
title,
message,
extra,
confirmText = 'Bestätigen',
cancelText = 'Abbrechen',
type = 'info',
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{message}
</p>
{extra}
</div>
</div>
@@ -91,6 +91,10 @@ export default function DeviceDiscovery() {
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
// Sprachauswahl dieses Rips (leer = alles behalten)
...(optionen?.audioSprachen?.length ? { audio_sprachen: optionen.audioSprachen } : {}),
...(optionen?.untertitelSprachen?.length
? { untertitel_sprachen: optionen.untertitelSprachen } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
+87 -22
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
import { api } from '../lib/api'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
@@ -37,35 +37,47 @@ const ENCODER_LABELS: Record<string, string> = {
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
const PLATZ_WARNUNG_GB = 60
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
function presetsFuer(schwach: boolean) {
if (schwach) {
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
// als H.265, dafür etwas größer).
return {
transcodePreset: 'HQ 1080p30 Surround',
transcodePresetBluray: 'HQ 1080p30 Surround',
transcodePresetDvd: 'HQ 576p25 Surround',
transcodePresetUhd: PRESET_KEINE,
}
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
interface PresetUebersicht {
quelle: 'worker' | 'rueckfall'
hardware: string[]
typen: Record<'uhd' | 'bluray' | 'dvd', {
empfehlung: { preset: string, grund: string, gefunden: boolean }
}>
}
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
function presetsFuer(uebersicht: PresetUebersicht | null) {
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
const e = uebersicht?.typen?.[typ]?.empfehlung
return e?.gefunden ? e.preset : ''
}
const uhd = nimm('uhd')
const bluray = nimm('bluray')
const dvd = nimm('dvd')
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
// statt dass HandBrake später ein leeres --preset bekommt.
return {
transcodePreset: 'H.265 MKV 1080p30',
transcodePresetBluray: 'H.265 MKV 1080p30',
transcodePresetDvd: 'H.265 MKV 576p25',
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
...(dvd ? { transcodePresetDvd: dvd } : {}),
...(uhd ? { transcodePresetUhd: uhd } : {}),
}
}
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [tmdbKey, setTmdbKey] = useState('')
const [omdbKey, setOmdbKey] = useState('')
// MakeMKV-Beta-Key: Lizenz fuer die SOFTWARE (Blu-ray), nicht der
// Disc-Schluessel einer einzelnen 4K-Disc.
const [makemkvKey, setMakemkvKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
const [geladen, setGeladen] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -77,15 +89,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
useEffect(() => {
let laeuft = true
const laden = async () => {
const [caps, devs, info] = await Promise.allSettled([
const [caps, devs, info, pres] = await Promise.allSettled([
api.get('/capabilities'),
api.get('/devices'),
api.get('/system/info'),
api.get('/presets'),
])
if (!laeuft) return
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
setGeladen(true)
}
laden()
@@ -94,7 +108,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
}, [])
const schwach = schwacheEncoderCpu(workers)
const presets = presetsFuer(schwach)
const presets = presetsFuer(presetInfo)
const encoderWorker = workers.filter(w => w.encoders.length > 0)
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
@@ -108,6 +122,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
...bestehende,
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
mediaServer,
transcodeEnabled,
...presets,
@@ -232,6 +247,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</div>
</section>
{/* ---- MakeMKV-Beta-Key --------------------------------------- */}
{/*
Neu 26.07.2026, bei der Weitergabe-Prüfung aufgefallen: Der Key war
in der README, in der .env und in den Einstellungen zu finden nur
NICHT hier. Ein Fremder installiert also, legt eine Blu-ray ein und
bekommt irgendwann einen Fehlschlag, ohne dass ihn beim Einrichten
jemand darauf hingewiesen hätte. Das ist die wahrscheinlichste
Stolperstelle einer frischen Installation.
*/}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<KeyRound size={18} className="text-amber-500" /> MakeMKV-Beta-Key
</h2>
<div className="space-y-3">
<Input
label="Beta-Key (für Blu-ray; DVDs gehen ohne)"
value={makemkvKey}
onChange={e => setMakemkvKey(e.target.value)}
placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
MakeMKV ist für <strong>DVDs dauerhaft kostenlos</strong>. Für
<strong> Blu-ray und 4K</strong> braucht es den Beta-Key; ohne ihn läuft
eine 30-Tage-Probezeit, danach bricht jeder Blu-ray-Rip ab. Der Key ist
kostenlos, wechselt aber etwa monatlich nachpflegen unter
Einstellungen System, das wirkt sofort ab dem nächsten Rip, ohne
Neubau.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Nicht verwechseln mit dem <strong>Disc-Schlüssel</strong> einer einzelnen
4K-Disc das ist eine andere Sache und steht unter Einstellungen System.
</p>
</div>
</section>
{/* ---- Media-Server ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
@@ -306,6 +356,10 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
{' '}dauern. Deshalb wird eingestellt:
</p>
</>
) : presetInfo && presetInfo.hardware.length > 0 ? (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) Rippy nimmt ihn:
</p>
) : (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Deine Worker sind schnell genug für H.265 Rippy stellt ein:
@@ -316,11 +370,22 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<strong>4K-UHD:</strong>{' '}
{presets.transcodePresetUhd === PRESET_KEINE
? 'nicht komprimieren, verlustfrei behalten (20100 GB je Film)'
: presets.transcodePresetUhd}
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>Blu-ray:</strong>{' '}
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>DVD:</strong>{' '}
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
</ul>
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Warum: {presetInfo.typen.uhd.empfehlung.grund}
</p>
)}
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Alles einzeln änderbar unter Einstellungen Verarbeitung.
</p>
+165
View File
@@ -0,0 +1,165 @@
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
/*
* Was wird bei Neu" eigentlich wiederholt?
*
* Commander-Frage 26.07.2026: Hier gibt es den Button neu' aber WAS wird dann
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
* Kompression auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
*
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
* passiert mit dem, was schon auf der Platte liegt?
*
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
* Einführung der Marke), wird nicht geraten dann stehen beide Wege da, und
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
*/
interface RetryDialogProps {
offen: boolean
titel: string
plan: 'transcode' | 'rip' | 'unklar'
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
onClose: () => void
onStart: (art: 'transcode' | 'rip') => void
}
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
if (!rohdaten) return null
if (!rohdaten.gb) {
return (
<p className="text-xs text-slate-500 dark:text-slate-400">
Auf der Platte liegt zu diesem Job nichts (mehr) neu rippen ist damit
der einzige Weg.
</p>
)
}
return (
<p className="text-xs text-slate-500 dark:text-slate-400">
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
{' '}Eine Blu-ray bringt roh etwa 2545 GB mit, eine 4K-UHD 50100 GB, eine
DVD 48 GB. Passt die Zahl dazu, war der Rip fertig.
</p>
)
}
export default function RetryDialog({
offen, titel, plan, rohdaten, onClose, onStart,
}: RetryDialogProps) {
const name = titel || 'Dieser Job'
return (
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
{plan === 'transcode' && (
<>
<div className="flex items-start gap-4">
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{name}" neu komprimieren?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Der Rip war fertig abgebrochen ist erst die Kompression. Rippy
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
</p>
<Rohdaten rohdaten={rohdaten} />
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button variant="ghost" onClick={() => onStart('rip')}>
<Disc size={14} /> Doch von der Disc neu rippen
</Button>
<Button variant="primary" onClick={() => onStart('transcode')}>
<RotateCcw size={14} /> Neu komprimieren
</Button>
</div>
</>
)}
{plan === 'rip' && (
<>
<div className="flex items-start gap-4">
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{name}" neu rippen?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
auf der Platte liegt, ist ein Bruchstück komprimieren würde
daraus einen Film machen, der mitten drin aufhört. Rippy liest
die Disc deshalb von vorn.
</p>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
</p>
<Rohdaten rohdaten={rohdaten} />
<p className="text-xs text-slate-500 dark:text-slate-400">
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
lässt sich über den Papierkorb daran mitlöschen.
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button variant="amber" onClick={() => onStart('rip')}>
<Disc size={14} /> Neu rippen
</Button>
</div>
</>
)}
{plan === 'unklar' && (
<>
<div className="flex items-start gap-4">
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{name}": was soll wiederholt werden?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
fertig war oder mitten drin abbrach er ist älter als der
Vermerk dafür. Deshalb wird hier nicht geraten:
</p>
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
<li>
<strong>Neu komprimieren</strong> schnell, braucht die Disc
nicht. Richtig, wenn der Rip durchlief.
</li>
<li>
<strong>Neu rippen</strong> dauert wieder eine Weile und
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
</li>
</ul>
<Rohdaten rohdaten={rohdaten} />
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
{!!rohdaten?.gb && (
<Button variant="primary" onClick={() => onStart('transcode')}>
<RotateCcw size={14} /> Neu komprimieren
</Button>
)}
<Button variant="amber" onClick={() => onStart('rip')}>
<Disc size={14} /> Neu rippen
</Button>
</div>
</>
)}
</Modal>
)
}
+219 -8
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react'
import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
interface TargetConfig {
id: string
@@ -20,6 +21,17 @@ export interface RipOptionen {
titles?: number[]
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
// Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der
// KOMPRESSION — der Rip bleibt vollständig und verlustfrei.
audioSprachen?: string[]
untertitelSprachen?: string[]
}
// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO).
interface DiscSprache {
lang: string // ISO-639-2, z. B. "deu"
sprache: string // Klartext, z. B. "German"
spuren: number
}
// Ein Ablageziel aus GET /storage-targets.
@@ -35,6 +47,10 @@ interface WorkerWahl {
node: string | null
online?: boolean
encoders: string[]
// extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die
// Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet
// beides selbst (worker/caps.py).
info?: { extern?: string, pfad_map?: string }
}
const ENCODER_KURZ: Record<string, string> = {
@@ -80,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [workers, setWorkers] = useState<WorkerWahl[]>([])
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
/*
* Sprachen der Disc (Commander-Anforderung 26.07.2026: Die Disc hat Material
* in X Sprachen und X Untertiteln Rippy muss VOR dem Rip fragen: Was genau
* willst du haben?").
*
* Die Auskunft kommt aus demselben Titel-Scan, der schon für die
* Titel-Auswahl läuft MakeMKV liefert sie in derselben Ausgabe mit, sie
* wurde bisher nur weggeworfen.
*
* Leere Auswahl heißt bewusst alles behalten": Wer nichts anklickt, bekommt
* das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur.
*/
const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null)
const [audioWahl, setAudioWahl] = useState<Set<string>>(new Set())
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
// soweit die Disc sie überhaupt hat.
const [standardAudio, setStandardAudio] = useState<string[]>([])
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
const scanStarten = async () => {
if (!deviceId) return
@@ -102,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const tracks: TitelInfo[] = r.data.tracks || []
setTitelListe(tracks)
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
const spr = r.data.sprachen || null
setDiscSprachen(spr)
// Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat.
// Sonst stünde da eine Auswahl, die nichts bewirkt.
if (spr) {
const vorhanden = (liste: DiscSprache[], wunsch: string[]) =>
new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang))
setAudioWahl(vorhanden(spr.audio || [], standardAudio))
setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel))
}
setScanStatus('done')
} else if (r.data.status === 'error') {
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
@@ -120,8 +165,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [customPath, setCustomPath] = useState('')
// Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier
// der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht
// wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein
// beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl
// darüber, und der Browser überschrieb sie stillschweigend, sobald man auf
// „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist
// die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen
// und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt.
const [browserOffen, setBrowserOffen] = useState(false)
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
@@ -141,6 +194,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setGewaehlt(new Set())
setEncoderNode('')
setArbeitsDir('')
setBrowserOffen(false)
setDiscSprachen(null)
setAudioWahl(new Set())
setUntertitelWahl(new Set())
api.get('/storage-targets')
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setArbeitsZiele([]))
@@ -152,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
setStandardArbeitsDir((s.workDir || '').trim())
const codes = (wert: any) => String(wert || '')
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
@@ -159,7 +220,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
])
}).catch(() => {})
laden('/app/media')
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
// Normalfall gar nicht.
}, [isOpen])
const laden = async (pfad: string) => {
@@ -168,13 +230,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
// Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner.
const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || ''
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
// 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
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
const gewaehlterWorker = workers.find(w => w.node === encoderNode)
/*
* Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte
* Worker erreicht. Aus /app/media/movies wird /app/media/<freigabe>/movies.
*
* Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf
* liegt sonst stünde ein Knopf da, der nichts tut.
*/
const zielAufFreigabe = (() => {
const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0]
if (!freigabe || !zielPfad) return ''
const unterordner = zielPfad.split('/').filter(Boolean).pop() || ''
const neu = `/app/media/${freigabe}/${unterordner}`
return neu === zielPfad ? '' : neu
})()
const pfadWarnung = selectedType === 'music'
? null
: encoderNode
? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv)
// „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch
// einen, der die Pfade nicht erreicht.
: automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv)
const handleSave = () => {
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
if (selectedType === 'series' && serienName.trim()) {
@@ -186,6 +277,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
}
if (encoderNode) optionen.transcodeNode = encoderNode
if (arbeitsDir) optionen.workDir = arbeitsDir
if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl]
if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl]
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
@@ -325,6 +418,31 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</div>
)}
{/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe
Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er
die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */}
{pfadWarnung && (
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
<strong> Dieser Encoder kann so nicht arbeiten.</strong>
<p className="mt-1">{pfadWarnung}</p>
{/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR
DIESEN RIP auf die Freigabe, die der Worker erreicht ohne die
globale Ablage anzufassen. Geh in die Einstellungen" mitten im
Dialog ist lästig, und wer den Rip jetzt starten will, will
jetzt eine Lösung. */}
{zielAufFreigabe && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => { setCustomPath(zielAufFreigabe); setBrowserOffen(false) }}
>
Ziel für diesen Rip auf die Freigabe legen
</Button>
)}
</div>
)}
{/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
@@ -390,8 +508,96 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</div>
)}
{/* Ordner-Browser */}
{/* Sprachen der Disc steht direkt unter der Titel-Auswahl, weil beides
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
{scanStatus === 'done' && discSprachen
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
Sprachen auf dieser Disc
</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
{discSprachen.untertitel.length > 0
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
</p>
</div>
<div className="p-4 space-y-3">
{(['audio', 'untertitel'] as const).map(art => {
const liste = discSprachen[art]
if (liste.length === 0) return null
const wahl = art === 'audio' ? audioWahl : untertitelWahl
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
return (
<div key={art}>
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
</p>
<div className="flex flex-wrap gap-2">
{liste.map(s => {
const an = wahl.has(s.lang)
return (
<button
key={s.lang}
onClick={() => {
const neu = new Set(wahl)
if (an) neu.delete(s.lang); else neu.add(s.lang)
setzen(neu)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
an
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{sprachName(s.lang, s.sprache)}
<span className="opacity-70"> ({s.lang})</span>
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
</button>
)
})}
</div>
</div>
)
})}
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
die Disc würde unvollständig gerippt. */}
<p className="text-xs text-slate-500 dark:text-slate-400">
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
<>
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> die
Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit
Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch
einmal einzulegen.
</>
) : (
<>Ohne Auswahl behält Rippy alle Sprachen wie bisher.</>
)}
</p>
</div>
</div>
)}
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
<div>
<button
onClick={() => {
const auf = !browserOffen
setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('/app/media')
}}
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} />}
Anderen Zielordner wählen (nur für diesen Rip)
</button>
{browserOffen && (
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => browseParent && laden(browseParent)}
@@ -405,7 +611,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(browsePath)}
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
@@ -421,14 +627,19 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{d.name}
</button>
))}
{browseDirs.length === 0 && (
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
)}
</div>
</div>
)}
</div>
{/* Gewähltes Ziel Display */}
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
{customPath || targets.find(t => t.type === selectedType)?.path}
{zielPfad}
</span>
</div>
@@ -21,6 +21,7 @@ interface WorkerInfo {
cpu_kerne?: string
cpu_simd?: string
handbrake_encoder?: string
rippy_version?: string
}
}
@@ -43,6 +44,7 @@ function relativeZeit(iso?: string): string {
export default function WorkerVerwaltung() {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [serverVersion, setServerVersion] = useState<string>('dev')
const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
@@ -68,6 +70,7 @@ export default function WorkerVerwaltung() {
api.get('/capabilities')
.then(r => {
setWorkers(r.data.workers || [])
if (r.data.server_version) setServerVersion(r.data.server_version)
if (manuell) toast('success', 'Worker-Liste aktualisiert')
})
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
@@ -146,6 +149,11 @@ export default function WorkerVerwaltung() {
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
</p>
{w.info?.rippy_version && (
<p className={`text-xs ${w.info.rippy_version !== serverVersion ? 'text-rose-500 font-semibold' : 'text-slate-500 dark:text-slate-400'}`}>
Version {w.info.rippy_version} {w.info.rippy_version !== serverVersion ? `(Veraltet! Server hat ${serverVersion})` : ''}
</p>
)}
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
+6 -1
View File
@@ -4,4 +4,9 @@ import axios from 'axios'
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
export const api = axios.create({ baseURL: '/api' })
// Zeitgrenze, damit ein hängender Aufruf nicht ewig offen bleibt und sich die
// Abfragen des 4-Sekunden-Takts stapeln (Befund 26.07.2026: axios wartet ohne
// `timeout` unbegrenzt). 20 s sind reichlich für jeden Endpunkt — die
// langsamsten sind der Update-Check und der Titel-Scan-Start, alles andere
// antwortet in Millisekunden.
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
+164
View File
@@ -33,6 +33,170 @@ interface WorkerFuerBewertung {
info?: { cpu_simd?: string }
}
// --- Sprachnamen für die Auswahl vor dem Rip ---------------------------------
//
// MakeMKV liefert die Namen auf ENGLISCH („German", „Japanese") und für Spuren
// ohne Sprach-Kennzeichnung „Undetermined". Beides an der Akira-Blu-ray gemessen
// (26.07.2026): Ton deu 4×, jpn 2×, und 4× — die „und"-Spuren sind typischerweise
// Audiokommentare, die kein Sprachfeld tragen.
//
// „Undetermined" versteht ein Nicht-Entwickler nicht, und der Commander liest
// alles auf Deutsch (AGENTS Grundregel 4). Unbekannte Codes behalten MakeMKVs
// Namen — geraten wird nichts.
const SPRACHNAMEN: Record<string, string> = {
und: 'Ohne Sprachangabe',
deu: 'Deutsch', ger: 'Deutsch',
eng: 'Englisch',
jpn: 'Japanisch',
fra: 'Französisch', fre: 'Französisch',
spa: 'Spanisch',
ita: 'Italienisch',
nld: 'Niederländisch', dut: 'Niederländisch',
rus: 'Russisch',
kor: 'Koreanisch',
zho: 'Chinesisch', chi: 'Chinesisch',
pol: 'Polnisch',
tur: 'Türkisch',
ces: 'Tschechisch', cze: 'Tschechisch',
por: 'Portugiesisch',
swe: 'Schwedisch',
dan: 'Dänisch',
nor: 'Norwegisch',
fin: 'Finnisch',
hun: 'Ungarisch',
ara: 'Arabisch',
mul: 'Mehrsprachig',
}
/** Sprachname auf Deutsch — sonst der Name, den MakeMKV geliefert hat. */
export function sprachName(lang: string, makemkvName?: string): string {
return SPRACHNAMEN[(lang || '').toLowerCase()] || makemkvName || lang || '?'
}
// --- Erreicht ein EXTERNER Worker die Pfade? ---------------------------------
//
// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des
// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und
// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des
// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie.
// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier.
interface WorkerFuerPfadPruefung {
name: string
node?: string | null
info?: { extern?: string, pfad_map?: string }
}
/**
* Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie
* `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`,
* getrennt durch `;`, Treffer per Präfix.
*/
export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
if (!pfad || !mapping) return false
return mapping.split(';').some(paar => {
const quelle = paar.split('=', 1)[0]
return !!quelle && pfad.startsWith(quelle)
})
}
/**
* Namen der Speicherziele, die dieser Worker über sein Mapping erreicht.
* Aus /app/media/rippy=\\NAS\rippy" wird ["rippy"].
*/
export function freigabenAusMapping(mapping?: string): string[] {
return (mapping || '').split(';')
.map(p => p.split('=', 1)[0])
.filter(Boolean)
.map(p => p.replace(/^\/app\/media\//, ''))
.filter(n => n && !n.includes('/'))
}
/**
* Warnt, BEVOR gerippt wird, wenn der gewählte Encoder die Pfade nicht
* erreichen kann. null = alles in Ordnung (oder der Worker läuft im Container,
* dann gibt es nichts zu übersetzen).
*
* `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw ein Docker-Volume,
* an das ein externer Worker grundsätzlich nicht herankommt.
*/
export function externWarnung(
worker: WorkerFuerPfadPruefung | undefined,
zielPfad: string,
arbeitsPfad: string,
): string | null {
if (!worker || worker.info?.extern !== 'ja') return null
const mapping = (worker.info?.pfad_map || '').trim()
if (!mapping) {
return `${worker.name}" läuft auf einer anderen Maschine und hat keine `
+ 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht '
+ 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut '
+ 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser '
+ '„Automatisch" wählen.'
}
if (!arbeitsPfad) {
return `${worker.name}" arbeitet auf einer anderen Maschine. Das `
+ 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist '
+ 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als '
+ 'Arbeitsverzeichnis wählen.'
}
const zielFehlt = !pfadAbgedeckt(zielPfad, mapping)
const arbeitFehlt = !pfadAbgedeckt(arbeitsPfad, mapping)
if (!zielFehlt && !arbeitFehlt) return null
/*
* Die Meldung muss sagen, WAS ZU TUN IST nicht nur, was klemmt.
*
* Commander-Rückmeldung 26.07.2026: Das ist ja quatsch. Mein PC hat das Ziel
* als Worker direkt auf dem PC eingebunden." Er hatte die Freigabe wirklich
* als Netzlaufwerk gemountet nur war das ARBEITSVERZEICHNIS darauf gelegt
* und die ABLAGE nicht. Das Ziel lag weiter auf der VM-Platte
* (/app/media/movies), und dorthin kommt sein PC nicht.
*
* Die alte Meldung war sachlich richtig und trotzdem unbrauchbar: Sie nannte
* den Container-Pfad und die Mapping-Zeichenkette, also genau die zwei Dinge,
* die ein Nicht-Entwickler nicht deuten kann. Jetzt steht der Handgriff drin,
* inklusive des Namens der Freigabe, die dieser Worker wirklich erreicht.
*/
const freigabe = freigabenAusMapping(mapping)[0] || ''
const teile: string[] = []
if (zielFehlt) {
teile.push(`Die Ablage (${zielPfad}) liegt nicht auf der Freigabe.`)
if (freigabe) {
teile.push(`Abhilfe: Einstellungen → Ripping → „Ablage" auf `
+ `${freigabe}" stellen — das ist die Freigabe, die „${worker.name}" `
+ 'wirklich erreicht.')
}
}
if (arbeitFehlt) {
teile.push(`Das Arbeitsverzeichnis (${arbeitsPfad || 'Container-Platte'}) `
+ 'liegt nicht auf der Freigabe — oben umstellen.')
}
teile.push('Ohne das scheitert die Kompression (die Rohdaten bleiben erhalten).')
return teile.join(' ')
}
/**
* Dasselbe Risiko bei Automatisch": dort nimmt die geteilte Queue den ersten
* freien Worker auch einen externen, der die Pfade nicht erreicht. Nennt die
* betroffenen Namen, damit man gezielt einen anderen wählt.
*/
export function automatikWarnung(
workers: WorkerFuerPfadPruefung[] | undefined,
zielPfad: string,
arbeitsPfad: string,
): string | null {
const betroffen = (workers || [])
.filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null)
.map(w => w.name)
if (betroffen.length === 0) return null
return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann `
+ `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. `
+ 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste '
+ 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).'
}
/**
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
+433 -66
View File
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
interface JobMeta {
year?: number
@@ -30,7 +31,13 @@ interface Job {
progress: number
title?: string
can_retry?: boolean
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
retry_art?: string
meta?: JobMeta | null
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
// bewusst nichts angezeigt statt einer erfundenen Zahl.
eta_sekunden?: number
eta_text?: string
}
interface SystemInfo {
@@ -39,18 +46,66 @@ interface SystemInfo {
workers: { name: string; encoders: string[] }[]
}
// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit.
// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker
// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026).
interface WorkerLive {
name: string
encoders: string[]
online?: boolean
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
}
// Ein Laufwerk aus GET /devices. Gebraucht, weil die Server-Status-Karte sonst
// „keine Disc in Arbeit" behauptet, während oben auf DEMSELBEN Bildschirm
// „Akira im Laufwerk erkannt" steht (Commander-Befund 26.07.2026).
interface LaufwerkLive {
id: string
status: string
disc?: { title?: string, year?: number | null, disc_type?: string }
}
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz.
//
// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media
// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit
// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem
// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für
// eine Disc ist, sah die falsche Zahl.
interface AblageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
async function fetchJobs(): Promise<Job[]> {
/*
* Ein fehlgeschlagener Abruf gibt `null` NICHT eine leere Liste.
*
* Commander-Befund 26.07.2026: Wenn der Worker installiert ist, wird dieser
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig hier
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle Keine Jobs", die Zähler
* sprangen auf (0), vier Sekunden später war alles wieder da.
*
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
* weiß, behält, was er wusste.
*/
async function fetchJobs(): Promise<Job[] | null> {
try {
const response = await api.get('/jobs')
return response.data
return Array.isArray(response.data) ? response.data : null
} catch {
return []
return null
}
}
@@ -60,15 +115,105 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const { toast } = useToast()
const jobLoeschen = async (job: Job) => {
/*
* Entfernen fragt jetzt ZUERST, was daneben liegen bleibt.
*
* Grund (Vorfall 25.07.2026, v3.14): Job aus der Liste entfernen" löscht
* bewusst keine Dateien. Das ist richtig aber Job und Rohdaten sind nur
* über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR:
* nichts zeigt mehr darauf, Neu komprimieren" ist unmöglich, und im UI ist
* nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der
* Platte, gefunden erst per SSH.
*/
const loeschenVorbereiten = async (job: Job) => {
setLoeschKandidat(job)
setRohdaten(null)
setRohdatenMitloeschen(false)
try {
await api.delete(`/jobs/${job.id}`)
toast('success', `${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
setJobs(await fetchJobs())
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setRohdaten(r.data)
} catch {
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
}
}
/*
* Der Neu"-Knopf sagt jetzt, WAS er tut.
*
* Commander-Frage 26.07.2026: Hier gibt es den Button neu' aber WAS wird
* dann gemacht? Komprimierung? Neu Gerippt?" Antwort damals: immer
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
*
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
* 'transcode' der Rip war fertig Neu komprimieren"
* 'rip' der Rip starb Neu rippen" (Disc muss drin sein)
* 'unklar' Bestandsjob fragen, mit der Rohdaten-Größe als
* Entscheidungshilfe
*
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
* false) dann hilft auch hier nur ein neuer Rip.
*/
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
if (job.retry_art === 'rip') return 'rip'
return 'unklar'
}
const wiederholen = async (job: Job) => {
setNeuKandidat(job)
setNeuRohdaten(null)
try {
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setNeuRohdaten(r.data)
} catch {
setNeuRohdaten(null) // dann eben ohne Zahl fragen
}
}
const neuStarten = async (art: 'transcode' | 'rip') => {
const job = neuKandidat
if (!job) return
setNeuKandidat(null)
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
try {
await api.post(`/jobs/${job.id}/${weg}`)
toast('success', art === 'rip'
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
}
}
const jobLoeschen = async () => {
const job = loeschKandidat
if (!job) return
setLoeschKandidat(null)
try {
const r = await api.delete(`/jobs/${job.id}`, {
params: rohdatenMitloeschen ? { rohdaten: true } : {},
})
const befreit = r.data?.rohdaten_geloescht_gb
toast('success', `${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
+ (befreit ? `${befreit} GB Rohdaten gelöscht` : ''))
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
}
@@ -79,33 +224,71 @@ export default function Dashboard() {
try {
const r = await api.delete('/jobs')
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
setJobs(await fetchJobs())
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch {
toast('error', 'Aufräumen fehlgeschlagen')
}
}
/*
* ZWEI TAKTE statt einem.
*
* JEDER Abruf hier gibt bei Fehlschlag `null` und `null` bedeutet nichts
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
* hat der Commander als wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
*
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
* 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
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
* 75/min sind damit 15 + 15/min geworden.
*/
useEffect(() => {
const loadData = async () => {
try {
const [jobsData, sysData] = await Promise.all([
fetchJobs(),
api.get('/system/info').then(r => r.data).catch(() => null),
])
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
const nichts = () => null
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
setSparklinePoints(prev => [...prev.slice(1), newPoint])
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
const schnellLaden = async () => {
try {
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)
}
}
loadData()
const interval = setInterval(loadData, 4000)
return () => clearInterval(interval)
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
const langsamLaden = async () => {
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()
langsamLaden()
const schnell = setInterval(schnellLaden, 4000)
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) }
}, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
@@ -113,18 +296,54 @@ export default function Dashboard() {
const completedJobs = jobs.filter(j => j.status === 'completed')
const failedJobs = jobs.filter(j => j.status === 'failed')
/*
* Was diese Karte anzeigt und was sie NICHT mehr anzeigt.
*
* Commander 26.07.2026: Der Server Status muss dringend überarbeitet werden,
* das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die
* Karte hieß Echte Live-Daten" und enthielt drei Angaben, von denen zwei
* erfunden waren.
*
* 1. Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der
* Fortschritt des Jobs bzw. eine feste 15/5, wenn keiner lief. Rippy
* misst nirgends CPU-Last, also wird sie auch nicht behauptet.
* 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die
* wie eine Messung aussah.
* 3. N Worker Online" zählte die registrierten Einträge aus
* /system/info auch Leichen alter Container-Rebuilds. Die echte
* Erreichbarkeit steht in /capabilities (Celery-Ping).
*
* Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit
* Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz.
*/
const mainPlatz = systemInfo?.plaetze?.[0]
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
const workerCount = systemInfo?.workers?.length || 1
const sparklinePath = sparklinePoints.map((val, idx) => {
const x = (idx / (sparklinePoints.length - 1)) * 100
const y = 50 - (val / 100) * 40
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const workerOnline = workersLive.filter(w => w.online)
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
/*
* Phase im Klartext processing" heißt rippen, „transcoding" komprimieren.
*
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
* stand hier stur Bereit keine Disc in Arbeit / Disc einlegen, Rippy erkennt
* sie selbst", während oben auf DEMSELBEN Bildschirm Akira im Laufwerk
* erkannt" prangte. Zwei Aussagen, ein Blick, Widerspruch und der Nutzer
* weiß nicht, welcher er glauben soll.
*/
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
// Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von
// /app/media (movies/series/music liegen auf der Container-Platte und sind
// dort schon mitgezählt).
const freigaben = ablagen.filter(a => a.is_mount)
const phaseText = aktiverJob
? (aktiverJob.status === 'transcoding'
? 'Kompression läuft (HandBrake)'
: 'Rip läuft (MakeMKV, verlustfrei)')
: discImLaufwerk
? 'Disc erkannt — wartet auf „Rippen starten"'
: 'Bereit — kein Datenträger im Laufwerk'
const filteredJobs = jobs.filter(j => {
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
@@ -271,11 +490,17 @@ export default function Dashboard() {
</div>
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
<div className="w-36 space-y-1 text-right">
<div className="w-40 space-y-1 text-right">
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
</div>
{/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026
unsichtbar machte. Ohne belastbare Messreihe steht hier
ehrlich, dass noch gemessen wird (siehe api/eta.py). */}
<div className="text-[11px] font-mono text-slate-300">
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
</div>
</div>
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
@@ -360,6 +585,9 @@ export default function Dashboard() {
style={{ width: `${job.progress}%` }}
/>
</div>
{job.eta_text && (
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
@@ -377,25 +605,25 @@ export default function Dashboard() {
</Button>
)}
{job.status === 'failed' && job.can_retry && (
{job.status === 'failed' && (
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
}}
onClick={() => wiederholen(job)}
title="Fragt vorher, was genau wiederholt wird"
>
<RotateCcw size={13} /> Neu
<RotateCcw size={13} />
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
: retryPlan(job) === 'rip' ? 'Neu rippen'
: 'Neu …'}
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
onClick={() => loeschenVorbereiten(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
title="Aus der Liste entfernen"
>
<Trash2 size={14} />
</button>
@@ -421,57 +649,153 @@ export default function Dashboard() {
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200 flex items-center gap-2">
<Server size={18} className="text-amber-400" />
Server Status (Echte Live-Daten)
Server-Status
</CardTitle>
</CardHeader>
<CardContent className="p-5 flex-1 flex items-center">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
<div className="space-y-4 flex-1 w-full">
<CardContent className="p-5 flex-1 flex flex-col justify-center">
<div className="space-y-4 w-full">
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
<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" /> Auslastung:</span>
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
<Activity size={14} className="text-amber-400" /> Gerade:
</span>
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
{phaseText}
</span>
</div>
{aktiverJob ? (
<>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
<span className="text-slate-300 truncate pr-2">
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
</span>
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
das auch so gesagt nicht geraten (siehe eta.py). */}
<span className="text-amber-400 flex-shrink-0">
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
</span>
</div>
</>
) : (
<p className="text-[11px] font-mono text-slate-500">
{queueJobs.length > 0
? `${queueJobs.length} Job(s) warten in der Schlange`
: discImLaufwerk
? `${discImLaufwerk.disc!.title}`
+ (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '')
+ ' liegt bereit — oben auf „Rippen starten"'
: 'Disc einlegen — Rippy erkennt sie selbst'}
</p>
)}
</div>
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
<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"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
<span className="text-slate-200">{workerCount} Worker Online</span>
<span className="text-slate-400 flex items-center gap-1.5">
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
</span>
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
{workerOnline.length} von {workersLive.length}
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
{workerOnline.length === 0 ? (
<p className="text-[11px] font-mono text-rose-400">
Kein Worker antwortet ohne ihn läuft kein Rip.
Prüfen: <span className="text-slate-300">docker compose ps</span>
</p>
) : (
<div className="space-y-0.5">
{workerOnline.map(w => (
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
<span className="text-emerald-400"></span> {w.name}
{w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''}
{w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''}
{w.info?.extern === 'ja' ? ' · extern' : ''}
{(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''}
</p>
))}
{encoderWorker.length === 0 && (
<p className="text-[11px] font-mono text-amber-400">
Keiner dieser Worker hat HandBrake es wird nur gerippt, nicht komprimiert.
</p>
)}
</div>
)}
</div>
{/* 3. Platz Container-Platte UND die eingehängten Freigaben.
Vorher stand hier nur die Container-Platte; wer wissen
wollte, ob die NAS noch Platz für eine Disc hat, sah die
falsche Zahl (Commander-Befund 26.07.2026). */}
<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"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
</span>
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
style={{ width: `${Math.max(10, belegtPercent)}%` }}
className={`h-full rounded-full transition-all duration-500 ${
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
}`}
style={{ width: `${Math.max(2, belegtPercent)}%` }}
/>
</div>
</div>
{freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (
<p className="text-[11px] font-mono text-amber-400 mt-1">
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB
das reicht nicht mehr für jede Disc.
</p>
)}
</div>
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
<path
d={`${sparklinePath} L 100 50 L 0 50 Z`}
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
Rohdaten liegen und bei externem Encoden liegen MÜSSEN. */}
<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">
<Server size={14} className="text-amber-400" /> Freigaben:
</span>
<span className={freigaben.length > 0 ? 'text-slate-200' : 'text-slate-400'}>
{freigaben.length > 0
? `${freigaben.length} eingehängt`
: 'keine eingehängt'}
</span>
</div>
{freigaben.length === 0 ? (
<p className="text-[11px] font-mono text-slate-500">
Ohne Freigabe kann ein externer Encoder nichts tun er sieht die
Container-Platte nicht. Einhängen: Einstellungen Speicherziele.
</p>
) : (
<div className="space-y-0.5">
{freigaben.map(f => (
<p key={f.path} className="text-[11px] font-mono text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">
<span className="text-emerald-400"></span> {f.name}
</span>
<span className={f.free_gb != null && f.free_gb < 60
? 'text-amber-400 flex-shrink-0'
: 'text-slate-300 flex-shrink-0'}>
{f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}
</span>
</p>
))}
</div>
)}
</div>
</div>
</CardContent>
@@ -548,6 +872,49 @@ export default function Dashboard() {
confirmText="Aufräumen"
type="warning"
/>
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
<RetryDialog
offen={neuKandidat !== null}
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
rohdaten={neuRohdaten}
onClose={() => setNeuKandidat(null)}
onStart={neuStarten}
/>
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
<ConfirmDialog
isOpen={loeschKandidat !== null}
onClose={() => setLoeschKandidat(null)}
onConfirm={jobLoeschen}
title={`${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`}
message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind."
confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'}
type="warning"
extra={rohdaten && rohdaten.gb > 0 ? (
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
<p>
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
{rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}.
Ohne den Eintrag zeigt nichts mehr darauf sie sind dann über Rippy
nicht mehr erreichbar und Neu komprimieren" ist unmöglich.
</p>
{rohdaten.pfade.map(p => (
<p key={p} className="font-mono break-all opacity-80">{p}</p>
))}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={rohdatenMitloeschen}
onChange={e => setRohdatenMitloeschen(e.target.checked)}
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
/>
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
</label>
</div>
) : null}
/>
</div>
)
}
+37
View File
@@ -74,6 +74,24 @@ export default function LogsPage() {
? logs
: logs.filter(log => log.level === filter || log.source === filter)
/*
* Quellen zum Anklicken (Commander-Wunsch 26.07.2026: bessere Log-Ansichten
* kein txt file, direkt von Rippy Logs").
*
* Der Filter konnte Quellen schon immer, es gab nur keinen Knopf dafür man
* musste die Zeilen mit dem Auge durchsuchen. Seit dieser Runde meldet auch
* der externe Windows-Worker hierher (worker/logbruecke.py, Quelle w:<name>"),
* und damit wird die Auswahl erst richtig nützlich: was macht mein PC?" ist
* ein Klick.
*
* Die Liste wird aus den WIRKLICH vorhandenen Zeilen gebaut, nicht aus einer
* festen Aufzählung sonst fehlt jede neue Maschine.
*/
const quellen = [...new Set(logs.map(l => l.source).filter(Boolean))].sort()
// „w:tobisnicerpc" ist als Knopfbeschriftung unschön.
const quelleLabel = (q: string) => q.startsWith('w:') ? `Worker ${q.slice(2)}` : q
const stats = {
info: logs.filter(l => l.level === 'info').length,
warning: logs.filter(l => l.level === 'warning').length,
@@ -162,6 +180,25 @@ export default function LogsPage() {
{f === 'all' ? 'Alle' : getLevelLabel(f)}
</button>
))}
{quellen.length > 1 && (
<>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mx-2">Quelle:</span>
{quellen.map(q => (
<button
key={q}
onClick={() => setFilter(q)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
filter === q
? 'bg-indigo-500 text-white font-bold shadow-md shadow-indigo-500/20'
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
}`}
>
{quelleLabel(q)}
</button>
))}
</>
)}
</div>
<div className="max-h-[600px] overflow-y-auto">
+215 -35
View File
@@ -38,6 +38,10 @@ interface SettingsState {
jellyfinApiKey: string
workDir: string
makemkvAppKey: string
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
audioSprachen: string
untertitelSprachen: string
}
const defaultSettings: SettingsState = {
@@ -63,6 +67,11 @@ const defaultSettings: SettingsState = {
jellyfinApiKey: '',
workDir: '',
makemkvAppKey: '',
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
// ohne dass jemand danach gefragt hat.
audioSprachen: '',
untertitelSprachen: '',
}
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
@@ -127,6 +136,37 @@ interface SystemInfo {
webhook_gesetzt: boolean
}
// Antwort von GET /presets — die Preset-Namen kommen aus
// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
// lässt die Kompression scheitern.
interface PresetEmpfehlung {
preset: string
grund: string
gefunden: boolean
}
interface PresetTyp {
empfehlung: PresetEmpfehlung
auswahl: { name: string, verkleinert: boolean }[]
}
interface PresetUebersicht {
quelle: 'worker' | 'rueckfall'
hardware: string[]
simd_schnell: boolean
typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
}
// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
uhd: 'transcodePresetUhd',
bluray: 'transcodePresetBluray',
dvd: 'transcodePresetDvd',
}
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
function bytesLesbar(bytes: number): string {
if (!bytes) return '0 B'
@@ -171,6 +211,8 @@ export default function SettingsPage() {
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
const { toast } = useToast()
@@ -291,8 +333,90 @@ export default function SettingsPage() {
api.get('/system/aacs-dumps')
.then(r => setDumps(r.data?.dumps || []))
.catch(() => setDumps([]))
api.get('/presets')
.then(r => setPresetInfo(r.data))
.catch(() => setPresetInfo(null))
}, [])
// „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
// drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
// gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
const bestePresetsUebernehmen = () => {
if (!presetInfo) return
const neu: Partial<SettingsState> = {}
const uebernommen: string[] = []
for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
const e = presetInfo.typen[typ]?.empfehlung
if (!e?.gefunden || !e.preset) continue
neu[PRESET_FELD[typ]] = e.preset as any
uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
}
if (uebernommen.length === 0) {
toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
return
}
setSettings(prev => ({ ...prev, ...neu }))
setSaved(false)
toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
}
// Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
// PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
// steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
// Seite still auf etwas anderes springen.
const presetAuswahl = (
typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
) => {
const feld = PRESET_FELD[typ]
const wert = (settings[feld] as string) || ''
const daten = presetInfo?.typen?.[typ]
const empfehlung = daten?.empfehlung
const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
const bekannt = wert === PRESET_KEINE
|| (daten?.auswahl || []).some(o => o.name === wert)
return (
<div className="space-y-1.5">
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
<option value={PRESET_KEINE}>{keineText}</option>
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
BENANNT statt verschluckt genau so fällt eine falsche
Bestandseinstellung überhaupt auf. */}
{!bekannt && wert && (
<option value={wert}>{wert} gespeichert, dieser Worker kennt es nicht</option>
)}
{passend.length > 0 && (
<optgroup label="Passt zur Auflösung">
{passend.map(o => (
<option key={o.name} value={o.name}>
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
</option>
))}
</optgroup>
)}
{kleiner.length > 0 && (
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
</optgroup>
)}
</Select>
{empfehlung && empfehlung.preset !== wert && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{empfehlung.gefunden ? (
<>
Empfohlen wäre{' '}
<strong className="text-slate-700 dark:text-slate-300">
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
</strong>
{' — '}{empfehlung.grund}
</>
) : empfehlung.grund}
</p>
)}
</div>
)
}
const webhookTesten = async () => {
if (!settings.notificationWebhook) return
setTestBusy(true)
@@ -637,28 +761,54 @@ export default function SettingsPage() {
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
dasselbe Preset eine 4K-UHD wurde damit auf 1080p
heruntergerechnet und eine DVD auf 1080p hochskaliert.
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
Seit 26.07.2026 kommen die NAMEN vom Worker selbst
(`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
Empfehlung von GET /presets. Vorher standen sie hier fest
verdrahtet sie unterscheiden sich aber je HandBrake-Version,
und ein Name, den das HandBrake des Workers nicht kennt, lässt
die Kompression scheitern.
*/}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-slate-500 dark:text-slate-400">
Ein Preset je Quelle Rippy erkennt den Disc-Typ selbst und wählt passend aus.
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
4K-Auflösung ist weg.
</p>
<Select
label="Preset für 4K-UHD"
value={settings.transcodePresetUhd}
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
<Button
variant="secondary"
onClick={bestePresetsUebernehmen}
disabled={!presetInfo}
className="flex-shrink-0"
>
<option value={PRESET_KEINE}>Nicht komprimieren 4K verlustfrei behalten</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K 4K erhalten</option>
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround 4K, mehr Qualität</option>
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround 4K, größer</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option>
</Select>
Bestes wählen
</Button>
</div>
{presetInfo && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{presetInfo.quelle === 'worker' ? (
<>
Die Liste kommt von deinen Workern selbst.
{presetInfo.hardware.length > 0
? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
: presetInfo.simd_schnell
? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
: ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 2855 h je Film).'}
</>
) : (
<>
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
keiner online) angezeigt wird deshalb eine geprüfte Standardauswahl.
</>
)}
</p>
)}
{presetAuswahl('uhd', 'Preset für 4K-UHD',
'Nicht komprimieren — 4K verlustfrei behalten')}
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
steht nur da, wenn die Maschine sie WIRKLICH braucht -
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
@@ -673,28 +823,10 @@ export default function SettingsPage() {
</p>
)}
<Select
label="Preset für Blu-ray"
value={settings.transcodePresetBluray}
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren verlustfrei behalten</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (empfohlen)</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround H.264, maximale Kompatibilität</option>
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround größer, beste Qualität</option>
</Select>
<Select
label="Preset für DVD"
value={settings.transcodePresetDvd}
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren verlustfrei behalten</option>
<option value="H.265 MKV 576p25">H.265 MKV 576p25 PAL-DVD, native Auflösung (empfohlen)</option>
<option value="H.265 MKV 480p30">H.265 MKV 480p30 NTSC-DVD, native Auflösung</option>
<option value="HQ 576p25 Surround">HQ 576p25 Surround H.264, maximale Kompatibilität</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 hochskalieren (bringt nichts)</option>
</Select>
{presetAuswahl('bluray', 'Preset für Blu-ray',
'Nicht komprimieren — verlustfrei behalten')}
{presetAuswahl('dvd', 'Preset für DVD',
'Nicht komprimieren — verlustfrei behalten')}
<p className="text-xs text-slate-500 dark:text-slate-400">
Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
@@ -703,6 +835,54 @@ export default function SettingsPage() {
</p>
</div>
{/*
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
muss in der Automatik ebenfalls einstellbar" sein).
Sie greifen bei der KOMPRESSION, nicht beim Rippen der Rip
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
sich später mit anderer Sprachwahl neu komprimieren, ohne die
Disc wieder einzulegen.
*/}
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
</p>
</div>
<Input
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
value={settings.audioSprachen}
onChange={(e) => handleChange('audioSprachen', e.target.value)}
placeholder="z. B. deu,eng,jpn"
/>
<Input
label="Untertitel (ISO-639-2, mit Komma getrennt)"
value={settings.untertitelSprachen}
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
placeholder="z. B. deu"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
{' '}<span className="font-mono">eng</span> Englisch,
{' '}<span className="font-mono">jpn</span> Japanisch,
{' '}<span className="font-mono">fra</span> Französisch,
{' '}<span className="font-mono">spa</span> Spanisch,
{' '}<span className="font-mono">ita</span> Italienisch.
Welche eine Disc wirklich hat, zeigt Rippy beim Disc scannen" im
Rip-Dialog dort ist die Auswahl auch je Disc änderbar.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
selbst sichert immer alles sonst wäre eine spätere Änderung nur mit
neuem Rip möglich.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"
+78 -1
View File
@@ -11,6 +11,8 @@ import re
import shutil
import subprocess
from winlauf import OHNE_FENSTER
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
@@ -106,6 +108,59 @@ def leite_backends_ab(hb_encoder) -> list:
return gefunden
def parse_preset_liste(text: str) -> list:
"""Preset-NAMEN, die DIESES HandBrake wirklich kennt.
Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image
gemessen (HandBrake 1.6.1) AGENTS Regel D, nicht aus dem Kopf:
General/
Very Fast 2160p60 4K AV1
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
container.
Hardware/
H.265 VCN 2160p 4K
Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit /" am
Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht.
HandBrakes Logzeilen ([10:26:05] qsv: not available on this system") und
Meldungen wie Cannot load libnvidia-encode.so.1" haben keine Einrückung
und fallen damit von selbst weg.
Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der
HARDWARE-Presets seien auf der Rippy-VM nicht ermittelbar", weil deren
HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch die
Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF).
HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets,
`--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für
welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das
hier" die Encoder-Liste. Deshalb werden beide gemeldet.
"""
presets = []
for zeile in (text or "").splitlines():
if not zeile.strip():
continue
einrueckung = len(zeile) - len(zeile.lstrip(" "))
if einrueckung == 4:
presets.append(zeile.strip())
return presets
def hole_handbrake_presets() -> list:
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"):
return []
try:
aus = subprocess.run(
["HandBrakeCLI", "--preset-list"],
capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER,
)
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
except (OSError, subprocess.TimeoutExpired):
return []
def beste_simd(flags_text: str) -> str:
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
@@ -211,7 +266,8 @@ def hole_handbrake_hilfe() -> str:
return ""
try:
aus = subprocess.run(
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER
)
return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired):
@@ -285,6 +341,14 @@ def werkzeug_versionen() -> dict:
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
# woher die Backend-Liste kommt.
info["handbrake_encoder"] = ",".join(hb_encoder)
# Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet
# genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich
# zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
# scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine
# echte Liste, nicht eine geratene).
presets = hole_handbrake_presets()
if presets:
info["presets"] = presets
if shutil.which("makemkvcon"):
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
if shutil.which("HandBrakeCLI"):
@@ -292,6 +356,7 @@ def werkzeug_versionen() -> dict:
aus = subprocess.run(
["HandBrakeCLI", "--version"],
capture_output=True, text=True, timeout=15,
creationflags=OHNE_FENSTER,
)
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
info["handbrake"] = treffer.group(1) if treffer else "installiert"
@@ -336,4 +401,16 @@ def werkzeug_versionen() -> dict:
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
except Exception:
info["schluessel"] = "unbekannt"
# Version für den Windows-Worker (aus version.txt) oder Linux-Worker (aus Env)
try:
ver_pfad = os.path.join(os.path.dirname(__file__), "version.txt")
if os.path.isfile(ver_pfad):
with open(ver_pfad, "r", encoding="utf-8") as f:
info["rippy_version"] = f.read().strip()
else:
info["rippy_version"] = os.getenv("RIPPY_VERSION", "dev")
except Exception:
info["rippy_version"] = "unbekannt"
return info
+32
View File
@@ -217,6 +217,38 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
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))
+760
View File
@@ -0,0 +1,760 @@
import verwaltung
import os
import subprocess
import threading
import time
import urllib.request
import json
import webbrowser
import re
import flet as ft
# Tray-Icon: pystray + PIL fuer das Icon-Bild
try:
import pystray
from PIL import Image as PILImage
TRAY_VERFUEGBAR = True
except ImportError:
TRAY_VERFUEGBAR = False
# Environment configuration
BASIS = os.path.dirname(os.path.abspath(__file__))
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "")
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1"))
ABFRAGE_TAKT_SEKUNDEN = 4
# Log file path
def _log_pfad() -> str:
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
if basis_daten:
ordner = os.path.join(basis_daten, "Rippy Worker")
os.makedirs(ordner, exist_ok=True)
return os.path.join(ordner, "worker.log")
return os.path.join(BASIS, "worker.log")
LOG_PFAD = _log_pfad()
# State variables
prozess = None
aktuell = {"job": None, "online": False}
# Windows Standby blocker
_wach = {"an": False}
def wach_halten(an: bool) -> None:
if _wach["an"] == an:
return
try:
import ctypes
ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001
flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS
ctypes.windll.kernel32.SetThreadExecutionState(flags)
_wach["an"] = an
except Exception:
pass
# Celery Subprocess Management
def _leser(prozess_ref, datei, log_callback) -> None:
try:
for zeile in prozess_ref.stdout:
try:
datei.write(zeile)
datei.flush()
except OSError:
pass
if log_callback:
log_callback(zeile)
except Exception:
pass
finally:
try:
datei.close()
except OSError:
pass
def worker_laeuft() -> bool:
return prozess is not None and prozess.poll() is None
def worker_starten(log_callback=None):
global prozess
if worker_laeuft():
return
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
if not os.path.exists(celery):
celery = "celery" # fallback to global if testing outside venv
befehl = [
celery,
"-A",
"celery_app",
"worker",
"--loglevel=info",
"-Q",
"transcode",
"-n",
f"{WORKER_NAME}@%h",
]
befehl += (
["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]
)
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
prozess = subprocess.Popen(
befehl,
cwd=BASIS,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
threading.Thread(
target=_leser,
args=(prozess, log, log_callback),
daemon=True,
name="log-leser",
).start()
def worker_stoppen():
global prozess
if worker_laeuft():
prozess.terminate()
try:
prozess.wait(15)
except subprocess.TimeoutExpired:
prozess.kill()
prozess = None
wach_halten(False)
aktuell["job"] = None
# API Polling
def old_hole(pfad: str, host: str = None, timeout: int = 5):
ziel = host if host is not None else RIPPY_HOST
if not ziel:
return None
try:
with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a:
return json.load(a)
except Exception:
return None
def hole_job_und_zustand():
caps = verwaltung.hole("/capabilities")
jobs = verwaltung.hole("/jobs") or []
online = False
w_info = {}
for w in (caps or {}).get("workers") or []:
if w.get("name") == WORKER_NAME:
online = bool(w.get("online"))
w_info = w
break
laufend = None
for job in jobs if isinstance(jobs, list) else []:
if job.get("status") in ("transcoding", "processing", "running"):
laufend = job
break
aktuell["online"] = online
aktuell["job"] = laufend
wach_halten(bool(laufend))
return online, laufend, caps, jobs, w_info
def deinstallieren() -> None:
skript = os.path.join(BASIS, "uninstall.ps1")
if not os.path.isfile(skript):
return
subprocess.Popen(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
def _bruecke_bauen():
try:
import db
import logbruecke
db.init_db()
return logbruecke.Bruecke(
WORKER_NAME,
schreiber=lambda level, quelle, text: db.add_log(level, quelle, text),
jetzt=time.monotonic,
)
except Exception:
return None
def _schluessel_wache(bruecke=None, log_callback=None):
try:
import schluessel
if not schluessel.makemkvcon_pfad():
return
def melden(level, text):
msg = f"[schluessel] {level.upper()} {text}"
if bruecke:
bruecke.zeile(msg)
if log_callback:
log_callback(msg + "\n")
stand = None
while True:
try:
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
except Exception as e:
if log_callback:
log_callback(
f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n"
)
time.sleep(schluessel.TAKT_SEKUNDEN)
except ImportError:
pass
# GUI Application
def main(page: ft.Page):
page.title = f"Rippy Worker — {WORKER_NAME}"
page.window.width = 900
page.window.height = 700
page.bgcolor = "#0f172a" # Rippy Slate-900 background
page.padding = 30
page.theme_mode = ft.ThemeMode.DARK
page.fonts = {
"Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
}
page.theme = ft.Theme(font_family="Inter")
# --- Tray-Icon -----------------------------------------------------------
tray_icon_ref = {"icon": None}
def _tray_icon_bild():
"""Lade rippy.ico oder erzeuge ein Fallback-Bild."""
ico_pfad = os.path.join(BASIS, "rippy.ico")
if os.path.isfile(ico_pfad):
try:
return PILImage.open(ico_pfad)
except Exception:
pass
# Fallback: kleines lila Quadrat
img = PILImage.new("RGB", (64, 64), (99, 102, 241))
return img
def tray_zeigen(icon, item):
"""Tray-Menue: Fenster wieder anzeigen."""
page.window.visible = True
page.window.minimized = False
page.update()
def tray_beenden(icon, item):
"""Tray-Menue: App komplett beenden."""
if tray_icon_ref["icon"]:
tray_icon_ref["icon"].stop()
worker_stoppen()
page.window.destroy()
def tray_starten():
"""Erstellt und startet das System-Tray-Icon."""
if not TRAY_VERFUEGBAR:
return
menu = pystray.Menu(
pystray.MenuItem("Rippy Worker anzeigen", tray_zeigen, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden", tray_beenden),
)
icon = pystray.Icon(
"rippy_worker", _tray_icon_bild(), f"Rippy Worker — {WORKER_NAME}", menu
)
tray_icon_ref["icon"] = icon
threading.Thread(target=icon.run, daemon=True, name="tray-icon").start()
def on_window_event(e):
"""Fenster-Schliessen minimiert ins Tray statt die App zu beenden."""
if e.data == "close":
if TRAY_VERFUEGBAR and tray_icon_ref["icon"]:
page.window.visible = False
page.update()
else:
# Kein Tray verfuegbar — wirklich beenden
worker_stoppen()
page.window.destroy()
page.window.prevent_close = True
page.window.on_event = on_window_event
tray_starten()
# Colors
c_panel = "#1e293b"
c_text = "#e2e8f0"
c_muted = "#94a3b8"
c_accent = "#6366f1" # Indigo
c_success = "#10b981" # Emerald
c_warning = "#f59e0b" # Amber
c_danger = "#f43f5e" # Rose
# Formatter for job status
def old_job_text(job: dict) -> str:
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
status = {
"transcoding": "komprimiert",
"processing": "rippt",
"running": "rippt",
"pending": "wartet",
"completed": "fertig",
"failed": "Fehler",
"canceling": "bricht ab",
}.get(job.get("status") or "", job.get("status") or "?")
return f"{titel}{status}"
# UI Components
status_text = ft.Text(
"Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD
)
title_text = ft.Text(
f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text
)
machine_info = ft.Text(
"System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas"
)
progress_ring = ft.ProgressRing(
width=120, height=120, stroke_width=10, value=0, color=c_accent
)
progress_percentage = ft.Text(
"0%", size=28, weight=ft.FontWeight.BOLD, color=c_text
)
job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted)
eta_text = ft.Text("Bereit", size=14, color=c_muted)
progress_stack = ft.Stack(
[
progress_ring,
ft.Container(
content=progress_percentage,
alignment=ft.alignment.center,
width=120,
height=120,
),
],
width=120,
height=120,
)
# Live Log Regex and Layout
# [2026-07-26 20:34:00,093: INFO/MainProcess] message...
LOG_REGEX = re.compile(
r"^\[.*? (\d{2}:\d{2}:\d{2}),.*?: (INFO|WARNING|ERROR|SUCCESS).*?\] (.*)"
)
log_view = ft.ListView(expand=1, spacing=0, auto_scroll=True)
queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False)
def add_log(zeile):
z = zeile.strip()
if not z:
return
match = LOG_REGEX.match(z)
if match:
zeit, level, msg = match.groups()
color = c_text
if level == "INFO":
color = "#38bdf8" # sky-400
elif level == "WARNING":
color = "#fbbf24" # amber-400
elif level == "ERROR":
color = "#f87171" # red-400
elif level == "SUCCESS":
color = "#34d399" # emerald-400
row = ft.Row(
[
ft.Text(zeit, color=c_muted, size=12, font_family="Consolas"),
ft.Text(
f"[{level}]",
color=color,
size=12,
font_family="Consolas",
weight=ft.FontWeight.BOLD,
),
ft.Text(
msg, color=c_text, size=12, font_family="Consolas", expand=True, selectable=True
),
],
spacing=10,
vertical_alignment=ft.CrossAxisAlignment.START,
)
log_view.controls.append(row)
else:
log_view.controls.append(
ft.Text(z, color=c_muted, size=12, font_family="Consolas", selectable=True)
)
if len(log_view.controls) > 200:
log_view.controls.pop(0)
try:
page.update()
except Exception:
pass
def toggle_worker(e):
if not worker_laeuft():
worker_starten(add_log)
btn_toggle.visible = False
page.update()
btn_toggle = ft.ElevatedButton(
text="Worker läuft" if worker_laeuft() else "Worker Starten",
icon=ft.icons.CHECK if worker_laeuft() else ft.icons.PLAY_ARROW,
bgcolor="#1e293b" if worker_laeuft() else c_success,
color="white",
disabled=worker_laeuft(),
on_click=toggle_worker,
)
def uninstall_click(e):
def close_dlg(e):
dlg.open = False
page.update()
def do_uninstall(e):
deinstallieren()
page.window.close()
dlg = ft.AlertDialog(
title=ft.Text("Deinstallieren?"),
content=ft.Text(
f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."
),
actions=[
ft.TextButton("Abbrechen", on_click=close_dlg),
ft.TextButton(
"Ja, Deinstallieren",
on_click=do_uninstall,
style=ft.ButtonStyle(color=c_danger),
),
],
actions_alignment=ft.MainAxisAlignment.END,
)
page.overlay.append(dlg)
dlg.open = True
page.update()
btn_uninstall = ft.TextButton(
text="Deinstallieren",
icon=ft.icons.DELETE,
icon_color=c_danger,
on_click=uninstall_click,
)
btn_dashboard = ft.TextButton(
text="Rippy Dashboard",
icon=ft.icons.OPEN_IN_BROWSER,
on_click=lambda _: (
webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None
),
)
btn_logs = ft.TextButton(
text="Log in Rippy",
icon=ft.icons.FORMAT_ALIGN_LEFT,
on_click=lambda _: (
webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None
),
)
btn_local_log = ft.TextButton(
text="Lokales Log",
icon=ft.icons.INSERT_DRIVE_FILE,
on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None,
)
# Layout
header = ft.Row(
[
title_text,
ft.Container(expand=True),
btn_dashboard,
btn_logs,
btn_local_log,
btn_uninstall,
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
)
active_poster = ft.Image(
src="",
visible=False,
width=72,
height=108,
border_radius=10,
fit=ft.ImageFit.COVER,
)
active_poster_container = ft.Container(
content=active_poster,
border=ft.border.all(1, c_warning),
border_radius=10,
shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)),
visible=False
)
dashboard_card = ft.Container(
content=ft.Column(
[
status_text,
machine_info,
ft.Container(height=5),
ft.Row(
[
active_poster_container,
progress_stack,
ft.Column(
[job_title, eta_text],
alignment=ft.MainAxisAlignment.CENTER,
spacing=10,
),
],
alignment=ft.MainAxisAlignment.START,
spacing=30,
),
ft.Container(height=10),
btn_toggle,
]
),
bgcolor=c_panel,
border_radius=15,
padding=30,
border=ft.border.all(1, "#334155"),
)
queue_card = ft.Container(
content=ft.Column(
[
ft.Text(
"Aufgaben (Warteschlange)",
size=16,
weight=ft.FontWeight.BOLD,
color=c_text,
),
queue_view,
]
),
bgcolor="#020617",
border_radius=10,
padding=15,
border=ft.border.all(1, "#1e293b"),
)
terminal_card = ft.Container(
content=log_view,
bgcolor="#020617",
border_radius=10,
padding=15,
expand=True,
border=ft.border.all(1, "#1e293b"),
)
page.add(
header,
ft.Container(height=10),
dashboard_card,
ft.Container(height=10),
queue_card,
ft.Container(height=10),
ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text),
terminal_card,
)
# Background polling loop
def poll_loop():
while True:
try:
online, job, caps, jobs, w_info = hole_job_und_zustand()
# Update System Info
info = w_info.get("info") or {}
teile = [
t
for t in (
info.get("cpu_modell"),
f"{info.get('cpu_kerne')} Kerne"
if info.get("cpu_kerne")
else "",
info.get("cpu_simd")
if info.get("cpu_simd") not in ("", "unbekannt")
else "",
", ".join(w_info.get("encoders") or []),
)
if t
]
machine_info.value = (
" · ".join(teile)
if teile
else f"{SLOTS} Aufträge gleichzeitig möglich"
)
# Update Queue
queue_view.controls.clear()
if not jobs:
queue_view.controls.append(
ft.Text(
"Keine Aufgaben in Rippy",
color=c_muted,
font_family="Consolas",
size=13,
)
)
else:
for j in jobs[:8]:
poster = None
if j.get("meta") and j["meta"].get("poster_path"):
p = j["meta"]["poster_path"]
poster = (
p
if p.startswith("http")
else f"https://image.tmdb.org/t/p/w342{p}"
)
row_items = []
if poster:
row_items.append(
ft.Container(
content=ft.Image(
src=poster,
width=32,
height=48,
border_radius=6,
fit=ft.ImageFit.COVER,
),
border=ft.border.all(1, "#334155"),
border_radius=6,
shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK))
)
)
row_items.append(
ft.Text(
verwaltung.job_text(j).split("")[0].split(",")[0],
color=c_muted,
font_family="Consolas",
size=13,
expand=True,
)
)
queue_view.controls.append(
ft.Row(
row_items,
alignment=ft.MainAxisAlignment.START,
spacing=15,
)
)
# Update status
if not worker_laeuft():
status_text.value = "Worker-Prozess gestoppt"
status_text.color = c_danger
progress_ring.value = 0
progress_percentage.value = "0%"
job_title.value = "Nichts in Arbeit"
eta_text.value = "Bereit"
active_poster_container.visible = False
btn_toggle.visible = True
btn_toggle.disabled = False
btn_toggle.text = "Worker Starten"
btn_toggle.icon = ft.icons.PLAY_ARROW
btn_toggle.bgcolor = c_success
elif not online:
status_text.value = "Nicht mit Rippy verbunden (offline)"
status_text.color = c_warning
progress_ring.value = 0
progress_percentage.value = "0%"
active_poster_container.visible = False
btn_toggle.visible = True
btn_toggle.disabled = True
btn_toggle.text = "Warte auf Verbindung..."
btn_toggle.bgcolor = "#1e293b"
elif job:
status_text.value = "Aktiv: Komprimiert"
status_text.color = c_success
prog = float(job.get("progress") or 0)
progress_ring.value = prog / 100.0
progress_percentage.value = f"{int(prog)}%"
titel = job.get("title") or (job.get("id") or "")[:8]
job_title.value = titel
eta = job.get("eta_text") or "Restzeit wird berechnet..."
eta_text.value = f"Verbleibend: {eta}"
# Update Active Poster
poster = None
if job.get("meta") and job["meta"].get("poster_path"):
p = job["meta"]["poster_path"]
poster = (
p
if p.startswith("http")
else f"https://image.tmdb.org/t/p/w342{p}"
)
if poster:
active_poster.src = poster
active_poster_container.visible = True
else:
active_poster_container.visible = False
btn_toggle.visible = False
else:
status_text.value = "Verbunden & Bereit"
status_text.color = c_accent
progress_ring.value = 0
progress_percentage.value = "0%"
job_title.value = "Warte auf Encoder-Aufträge..."
eta_text.value = "Nichts in Arbeit"
active_poster_container.visible = False
btn_toggle.visible = False
page.update()
except Exception:
pass
time.sleep(ABFRAGE_TAKT_SEKUNDEN)
# Start Celery automatically
worker_starten(add_log)
# Init initial logs
if os.path.exists(LOG_PFAD):
try:
with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()[-50:]
for line in lines:
add_log(line)
except OSError:
pass
threading.Thread(target=poll_loop, daemon=True).start()
threading.Thread(
target=_schluessel_wache,
args=(_bruecke_bauen(), add_log),
daemon=True,
name="schluessel-wache",
).start()
if __name__ == "__main__":
ft.app(target=main)
+157
View File
@@ -0,0 +1,157 @@
"""Celery-Ausgabe des Workers nach Rippy melden — statt in eine txt-Datei.
Commander-Wunsch 26.07.2026: *bessere Log-Ansichten (kein txt file direkt von
Rippy Logs)"*. Der native Windows-Worker schrieb sein Log bisher nach
`%LOCALAPPDATA%\\Rippy Worker\\worker.log`, und das Tray öffnete die Datei im
Editor. Wer wissen wollte, warum der Worker nichts tut, musste sich also an den
PC setzen während Rippy im Browser schon eine Log-Seite hat.
## Was hier durchgeht — und warum so wenig
Die JOB-Meldungen stehen längst in Rippy: `tasks.py` schreibt Kompression
gestartet", Fehlschläge, Abbrüche selbst über `db.add_log`. Was fehlte, ist
alles, was DANEBEN passiert und den Worker unbrauchbar macht, ohne dass ein Job
existiert:
- Der Worker ist hochgefahren (oder eben nicht).
- Die Verbindung zu Redis/Postgres steht nicht.
- Er ist abgestürzt (Traceback).
Alles andere wird verworfen. Celery ist bei `--loglevel=info` gesprächig, die
`logs`-Tabelle hat keine automatische Aufräumung, und ein zugemülltes Log ist
genauso unbrauchbar wie keins.
## Das Format ist gemessen, nicht geraten
Wörtlich aus dem laufenden Worker-Container (26.07.2026, Celery 5.4.0)
AGENTS Regel D:
[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0
[2026-07-26 12:04:19,063: INFO/MainProcess] mingle: searching for neighbors
[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready.
[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: {...}
Dazu der Startbanner (Zeilen ohne diese Klammer) und Warnungen aus Pythons
warnings-Modul.
"""
import re
# `[<Zeitstempel>: <LEVEL>/<Prozess>] <Text>`
CELERY_ZEILE = re.compile(r"^\[[^\]]*?:\s*(\w+)/([^\]]+)\]\s*(.*)$")
# Celery-Level → Rippy-Level. DEBUG fällt bewusst weg.
LEVEL_MAP = {
"CRITICAL": "error",
"ERROR": "error",
"WARNING": "warning",
"INFO": "info",
}
# INFO-Zeilen sind nur interessant, wenn sie einen ZUSTAND melden. Diese Stücke
# sind alle oben im echten Log nachweisbar.
INFO_DURCHLASSEN = ("ready.", "Connected to", "Trying again", "connection")
# ... und diese sind reines Grundrauschen.
INFO_VERWERFEN = ("mingle:", "task events", "searching for neighbors", "all alone")
# Zeilen OHNE Celery-Klammer (Startbanner, Python-warnings) kommen nur durch,
# wenn sie nach einem echten Problem aussehen.
ROHE_FEHLER = ("Traceback", "Error:", "error(", "Errno", "refused", "Cannot")
# Schutz für die logs-Tabelle: mehr als das pro Minute ist kein Log, das ist ein
# Wasserfall.
MAX_ZEILEN_JE_MINUTE = 30
def einordnen(zeile: str):
"""(level, text) für eine Ausgabezeile — oder None, wenn sie nichts wert ist.
Reine Funktion, damit das Urteil ohne laufenden Worker prüfbar ist.
"""
text = (zeile or "").strip()
if not text:
return None
treffer = CELERY_ZEILE.match(text)
if not treffer:
# Startbanner und warnings-Ausgaben: nur echte Probleme.
if any(s in text for s in ROHE_FEHLER):
return ("error", text[:500])
return None
celery_level, _prozess, inhalt = treffer.groups()
level = LEVEL_MAP.get(celery_level.upper())
if not level or not inhalt:
return None
if level == "info":
if any(s in inhalt for s in INFO_VERWERFEN):
return None
if not any(s in inhalt for s in INFO_DURCHLASSEN):
return None
# „ready." heißt: der Worker nimmt jetzt Aufträge an. Das ist die
# Meldung, auf die man wartet, wenn nichts passiert.
if "ready." in inhalt:
return ("success", inhalt[:500])
return (level, inhalt[:500])
def quelle_fuer(worker_name: str) -> str:
"""Log-Quelle für diesen Worker.
Gekappt auf 32 Zeichen, weil `logs.source` genau so breit ist (db.py). Ohne
das Kappen scheitert das Insert still bei einem langen Worker-Namen und
dann fehlt genau das Log, das man sucht.
"""
name = (worker_name or "worker").strip() or "worker"
return f"w:{name}"[:32]
class Bruecke:
"""Nimmt Ausgabezeilen und schreibt die wichtigen nach Rippy.
`schreiber(level, quelle, text)` und `jetzt()` sind einspritzbar so ist
die Drosselung ohne Datenbank und ohne echte Uhr prüfbar.
"""
def __init__(self, worker_name: str, schreiber, jetzt):
self.quelle = quelle_fuer(worker_name)
self.schreiber = schreiber
self.jetzt = jetzt
self._fenster_start = None
self._im_fenster = 0
self._unterdrueckt = 0
def zeile(self, text: str) -> bool:
"""Eine Ausgabezeile verarbeiten. True, wenn sie nach Rippy ging."""
eingeordnet = einordnen(text)
if not eingeordnet:
return False
level, inhalt = eingeordnet
jetzt = self.jetzt()
if self._fenster_start is None or jetzt - self._fenster_start >= 60:
# Neues Fenster: erst melden, wieviel im alten verschluckt wurde.
if self._unterdrueckt:
self._melden(
"warning",
f"{self._unterdrueckt} weitere Log-Zeilen unterdrückt "
"(mehr als 30 pro Minute) — Details im Worker-Log auf der "
"Maschine selbst.",
)
self._fenster_start = jetzt
self._im_fenster = 0
self._unterdrueckt = 0
if self._im_fenster >= MAX_ZEILEN_JE_MINUTE:
self._unterdrueckt += 1
return False
self._im_fenster += 1
return self._melden(level, inhalt)
def _melden(self, level: str, text: str) -> bool:
try:
self.schreiber(level, self.quelle, text)
return True
except Exception:
# Rippy nicht erreichbar → die lokale Datei bleibt der Rückfall.
# Hier NICHTS werfen: der Worker soll weiterarbeiten.
return False
+24 -5
View File
@@ -140,25 +140,44 @@ def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list)
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby).
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby/Kodi).
API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh,
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token.
Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass).
Wirft nie der Refresh ist Komfort, nicht Teil des Rips.
"""
if media_server not in ("jellyfin", "emby") or not url or not api_key:
if not url or media_server not in ("jellyfin", "emby", "kodi"):
return ""
try:
if media_server in ("jellyfin", "emby") and api_key:
antwort = requests.post(
url.rstrip("/") + "/Library/Refresh",
headers={"X-Emby-Token": api_key}, timeout=15,
)
if antwort.status_code < 300:
return f"{media_server}-Bibliothek aktualisiert ({url})"
return f"{media_server.title()}-Bibliothek aktualisiert ({url})"
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
elif media_server == "kodi":
auth = None
if api_key and ":" in api_key:
user, pw = api_key.split(":", 1)
auth = (user, pw)
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
antwort = requests.post(
url.rstrip("/") + "/jsonrpc",
json=payload, auth=auth, timeout=15,
)
if antwort.status_code < 300:
return f"Kodi-Bibliothek aktualisiert ({url})"
return f"Kodi-Refresh: HTTP {antwort.status_code} von {url}"
except requests.RequestException as e:
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
return ""
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
+1
View File
@@ -3,3 +3,4 @@ redis==5.0.4
requests==2.32.3
sqlalchemy==2.0.32
psycopg2-binary==2.9.9
flet==0.23.2
+340 -29
View File
@@ -17,6 +17,8 @@ import shutil
import subprocess
import tempfile
from winlauf import OHNE_FENSTER
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
@@ -25,34 +27,109 @@ class RipAbbruch(Exception):
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
# 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 wirf_disc_aus(device_path: str) -> bool:
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
def _auswurf_geglueckt(status: int) -> bool:
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
Befund 25.07.2026: Die Einstellung Automatischer Auswurf" (Standard: ein,
Beschreibung Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
von niemandem gelesen bei DVD/Blu-ray warf Rippy nie aus.
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
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:
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
# 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(fd, CDROMEJECT, 0)
finally:
os.close(fd)
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:
@@ -134,13 +211,119 @@ def parse_titel_info(ausgabe: str) -> list:
return [titel[nr] for nr in sorted(titel)]
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
# der Rippy-VM abgenommen (AGENTS Regel D):
#
# SINFO:0,1,1,6202,"Audio"
# SINFO:0,1,3,0,"deu"
# SINFO:0,1,4,0,"German"
# SINFO:0,1,6,0,"DTS-HD MA"
# SINFO:0,1,14,0,"2"
# SINFO:0,1,30,0,"DTS-HD MA Stereo German"
#
# ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29. Die tragen auf JEDEM Stream
# „eng"/„English", auch auf dem Videostream einer deutschen Tonspur; das ist
# MakeMKVs eigene Anzeigesprache und nicht die des Tracks. Wer 28 nimmt, hält
# jede Disc für englisch.
SINFO_TYP = 1
SINFO_SPRACHE_CODE = 3
SINFO_SPRACHE_NAME = 4
SINFO_CODEC = 6
SINFO_KANAELE = 14
SINFO_BESCHREIBUNG = 30
SINFO_ZEILE = re.compile(r'SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$')
def parse_stream_info(ausgabe: str) -> dict:
"""SINFO-Zeilen → Tonspuren und Untertitel je Titel (pure Funktion).
Commander-Anforderung 26.07.2026: *Es muss verfügbar sein, dass man die
Sprache von Medien auswählen kann Rippy muss VOR dem Rip fragen: Was genau
willst du haben?"* Die Auskunft liegt längst vor — der Titel-Scan liest sie
mit und hat sie bisher weggeworfen.
Rückgabe: {titel_nr: {"audio": [...], "untertitel": [...]}}, jeder Eintrag
{"nr", "lang", "sprache", "codec", "kanaele", "beschreibung"}.
"""
titel: dict = {}
for zeile in (ausgabe or "").splitlines():
treffer = SINFO_ZEILE.match(zeile.strip())
if not treffer:
continue
t_nr, s_nr, attr = (int(treffer.group(i)) for i in (1, 2, 3))
wert = treffer.group(4)
streams = titel.setdefault(t_nr, {})
eintrag = streams.setdefault(s_nr, {"nr": s_nr, "typ": "", "lang": "",
"sprache": "", "codec": "",
"kanaele": "", "beschreibung": ""})
if attr == SINFO_TYP:
eintrag["typ"] = wert
elif attr == SINFO_SPRACHE_CODE:
eintrag["lang"] = wert
elif attr == SINFO_SPRACHE_NAME:
eintrag["sprache"] = wert
elif attr == SINFO_CODEC:
eintrag["codec"] = wert
elif attr == SINFO_KANAELE:
eintrag["kanaele"] = wert
elif attr == SINFO_BESCHREIBUNG:
eintrag["beschreibung"] = wert
ergebnis = {}
for t_nr, streams in titel.items():
audio, untertitel = [], []
for s_nr in sorted(streams):
s = streams[s_nr]
# MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
if s["typ"] == "Audio":
audio.append(s)
elif s["typ"].startswith("Subtitle"):
untertitel.append(s)
ergebnis[t_nr] = {"audio": audio, "untertitel": untertitel}
return ergebnis
def sprachen_zusammenfassen(streams_je_titel: dict) -> dict:
"""Welche Sprachen hat die DISC insgesamt? (pure Funktion)
Für die Frage vor dem Rip zählt nicht Titel 3 Stream 7, sondern: Diese Disc
hat Ton auf Deutsch, Englisch und Japanisch, Untertitel auf Deutsch und
Englisch." Deshalb über alle Titel vereinigt, Reihenfolge stabil (so wie sie
auf der Disc stehen).
Rückgabe: {"audio": [{"lang","sprache","spuren"}], "untertitel": [...]}
"""
def sammeln(schluessel):
gefunden = {}
for streams in (streams_je_titel or {}).values():
for s in streams.get(schluessel) or []:
code = s.get("lang") or ""
if not code:
continue
eintrag = gefunden.setdefault(
code, {"lang": code, "sprache": s.get("sprache") or code,
"spuren": 0})
eintrag["spuren"] += 1
return list(gefunden.values())
return {"audio": sammeln("audio"), "untertitel": sammeln("untertitel")}
def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
"""(Titel-Liste, Streams je Titel) der eingelegten Disc — EIN Info-Lauf.
Beides aus demselben Aufruf: Ein `makemkvcon info` dauert je Disc 20120 s,
und die Sprach-Auskunft steht in derselben Ausgabe. Sie zweimal zu holen
wäre eine Minute Wartezeit für nichts.
"""
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
)
return parse_titel_info(ergebnis.stdout or "")
ausgabe = ergebnis.stdout or ""
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
@@ -165,19 +348,52 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
def laengster_titel(dauern: dict):
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
def laengster_titel(dauern: dict, meta: dict = None):
"""Titel-Nummer für den Hauptfilm (pure Funktion).
Mit meta['runtime'] wird heuristisch der Titel gesucht, dessen Laufzeit am nächsten
an der TMDB-Laufzeit liegt (vermeidet 'Play All'-Tracks, die länger als der Film sind).
Ohne runtime wird klassisch der längste Titel gewählt.
"""
if not dauern:
return None
ziel_s = (meta.get("runtime") or 0) * 60 if meta else 0
if ziel_s > 0:
bester = min(dauern, key=lambda t: abs(dauern[t] - ziel_s))
if abs(dauern[bester] - ziel_s) <= 20 * 60:
return bester
return max(dauern, key=dauern.get)
def episoden_titel(dauern: dict, meta: dict) -> list:
"""Findet Episoden-Titel anhand der TMDB-Episoden-Laufzeiten.
Toleranz: +/- 10 Minuten (600 Sekunden) zum Durchschnitt.
Gibt eine sortierte Liste der gefundenen Titelnummern zurück.
"""
if not dauern or not meta or not meta.get("episode_runtimes"):
return []
runtimes_s = [r * 60 for r in meta["episode_runtimes"]]
avg_runtime = sum(runtimes_s) / len(runtimes_s)
gefundene_titel = []
for titel, dauer in dauern.items():
if abs(dauer - avg_runtime) <= 10 * 60:
gefundene_titel.append(int(titel))
return sorted(gefundene_titel)
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try:
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return {}
@@ -206,6 +422,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
ergebnis = subprocess.run(
["HandBrakeCLI", "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return 0
@@ -318,22 +535,50 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
def build_handbrake_cmd(input_path: str, output_path: str,
preset: str = DEFAULT_HB_PRESET,
audio_sprachen=None, untertitel_sprachen=None) -> list:
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip nie auf dem Laufwerk:
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
## Die Sprachauswahl greift HIER, nicht beim Rippen
Commander-Anforderung 26.07.2026. Bewusst in der KOMPRESSION und nicht im
Rip, aus drei Gründen:
1. Der Rip bleibt vollständig und verlustfrei das ist ein Muss-Feature
(KONZEPT). Wer später eine andere Sprachwahl will, komprimiert neu, statt
die Disc wieder einzulegen.
2. MakeMKV kann Tracks nur über eine Auswahl-Profildatei filtern; HandBrake
hat dafür dokumentierte Schalter, die genau die Codes nehmen, die MakeMKV
liefert (ISO 639-2, deu"/„eng" beides am 26.07.2026 gegengeprüft).
3. Wird ein Disc-Typ NICHT komprimiert (4K verlustfrei), bleiben ohnehin alle
Sprachen drin und das ist bei einer verlustfreien Ablage richtig.
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
"""
return [
befehl = [
"HandBrakeCLI",
"--input", input_path,
"--output", output_path,
"--preset", preset,
"--all-audio",
"--all-subtitles",
]
audio = [s for s in (audio_sprachen or []) if s]
if audio:
befehl += ["--audio-lang-list", ",".join(audio)]
befehl.append("--first-audio")
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
untertitel = [s for s in (untertitel_sprachen or []) if s]
if untertitel:
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
befehl.append("--all-subtitles")
return befehl
HB_ENCODE_RE = re.compile(
@@ -374,8 +619,28 @@ def get_progress_from_line(line: str) -> int:
return int(((aktuell - 1) * 100 + prozent) / gesamt)
def sprachliste(wert) -> list:
"""„deu, eng , ,DEU" → ["deu", "eng"] (pure Funktion).
Nimmt Komma-Text aus den Einstellungen oder eine fertige Liste. Doppelte und
Leeres fliegen raus, Kleinschreibung wird erzwungen HandBrake will
ISO-639-2 in Kleinbuchstaben, und ein DEU" aus einem Eingabefeld soll nicht
still die ganze Auswahl unwirksam machen.
"""
if not wert:
return []
teile = wert.split(",") if isinstance(wert, str) else list(wert)
sauber = []
for t in teile:
code = str(t).strip().lower()
if code and code not in sauber:
sauber.append(code)
return sauber
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
progress_cb=None, abbruch_cb=None) -> dict:
progress_cb=None, abbruch_cb=None,
audio_sprachen=None, untertitel_sprachen=None) -> dict:
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
@@ -392,20 +657,43 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
try:
process = subprocess.Popen(
build_handbrake_cmd(input_path, output_path, preset),
build_handbrake_cmd(input_path, output_path, preset,
audio_sprachen, untertitel_sprachen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
bufsize=1,
creationflags=OHNE_FENSTER,
)
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e:
return {"status": "error", "error": str(e)}
def unbekanntes_preset(zeile: str) -> str:
"""Meldet HandBrake diese Zeile ein UNBEKANNTES Preset? Name oder "".
Wortlaut aus dem echten Lauf im Worker-Image (26.07.2026, AGENTS Regel D):
$ HandBrakeCLI --preset 'Gibt Es Nicht 999p'
Invalid preset Gibt Es Nicht 999p
Valid presets are:
Ohne diese Übersetzung stand im UI nur HandBrake endete mit Code 3"
und die eigentliche Ursache (ein Preset-Name, den DIESES HandBrake nicht
kennt) war daraus nicht zu erraten. Genau dieser Fall wird häufiger, weil
die Namen je HandBrake-Version verschieden sind und ein Remote-Worker eine
andere Version haben kann als die Rippy-VM.
"""
text = (zeile or "").strip()
kopf = "Invalid preset "
return text[len(kopf):].strip() if text.startswith(kopf) else ""
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
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
falsches_preset = ""
try:
for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
@@ -413,6 +701,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
# sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb:
abbruch_cb()
if not falsches_preset:
falsches_preset = unbekanntes_preset(line)
progress = get_progress_from_line(line)
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
@@ -428,6 +718,18 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path}
if falsches_preset:
return {
"status": "error",
"error": (
f'Das HandBrake dieses Workers kennt das Preset „{falsches_preset}" '
'nicht. Preset-Namen unterscheiden sich zwischen '
'HandBrake-Versionen. Abhilfe: Einstellungen → Verarbeitung, dort '
'auf „Bestes wählen" klicken — die Liste dort kommt vom Worker '
'selbst und enthält nur Namen, die es wirklich gibt.'
),
"return_code": process.returncode,
}
return {
"status": "error",
"error": f"HandBrake endete mit Code {process.returncode}",
@@ -490,7 +792,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
bufsize=1,
creationflags=OHNE_FENSTER,
)
letzte_meldung = ""
@@ -561,15 +864,14 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
output_dir: str = None, nur_hauptfilm: bool = False,
titel_liste: list = None, log_cb=None) -> dict:
titel_liste: list = None, log_cb=None, meta: dict = None) -> dict:
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen.
"""
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
@@ -578,10 +880,18 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
os.makedirs(output_dir, exist_ok=True)
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
# Serien-Erkennung: Wenn keine Titel explizit gewählt wurden und es eine Serie ist
if meta and meta.get("series") and meta.get("episode_runtimes"):
dauern = lies_titel_dauern(device_path)
episoden = episoden_titel(dauern, meta)
if episoden:
os.makedirs(output_dir, exist_ok=True)
return rip_titel_auswahl(device_path, output_dir, episoden, progress_cb, log_cb)
titel = "all"
if nur_hauptfilm:
dauern = lies_titel_dauern(device_path)
haupt = laengster_titel(dauern)
haupt = laengster_titel(dauern, meta)
if haupt is not None:
titel = str(haupt)
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
@@ -620,7 +930,8 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
bufsize=1,
creationflags=OHNE_FENSTER,
)
try:
+255
View File
@@ -0,0 +1,255 @@
"""Schlüssel-Automatik für 4K-UHD — der Windows-PC holt, was Linux nie holt.
## Das Problem, das das hier löst
Belegt am 25.07.2026 auf BEIDEN Maschinen: `makemkvcon` unter **Linux** ruft
Disc-Schlüssel NIE ab kein einziger Verbindungsversuch, geprüft mit leerem und
gefülltem Speicher, mit und ohne `--noscan`. Die **Windows**-Version tut es
(Meldung 3338). Deshalb scheitert jede unbekannte UHD-Disc auf der Rippy-VM mit
The volume key is unknown", und der Weg, der funktioniert, war bisher
Handarbeit: Laufwerk an den Windows-PC, Disc öffnen, `_private_data.tar` suchen,
im UI hochladen.
Das macht diese Datei automatisch.
## Zwei Schichten, absichtlich getrennt
**1. Der Wächter (verlässlich).** Er sieht `_private_data.tar` nach und lädt sie
zu Rippy hoch, sobald sie sich geändert hat. Er braucht keine
Laufwerkserkennung, kein Disc-Öffnen, nichts geraten wenn MakeMKV neue
Schlüssel gelernt hat, wandern sie rüber. Das deckt auch den Fall ab, dass der
Nutzer die Disc einfach in der MakeMKV-Oberfläche öffnet.
**2. Das Anstoßen (nach bestem Wissen).** Liegt eine Disc im Laufwerk dieses PCs,
wird `makemkvcon info` darauf losgelassen dabei holt MakeMKV den Schlüssel.
Warum die Trennung: Das Format der belegten `DRV:`-Zeile konnte auf dem
Commander-PC nicht gemessen werden (dort steckt kein optisches Laufwerk, alle 16
Plätze melden `DRV:i,256,999,0,"","",""` gemessen). Geraten wird deshalb nur in
Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach nichts
Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im verlässlichen Teil.
## Gemessene Fundstellen (AGENTS Regel D)
- Datenverzeichnis unter Windows: `%USERPROFILE%\\.MakeMKV` dort lag die echte
`_private_data.tar` (6.420.480 Bytes). NICHT `%APPDATA%\\MakeMKV`, wie man
vermuten würde.
- Programm: `C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe` (v1.18.4).
- Laufwerksliste: `makemkvcon -r --cache=1 info disc:9999` gibt `DRV:`-Zeilen.
- Hochladen: `POST /api/system/keystore` mit ROHEM Körper (kein JSON, kein
Multipart die API hat kein python-multipart).
"""
import os
import re
import subprocess
import urllib.request
from winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
# Wie oft nachgesehen wird. Eine Minute reicht: Discs wechseln nicht im
# Sekundentakt, und ein `makemkvcon info` belastet das Laufwerk.
TAKT_SEKUNDEN = 60
def daten_verzeichnis() -> str:
"""MakeMKVs Datenverzeichnis auf DIESEM Rechner (leer, wenn nicht gefunden).
`%USERPROFILE%\\.MakeMKV` ist der gemessene Ort (26.07.2026 auf dem
Commander-PC, MakeMKV 1.18.4). Die beiden anderen Kandidaten stehen als
Rückfall drin, weil MakeMKV über die Versionen umgezogen ist geprüft wird,
welcher wirklich existiert, statt einen zu behaupten.
"""
profil = os.getenv("USERPROFILE") or os.path.expanduser("~")
kandidaten = [
os.path.join(profil, ".MakeMKV"),
os.path.join(os.getenv("APPDATA") or "", "MakeMKV"),
os.path.join(os.getenv("LOCALAPPDATA") or "", "MakeMKV"),
]
for pfad in kandidaten:
if pfad and os.path.isdir(pfad):
return pfad
return ""
def schluesseldatei() -> str:
"""Voller Pfad zu `_private_data.tar` (leer, wenn es sie nicht gibt)."""
ordner = daten_verzeichnis()
if not ordner:
return ""
datei = os.path.join(ordner, "_private_data.tar")
return datei if os.path.isfile(datei) else ""
def makemkvcon_pfad() -> str:
"""makemkvcon auf dieser Maschine (leer, wenn MakeMKV nicht installiert ist).
Die 64-Bit-Variante zuerst auf dem Commander-PC liegen beide, und die
32-Bit-Version ist nur noch Beiwerk.
"""
import shutil
fest = [
r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe",
r"C:\Program Files (x86)\MakeMKV\makemkvcon.exe",
r"C:\Program Files\MakeMKV\makemkvcon64.exe",
r"C:\Program Files\MakeMKV\makemkvcon.exe",
]
for pfad in fest:
if os.path.isfile(pfad):
return pfad
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
def parse_laufwerke(ausgabe: str) -> list:
"""`DRV:`-Zeilen → Laufwerke mit Disc (pure Funktion).
Rückgabe: [{"index", "laufwerk", "disc", "geraet"}] NUR Einträge, bei denen
ein Disc-Name steht. Ein leerer Platz sieht so aus (gemessen):
DRV:0,256,999,0,"","",""
Die BELEGTE Form ist nicht gemessen auf dem Commander-PC steckt kein
optisches Laufwerk. Deshalb ist die Regel bewusst konservativ: ohne
Disc-Namen gilt nichts da", und dann tut die Automatik einfach nichts.
Falsch-negativ ist hier harmlos (der Wächter greift trotzdem),
falsch-positiv wäre ein `makemkvcon`-Lauf ins Leere.
"""
gefunden = []
for zeile in (ausgabe or "").splitlines():
treffer = DRV_ZEILE.match(zeile.strip())
if not treffer:
continue
index, _zustand, _flags, _x, laufwerk, disc, geraet = treffer.groups()
if not disc.strip():
continue
gefunden.append({
"index": int(index),
"laufwerk": laufwerk,
"disc": disc,
"geraet": geraet,
})
return gefunden
def datei_stand(pfad: str) -> tuple:
"""(Größe, Änderungszeit) — die Kennung, an der eine Änderung auffällt."""
try:
s = os.stat(pfad)
return (s.st_size, int(s.st_mtime))
except OSError:
return (0, 0)
def hat_sich_geaendert(vorher: tuple, jetzt: tuple) -> bool:
"""Lohnt ein Upload? (pure Funktion)
Nur wenn die Datei EXISTIERT und sich unterscheidet. Beim allerersten Lauf
(vorher = None) wird ebenfalls hochgeladen: Rippy soll den Bestand kennen,
auch wenn MakeMKV gerade nichts Neues gelernt hat.
"""
if jetzt == (0, 0):
return False
return vorher is None or vorher != jetzt
def laufwerke_lesen(programm: str, laufen=None, timeout: int = 120) -> list:
"""Welche Laufwerke dieses PCs haben eine Disc? (leer bei jedem Fehler)"""
if not programm:
return []
starten = laufen or subprocess.run
try:
ergebnis = starten(
[programm, "-r", "--cache=1", "info", "disc:9999"],
capture_output=True, text=True, timeout=timeout,
errors="replace", creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return []
return parse_laufwerke(ergebnis.stdout or "")
def disc_oeffnen(programm: str, index: int, laufen=None, timeout: int = 600) -> bool:
"""Lässt MakeMKV die Disc lesen — dabei holt es unter Windows den Schlüssel.
Der Rückgabewert sagt nur, ob der Aufruf durchlief. Ob ein Schlüssel dabei
herauskam, entscheidet allein die Datei deshalb wird danach ihr Stand
verglichen und nicht diese Antwort geglaubt.
"""
if not programm:
return False
starten = laufen or subprocess.run
try:
starten(
[programm, "-r", "--noscan", "info", f"disc:{index}"],
capture_output=True, text=True, timeout=timeout, errors="replace",
creationflags=OHNE_FENSTER,
)
return True
except (OSError, subprocess.TimeoutExpired):
return False
def hochladen(host: str, datei: str, oeffner=None, timeout: int = 120) -> str:
"""`_private_data.tar` zu Rippy schicken. "" = geklappt, sonst der Fehler.
ROHER Körper, kein JSON und kein Multipart genau so nimmt
`POST /system/keystore` die Datei an (der API fehlt python-multipart).
"""
if not host:
return "keine Rippy-Adresse gesetzt"
if not datei:
return "keine Schlüsseldatei gefunden"
try:
with open(datei, "rb") as f:
inhalt = f.read()
except OSError as e:
return f"Schlüsseldatei nicht lesbar: {e}"
anfrage = urllib.request.Request(
f"http://{host}/api/system/keystore", data=inhalt, method="POST",
headers={"Content-Type": "application/octet-stream"},
)
macher = oeffner or urllib.request.urlopen
try:
with macher(anfrage, timeout=timeout) as antwort:
antwort.read()
return ""
except Exception as e:
return f"Hochladen fehlgeschlagen: {e}"
def runde(host: str, letzter_stand, melden=None, programm=None,
laufen=None, oeffner=None) -> tuple:
"""Ein Durchlauf der Automatik. Rückgabe: (neuer_stand, was_passiert_ist).
Reihenfolge mit Absicht: ERST anstoßen (falls eine Disc liegt), DANN die
Datei vergleichen. So wird ein gerade geholter Schlüssel in derselben Runde
mitgenommen, statt eine Minute zu warten.
"""
sage = melden or (lambda level, text: None)
prog = programm if programm is not None else makemkvcon_pfad()
if not prog:
return letzter_stand, "kein-makemkv"
for laufwerk in laufwerke_lesen(prog, laufen=laufen):
sage("info", f"Disc erkannt: {laufwerk['disc']} — MakeMKV liest sie, "
"um den Disc-Schlüssel zu holen")
disc_oeffnen(prog, laufwerk["index"], laufen=laufen)
datei = schluesseldatei()
jetzt = datei_stand(datei) if datei else (0, 0)
if not hat_sich_geaendert(letzter_stand, jetzt):
return letzter_stand, "unveraendert"
fehler = hochladen(host, datei, oeffner=oeffner)
if fehler:
sage("warning", f"Schlüsselspeicher konnte nicht zu Rippy: {fehler}")
return letzter_stand, "fehler"
sage("success",
f"Schlüsselspeicher an Rippy übergeben ({jetzt[0]} Bytes) — "
"wirkt ab dem nächsten Rip")
return jetzt, "hochgeladen"
+118 -15
View File
@@ -47,11 +47,24 @@ from ripping import (
rip_cd,
rip_video,
run_handbrake,
sprachen_zusammenfassen,
sprachliste,
wirf_disc_aus,
)
API_URL = os.getenv("API_URL", "http://api:8000")
# Phasen-Marke in den Job-Metadaten: war der RIP fertig, als es schiefging?
#
# Sobald `status = "failed"` in der Zeile steht, ist die Phase sonst
# unwiederbringlich fort — und genau die entscheidet, was danach hilft: Nach
# einem toten Transcode genügt „Neu komprimieren"; nach einem toten Rip liegt
# nur ein Bruchstück da (Vorfall 26.07.2026: 5,1 GB von rund 40 GB) und es muss
# neu gerippt werden. Gelesen wird die Marke in api/phasen.py — der Name steht
# in beiden Dateien und wird von test_phasen.py gegeneinander geprüft, weil es
# kein geteiltes Paket zwischen den Containern gibt.
RIP_FERTIG = "rip_fertig"
def _transcode_queue(node: str):
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
@@ -471,6 +484,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return {"status": "error", "error": fehler, "disc_type": disc_type}
db.update_job(job_id, status="running", disc_type=disc_type)
# Ab hier gilt: der Rip läuft, ist aber NICHT fertig. Stirbt der Job jetzt,
# ist jede Roh-Datei ein Bruchstück (siehe RIP_FERTIG oben).
db.meta_merken(job_id, **{RIP_FERTIG: False})
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
letzter = [-1]
@@ -653,6 +669,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# freier Worker, inkl. Remote-GPU).
ziel_queue = _transcode_queue(meta.get("transcode_node"))
db.update_job(job_id, status="transcoding", progress=0)
# Der Rip ist durch. Ab jetzt ist ein Fehlschlag mit „Neu komprimieren"
# zu heilen, ohne die Disc noch einmal zu lesen.
db.meta_merken(job_id, **{RIP_FERTIG: True})
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
db.add_log("info", "worker",
f"Job {job_id}: Rip fertig, Kompression eingereiht"
@@ -690,9 +709,20 @@ def scan_tracks(device_path: str):
key = f"tracks:{device_path}"
db.save_settings({"status": "running"}, key)
try:
titel = lies_titel_info(device_path)
db.save_settings({"status": "done", "tracks": titel}, key)
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
# Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die
# Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur
# weggeworfen (Commander-Anforderung 26.07.2026: vor dem Rip fragen,
# was man haben will).
titel, streams = lies_titel_info(device_path)
sprachen = sprachen_zusammenfassen(streams)
db.save_settings(
{"status": "done", "tracks": titel, "sprachen": sprachen}, key)
db.add_log(
"info", "worker",
f"Titel-Scan {device_path}: {len(titel)} Titel, "
f"{len(sprachen['audio'])} Tonsprache(n), "
f"{len(sprachen['untertitel'])} Untertitelsprache(n)",
)
return {"status": "done", "anzahl": len(titel)}
except Exception as e:
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
@@ -705,6 +735,46 @@ def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
return container_pfad != lokal_pfad
# Wie weit wird nach einem vorhandenen Elternordner gesucht. Begrenzt, damit auf
# einer toten Freigabe nicht endlos geklopft wird.
MAX_ELTERN_STUFEN = 12
def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
"""Der nächste EXISTIERENDE Ordner oberhalb von `pfad` — oder "".
Vorfall 26.07.2026, im vollen Durchlauf aufgefallen: Der Rip lief sauber
durch (40 GB), die Kompression brach sofort ab mit Dieser Worker erreicht
das Ziel nicht: \\\\NAS\\rippy\\movies\\Akira (1988)". Erreichbar war die
Freigabe sehr wohl es fehlten schlicht ZWEI Ordner: `movies` und der
Filmordner darin. Die Prüfung ließ nur EINE fehlende Ebene durchgehen (sie
sah nach `dirname`), obwohl gleich darauf `os.makedirs` die ganze Kette
anlegt. Ein Ziel, das noch nie beschrieben wurde, war damit systematisch
unerreichbar also jeder erste Film in einer neuen Ablage.
Es zählt daher, ob IRGENDEIN Vorfahre existiert: Von dem aus kann
`makedirs` den Rest bauen. Existiert keiner, ist die Freigabe wirklich weg.
`isdir`/`dirname` werden BEIM AUFRUF aufgelöst, nicht als Vorgabewert
gebunden sonst zeigt die Vorgabe für immer auf die Funktion von damals,
und ein Ersetzen von `os.path.isdir` (im Test wie im Betrieb) ginge ins
Leere. Genau darüber bin ich beim Schreiben der Tests gestolpert.
"""
isdir = isdir or os.path.isdir
dirname = dirname or os.path.dirname
aktuell = dirname(pfad or "")
for _ in range(MAX_ELTERN_STUFEN):
if not aktuell:
return ""
if isdir(aktuell):
return aktuell
naechster = dirname(aktuell)
if naechster == aktuell: # Wurzel erreicht
return ""
aktuell = naechster
return ""
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
final_container: str, final_lokal: str) -> str:
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
@@ -731,17 +801,18 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
fremder_worker = not os.path.isdir("/app")
for zweck, container, lokal in (
("Quelle (Rohdaten)", raw_container, raw_lokal),
("Ziel (fertige Datei)", final_container, final_lokal),
("die Quelle (Rohdaten)", raw_container, raw_lokal),
("das Ziel (fertige Datei)", final_container, final_lokal),
):
if os.path.isdir(lokal):
continue
# Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird
# gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem.
if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."):
# Das ZIEL darf fehlen — es wird gleich mit `os.makedirs` angelegt, und
# zwar samt aller fehlenden Zwischenebenen. Es genügt also, dass
# irgendein Vorfahre existiert (Herleitung in erster_vorhandener_ordner).
if zweck.startswith("das Ziel") and erster_vorhandener_ordner(lokal):
continue
text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"]
text = [f"Dieser Worker erreicht {zweck} nicht: {lokal}"]
if fremder_worker and not mapping:
text.append(
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
@@ -754,12 +825,23 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
"start-tray.bat setzen, z. B. "
"set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
)
elif fremder_worker and mapping:
text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.")
if not _ist_uebersetzt(container, lokal):
elif fremder_worker and mapping and not _ist_uebersetzt(container, lokal):
text.append(
f'{container}" wurde von keinem Eintrag übersetzt — fehlt ein '
'Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber "
f'nicht ab: „{container}" wurde von keinem Eintrag übersetzt — '
'fehlt ein Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
)
elif fremder_worker and mapping:
# ⚠️ Hier stand bis zum 26.07.2026 dieselbe Meldung wie oben — „deckt
# diesen Pfad nicht ab" —, obwohl die Karte ihn nachweislich übersetzt
# hatte (der UNC-Pfad stand im selben Satz). Wer der Meldung folgte,
# suchte den Fehler in der Karte, während die Freigabe schlicht nicht
# verbunden war. Eine falsche Ursache ist teurer als gar keine.
text.append(
f'Übersetzt wurde er korrekt ({container}{lokal}, Karte: '
f'{mapping}) — dieser Pfad ist auf diesem Rechner nur gerade '
'nicht erreichbar. Ist die Freigabe verbunden? Anmeldedaten noch '
'gültig? Von Hand prüfen: den Pfad im Explorer öffnen.'
)
else:
text.append(
@@ -817,12 +899,31 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
preset = preset_fuer(disc_type, einstellungen)
original_behalten = einstellungen.get("keepOriginal", False)
# Sprachauswahl: Wahl für DIESEN Rip (aus meta) schlägt die Einstellung.
# Leer heißt „alles behalten" — genau das Verhalten von vorher.
try:
job_meta = json.loads(job.get("meta") or "{}")
except ValueError:
job_meta = {}
audio_sprachen = sprachliste(
job_meta.get("audio_sprachen") or einstellungen.get("audioSprachen"))
untertitel_sprachen = sprachliste(
job_meta.get("untertitel_sprachen") or einstellungen.get("untertitelSprachen"))
os.makedirs(final_dir, exist_ok=True)
db.update_job(job_id, status="transcoding", progress=0, error=None)
# Wer hier ankommt, hat vollständige Quelldateien — sonst wäre oben schon
# abgebrochen worden. Das vermerkt die Marke auch für BESTANDSJOBS, die vor
# ihrer Einführung gerippt wurden und sie darum noch nicht tragen.
db.meta_merken(job_id, **{RIP_FERTIG: True})
db.add_log(
"info", "worker",
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}'"
+ (f", Ton: {','.join(audio_sprachen)}" if audio_sprachen else ", Ton: alle")
+ (f", Untertitel: {','.join(untertitel_sprachen)}"
if untertitel_sprachen else ", Untertitel: alle")
+ ")",
)
anzahl = len(quellen)
@@ -865,6 +966,8 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
quelle, ziel, preset=preset,
progress_cb=datei_fortschritt,
abbruch_cb=abbruch_pruefen,
audio_sprachen=audio_sprachen,
untertitel_sprachen=untertitel_sprachen,
)
if hb.get("status") == "cancelled":
_job_abschliessen(job_id, hb)
+76
View File
@@ -138,3 +138,79 @@ def test_simd_stufe_der_rippy_vm_ist_sse4_2():
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
)
assert caps.beste_simd(flags_vm) == "sse4_2"
# --- Preset-Liste: die Namen, die HandBrake wirklich kennt -------------------
# Wörtlich aus `HandBrakeCLI --preset-list` im Worker-Image (26.07.2026,
# HandBrake 1.6.1) — gekürzt auf je zwei Presets pro Kategorie, Struktur und
# Einrückung unverändert. Die zwei Logzeilen oben stehen dort wirklich mit
# drin (HandBrake schreibt sie in denselben Strom) und müssen wegfallen.
HB_PRESETS_ECHT = """[10:26:05] Compile-time hardening features are enabled
[10:26:05] qsv: not available on this system
Cannot load libnvidia-encode.so.1
General/
Very Fast 2160p60 4K AV1
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
container.
Super HQ 1080p30 Surround
High quality H.264 video (up to 1080p30) and high bit rate AAC
surround audio, in an MP4 container.
Matroska/
H.265 MKV 2160p60 4K
H.265 video (up to 2160p60) and surround audio, in an MKV
container.
H.265 MKV 1080p30
H.265 video (up to 1080p30) and surround audio, in an MKV
container.
Hardware/
AV1 QSV 2160p 4K
AV1 video (up to 2160p) and surround audio, in an MP4 container.
H.265 VCN 2160p 4K
H.265 video (up to 2160p) and surround audio, in an MP4 container.
CLI Defaults/
CLI Default
"""
def test_preset_liste_kommt_aus_der_einrueckung():
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
assert presets == [
"Very Fast 2160p60 4K AV1",
"Super HQ 1080p30 Surround",
"H.265 MKV 2160p60 4K",
"H.265 MKV 1080p30",
"AV1 QSV 2160p 4K",
"H.265 VCN 2160p 4K",
"CLI Default",
]
def test_preset_liste_nimmt_keine_beschreibung_und_kein_log():
"""Beschreibungen (8 Leerzeichen), Kategorien (0) und HandBrakes eigene
Logzeilen (0) dürfen nicht als Preset-Namen durchgehen."""
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
assert not any(p.endswith("/") for p in presets)
assert not any("container" in p for p in presets)
assert not any(p.startswith("[") for p in presets)
assert "Cannot load libnvidia-encode.so.1" not in presets
def test_preset_liste_leer_bleibt_leer():
assert caps.parse_preset_liste("") == []
assert caps.parse_preset_liste(None) == []
def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
"""Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmöglich, die
Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
Hardware-Encoder läuft. Die Messung sagt das Gegenteil `--preset-list`
nennt sie, `--help` nennt nur die nutzbaren Encoder. Zwei Fragen, zwei
Quellen."""
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
assert "H.265 VCN 2160p 4K" in presets # AMD, aus der VM-Liste
assert "AV1 QSV 2160p 4K" in presets # Intel, aus der VM-Liste
# ... und dieselbe Maschine meldet trotzdem KEINEN Hardware-Encoder:
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
"cpu-x264", "cpu-x265", "cpu-av1",
]
+145
View File
@@ -0,0 +1,145 @@
"""Tests für die Erreichbarkeits-Prüfung vor der Kompression.
Diese Prüfung hatte KEINEN einzigen Test und genau deshalb ist sie am
26.07.2026 im ersten vollständigen Durchlauf umgefallen: Der Rip lief sauber
durch (40 GB Akira), die Kompression brach 0,2 s später ab mit Dieser Worker
erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
zwei noch nie angelegte Ordner.
"""
import tasks
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
def _ohne_schluss_trenner(p):
"""`\\\\NAS\\rippy\\` und `\\\\NAS\\rippy` sind derselbe Ordner.
Wichtig, weil `ntpath.dirname` für die UNC-Wurzel MIT abschließendem
Backslash zurückkommt. Das echte `os.path.isdir` stört das nicht eine
Attrappe, die stur vergleicht, hätte hier einen Fehler erfunden, den es
nicht gibt.
"""
return p.rstrip("\\/") or p
def _fake_isdir(vorhandene):
"""isdir-Ersatz, der nur die genannten Pfade kennt."""
bekannt = {_ohne_schluss_trenner(p) for p in vorhandene}
return lambda p: _ohne_schluss_trenner(p) in bekannt
def test_findet_den_naechsten_vorhandenen_ordner():
da = {"\\\\NAS\\rippy"}
assert tasks.erster_vorhandener_ordner(
"\\\\NAS\\rippy\\movies\\Akira (1988)",
isdir=_fake_isdir(da), dirname=lambda p: p.rsplit("\\", 1)[0] if "\\" in p.strip("\\") else "",
) == "\\\\NAS\\rippy"
def test_zwei_fehlende_ebenen_sind_in_ordnung():
"""DER Vorfall: `movies` UND der Filmordner fehlten beide.
`os.makedirs` legt die ganze Kette an es genügt also, dass irgendein
Vorfahre existiert. Die alte Prüfung sah nur nach `dirname` und erklärte
damit jeden ERSTEN Film in einer neuen Ablage für unerreichbar.
"""
da = {"/share"}
assert tasks.erster_vorhandener_ordner(
"/share/movies/Akira (1988)", isdir=_fake_isdir(da)) == "/share"
def test_gar_nichts_erreichbar_gibt_leer():
"""Ist die Freigabe wirklich weg, muss die Prüfung anschlagen."""
assert tasks.erster_vorhandener_ordner(
"/share/movies/Film", isdir=_fake_isdir(set())) == ""
def test_sucht_nicht_endlos_auf_einer_toten_freigabe():
"""Jeder Schritt kostet auf einer toten Freigabe einen Timeout."""
gefragt = []
def zaehlend(p):
gefragt.append(p)
return False
tiefer_pfad = "/" + "/".join(f"e{i}" for i in range(50))
assert tasks.erster_vorhandener_ordner(tiefer_pfad, isdir=zaehlend) == ""
assert len(gefragt) <= tasks.MAX_ELTERN_STUFEN
def test_leerer_pfad_stuerzt_nicht_ab():
assert tasks.erster_vorhandener_ordner("", isdir=_fake_isdir(set())) == ""
# --- Die Meldung: eine falsche Ursache ist teurer als gar keine -------------
def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
import ntpath
kennt = _fake_isdir(vorhandene)
monkeypatch.setattr(tasks.os.path, "isdir",
lambda p: kennt(p) or (im_container and p == "/app"))
monkeypatch.setattr(tasks.os.path, "dirname", ntpath.dirname)
monkeypatch.setenv("RIPPY_PATH_MAP", mapping)
return tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
)
def test_alles_da_meldet_nichts(monkeypatch):
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy\\movies\\Akira (1988)"},
"/app/media/rippy=\\\\NAS\\rippy") == ""
def test_ziel_fehlt_ganz_ist_kein_fehler(monkeypatch):
"""Der Vorfall vom 26.07.2026 — jetzt geht er durch."""
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy"},
"/app/media/rippy=\\\\NAS\\rippy") == ""
def test_fehlende_quelle_bleibt_ein_fehler(monkeypatch):
meldung = _pruefen(monkeypatch, {"\\\\NAS\\rippy"}, "/app/media/rippy=\\\\NAS\\rippy")
assert "Quelle" in meldung
assert "Rohdaten sind NICHT verloren" in meldung
def test_uebersetzter_aber_unerreichbarer_pfad_beschuldigt_nicht_die_karte(monkeypatch):
"""⚠️ Genau die Falschaussage aus dem Vorfall.
Die Meldung lautete RIPPY_PATH_MAP deckt diesen Pfad aber nicht ab",
während sie ihn im selben Satz korrekt übersetzt hatte. Wer dem folgte,
suchte in der Karte statt in der Freigabe.
"""
meldung = _pruefen(monkeypatch, set(), "/app/media/rippy=\\\\NAS\\rippy")
assert "deckt diesen Pfad aber nicht ab" not in meldung
assert "Übersetzt wurde er korrekt" in meldung
assert "Freigabe verbunden" in meldung
def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
"""Hier ist die Karte wirklich schuld — der Pfad blieb unverändert."""
monkeypatch.setattr(tasks.os.path, "isdir", lambda p: False)
monkeypatch.setenv("RIPPY_PATH_MAP", "/app/temp=Y:\\temp")
meldung = tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "/app/media/rippy/job1",
"/app/media/movies/X", "/app/media/movies/X",
)
assert "deckt diesen Pfad aber nicht ab" in meldung
assert "von keinem Eintrag übersetzt" in meldung
def test_ohne_mapping_nennt_die_wahre_ursache(monkeypatch):
meldung = _pruefen(monkeypatch, set(), "")
assert "nicht gesetzt" in meldung
assert "RIPPY_PATH_MAP" in meldung
def test_artikel_stimmt_in_beiden_meldungen(monkeypatch):
"""„erreicht die Ziel" stand wörtlich im Log des Commanders."""
quelle = _pruefen(monkeypatch, set(), "")
assert "erreicht die Quelle" in quelle
# Quelle da, Ziel unerreichbar (kein einziger Vorfahre) → Ziel-Meldung
ziel = _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1"},
"/app/media/rippy=\\\\NAS\\rippy")
assert "erreicht das Ziel" in ziel
+127
View File
@@ -0,0 +1,127 @@
"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr.
Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen
(26.07.2026, Celery 5.4.0) AGENTS Regel D. Erfundene Formate hätten hier
keinen Wert: Genau am geratenen Format scheitert so eine Brücke.
"""
import logbruecke
# Aus `docker compose logs worker`, unverändert.
ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0"
ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors"
ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone"
ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready."
ECHT_WARNUNG = (
"[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: "
"{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}"
)
ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)"
def test_ready_ist_die_meldung_auf_die_man_wartet():
"""Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will —
deshalb als Erfolg, nicht als Info im Grundrauschen."""
assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.")
def test_verbindung_kommt_durch():
level, text = logbruecke.einordnen(ECHT_CONNECTED)
assert level == "info"
assert "redis" in text
def test_warnungen_und_fehler_kommen_immer_durch():
level, text = logbruecke.einordnen(ECHT_WARNUNG)
assert level == "warning"
assert "Zombie-Erkennung" in text
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error"
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error"
def test_grundrauschen_wird_verworfen():
"""Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine
automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins."""
assert logbruecke.einordnen(ECHT_MINGLE) is None
assert logbruecke.einordnen(ECHT_ALLEIN) is None
assert logbruecke.einordnen(ECHT_BANNER) is None
assert logbruecke.einordnen("") is None
assert logbruecke.einordnen(None) is None
assert logbruecke.einordnen(
"[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None
def test_absturz_ohne_celery_klammer_kommt_durch():
"""Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was
passieren kann."""
assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error"
assert logbruecke.einordnen(
"ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error"
def test_quelle_wird_auf_32_zeichen_gekappt():
"""`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei
einem langen Worker-Namen und dann fehlt genau das Log, das man sucht."""
assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc"
lang = logbruecke.quelle_fuer("x" * 100)
assert len(lang) == 32
assert lang.startswith("w:")
assert logbruecke.quelle_fuer("") == "w:worker"
assert logbruecke.quelle_fuer(None) == "w:worker"
# --- Drosselung -------------------------------------------------------------
def _bruecke(uhr):
geschrieben = []
b = logbruecke.Bruecke(
"testpc",
schreiber=lambda level, quelle, text: geschrieben.append((level, text)),
jetzt=lambda: uhr[0],
)
return b, geschrieben
def test_drosselung_haelt_bei_30_zeilen_die_minute():
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for i in range(40):
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE
def test_naechste_minute_meldet_wieviel_verschluckt_wurde():
"""Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon
einmal eine Stunde gekostet hat."""
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for i in range(40):
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
uhr[0] = 61.0
b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler")
unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t]
assert len(unterdrueckt) == 1
assert "10 weitere" in unterdrueckt[0]
def test_kaputter_schreiber_bringt_den_worker_nicht_um():
"""Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der
Worker muss weiterarbeiten."""
def kaputt(level, quelle, text):
raise RuntimeError("Datenbank weg")
b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0)
assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False
def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel():
uhr = [0.0]
b, geschrieben = _bruecke(uhr)
for _ in range(100):
b.zeile(ECHT_MINGLE)
b.zeile(ECHT_READY)
assert geschrieben == [("success", "celery@ced31798928d ready.")]
+277 -1
View File
@@ -38,7 +38,8 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
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 "--preset" in cmd
assert "--all-audio" in cmd # alle Sprachen behalten
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
assert "--audio-codec" in cmd
assert "--all-subtitles" in cmd
@@ -317,3 +318,278 @@ def test_reservewert_kollidiert_mit_keinem_echten_preset():
assert PRESET_KEINE == "keine"
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
# --- Unbekanntes Preset: aus „Code 3" wird eine handelbare Meldung -----------
def test_unbekanntes_preset_wird_erkannt():
"""Wortlaut echt gemessen (26.07.2026, Worker-Image): HandBrake antwortet
Invalid preset <Name>" und listet danach die gültigen auf."""
from ripping import unbekanntes_preset
assert unbekanntes_preset("Invalid preset Gibt Es Nicht 999p") == "Gibt Es Nicht 999p"
assert unbekanntes_preset(" Invalid preset H.265 XYZ 4K ") == "H.265 XYZ 4K"
# Alles andere ist kein Preset-Fehler
assert unbekanntes_preset("Valid presets are:") == ""
assert unbekanntes_preset("Encoding: task 1 of 1, 5.00 %") == ""
assert unbekanntes_preset("") == ""
assert unbekanntes_preset(None) == ""
def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
"""Vorher stand im UI nur „HandBrake endete mit Code 3" — daraus war nicht
zu erraten, dass der Preset-NAME das Problem ist. Der Fall wird häufiger,
weil ein Remote-Worker eine andere HandBrake-Version haben kann."""
import ripping
class FakeProcess:
def __init__(self):
self.stdout = iter([
"Invalid preset H.265 VCN 2160p 4K\n",
"Valid presets are:\n",
])
self.returncode = 3
def kill(self):
pass
def wait(self):
return 3
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["status"] == "error"
assert "H.265 VCN 2160p 4K" in ergebnis["error"]
assert "Bestes wählen" in ergebnis["error"]
assert ergebnis["return_code"] == 3
def test_fehler_ohne_preset_problem_bleibt_der_alte():
import ripping
class FakeProcess:
def __init__(self):
self.stdout = iter(["irgendwas ganz anderes\n"])
self.returncode = 1
def kill(self):
pass
def wait(self):
return 1
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["error"] == "HandBrake endete mit Code 1"
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
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
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
"aufrufe": []}
def ioctl_fn(fd, befehl, arg=0):
zustand["aufrufe"].append(befehl)
if befehl == ripping.CDROM_LOCKDOOR:
if not kennt_lockdoor:
raise OSError("ioctl unbekannt")
zustand["verriegelt"] = bool(arg)
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
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
import ripping
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) -----------
#
# Woertlich aus `makemkvcon -r --noscan info dev:/dev/sr0` im Worker-Container
# der Rippy-VM, gekuerzt auf die Attribute, die Rippy liest (AGENTS Regel D).
SINFO_ECHT = """SINFO:0,0,1,6201,"Video"
SINFO:0,0,19,0,"1920x1080"
SINFO:0,0,28,0,"eng"
SINFO:0,0,29,0,"English"
SINFO:0,1,1,6202,"Audio"
SINFO:0,1,3,0,"deu"
SINFO:0,1,4,0,"German"
SINFO:0,1,6,0,"DTS-HD MA"
SINFO:0,1,14,0,"2"
SINFO:0,1,28,0,"eng"
SINFO:0,1,29,0,"English"
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
SINFO:0,2,1,6202,"Audio"
SINFO:0,2,3,0,"jpn"
SINFO:0,2,4,0,"Japanese"
SINFO:0,2,6,0,"DTS-HD MA"
SINFO:0,2,14,0,"6"
SINFO:0,2,30,0,"DTS-HD MA 5.1 Japanese"
SINFO:0,3,1,6203,"Subtitles"
SINFO:0,3,3,0,"deu"
SINFO:0,3,4,0,"German"
SINFO:0,3,6,0,"PGS"
SINFO:1,0,1,6202,"Audio"
SINFO:1,0,3,0,"eng"
SINFO:1,0,4,0,"English"
SINFO:1,0,6,0,"AC3"
"""
def test_streams_werden_nach_typ_getrennt():
from ripping import parse_stream_info
je_titel = parse_stream_info(SINFO_ECHT)
assert set(je_titel) == {0, 1}
assert [a["lang"] for a in je_titel[0]["audio"]] == ["deu", "jpn"]
assert [u["lang"] for u in je_titel[0]["untertitel"]] == ["deu"]
assert je_titel[0]["audio"][1]["kanaele"] == "6"
assert je_titel[0]["audio"][0]["codec"] == "DTS-HD MA"
def test_die_sprache_steht_in_attribut_3_nicht_28():
"""DIE Falle: 28/29 tragen auf JEDEM Stream "eng"/"English" - auch auf einer
deutschen Tonspur und auf dem Videostream. Das ist MakeMKVs Anzeigesprache.
Wer 28 nimmt, haelt jede Disc fuer englisch."""
from ripping import parse_stream_info
deutsche_spur = parse_stream_info(SINFO_ECHT)[0]["audio"][0]
assert deutsche_spur["lang"] == "deu"
assert deutsche_spur["sprache"] == "German"
def test_video_taucht_nicht_als_tonspur_auf():
from ripping import parse_stream_info
je_titel = parse_stream_info(SINFO_ECHT)
assert all(a["typ"] == "Audio" for a in je_titel[0]["audio"])
def test_sprachen_werden_ueber_alle_titel_vereinigt():
"""Vor dem Rip zaehlt nicht "Titel 3 Stream 7", sondern was die DISC hat."""
from ripping import parse_stream_info, sprachen_zusammenfassen
s = sprachen_zusammenfassen(parse_stream_info(SINFO_ECHT))
assert [a["lang"] for a in s["audio"]] == ["deu", "jpn", "eng"]
assert [a["sprache"] for a in s["audio"]] == ["German", "Japanese", "English"]
assert [u["lang"] for u in s["untertitel"]] == ["deu"]
# deu kommt in Titel 0 einmal vor
assert s["audio"][0]["spuren"] == 1
def test_leere_ausgabe_gibt_leere_sprachen():
from ripping import parse_stream_info, sprachen_zusammenfassen
assert parse_stream_info("") == {}
assert sprachen_zusammenfassen({}) == {"audio": [], "untertitel": []}
assert sprachen_zusammenfassen(None) == {"audio": [], "untertitel": []}
# --- Sprachliste aus den Einstellungen --------------------------------------
def test_sprachliste_raeumt_auf():
from ripping import sprachliste
assert sprachliste("deu, eng , ,DEU") == ["deu", "eng"]
assert sprachliste(["deu", "deu", "jpn"]) == ["deu", "jpn"]
assert sprachliste("") == []
assert sprachliste(None) == []
def test_grossschreibung_macht_die_auswahl_nicht_unwirksam():
"""HandBrake will ISO-639-2 klein. Ein "DEU" aus einem Eingabefeld darf nicht
still die ganze Auswahl aushebeln."""
from ripping import sprachliste
assert sprachliste("DEU,ENG") == ["deu", "eng"]
# --- Das HandBrake-Kommando -------------------------------------------------
def test_handbrake_kommando_ohne_auswahl_bleibt_wie_bisher():
from ripping import build_handbrake_cmd
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30")
assert "--audio-lang-list" not in cmd
assert "--subtitle-lang-list" not in cmd
assert "--first-audio" in cmd and "--all-subtitles" in cmd
def test_handbrake_kommando_mit_sprachauswahl():
"""--audio-lang-list zusammen mit --first-audio heisst laut HandBrake-Hilfe:
nur die erste (beste) Tonspur, die zu einer der Sprachen passen."""
from ripping import build_handbrake_cmd
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30",
audio_sprachen=["deu", "jpn"],
untertitel_sprachen=["deu"])
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
assert "--first-audio" in cmd and "--all-subtitles" in cmd
+194
View File
@@ -0,0 +1,194 @@
"""Tests der Schlüssel-Automatik — ohne MakeMKV, ohne Laufwerk, ohne Netz.
Die Testdaten sind gemessen (26.07.2026, Commander-PC, MakeMKV 1.18.4):
`makemkvcon -r --cache=1 info disc:9999` auf einer Maschine OHNE optisches
Laufwerk gibt sechzehn leere `DRV:`-Zeilen. Die BELEGTE Form ließ sich dort nicht
messen deshalb prüfen die Tests vor allem, dass die Automatik im Zweifel
NICHTS tut.
"""
import schluessel
# Wörtlich aus dem echten Lauf (gekürzt auf drei der sechzehn Zeilen).
DRV_LEER = """MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","Das Programm konnte keine verwendbaren optischen Laufwerke finden."
DRV:0,256,999,0,"","",""
DRV:1,256,999,0,"","",""
DRV:2,256,999,0,"","",""
MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","Das Öffnen der Disk schlug fehl "
TCOUNT:0
"""
# So sieht eine belegte Zeile AUS SICHT DES FORMATS aus — nicht gemessen,
# deshalb steht sie nur hier im Test und nicht als Annahme im Code.
DRV_MIT_DISC = """DRV:0,2,999,12,"BD-RE HL-DT-ST BU40N","AKIRA","D:"
DRV:1,256,999,0,"","",""
"""
def test_leere_laufwerke_ergeben_nichts():
"""Der gemessene Fall: kein optisches Laufwerk → die Automatik tut nichts."""
assert schluessel.parse_laufwerke(DRV_LEER) == []
def test_kein_disc_name_gilt_als_nichts_da():
"""Konservativ mit Absicht: Ein falsch-negativ ist harmlos (der Wächter
greift trotzdem), ein falsch-positiv wäre ein makemkvcon-Lauf ins Leere."""
assert schluessel.parse_laufwerke('DRV:0,2,999,12,"BD-RE BU40N","",""') == []
def test_belegtes_laufwerk_wird_gefunden():
gefunden = schluessel.parse_laufwerke(DRV_MIT_DISC)
assert len(gefunden) == 1
assert gefunden[0]["index"] == 0
assert gefunden[0]["disc"] == "AKIRA"
assert gefunden[0]["geraet"] == "D:"
def test_muell_und_leer_stuerzen_nicht_ab():
assert schluessel.parse_laufwerke("") == []
assert schluessel.parse_laufwerke(None) == []
assert schluessel.parse_laufwerke("irgendwas ganz anderes") == []
# --- Wann hochgeladen wird --------------------------------------------------
def test_erste_runde_laedt_immer_hoch():
"""Rippy soll den Bestand kennen, auch wenn MakeMKV gerade nichts Neues
gelernt hat."""
assert schluessel.hat_sich_geaendert(None, (6420480, 1753000000)) is True
def test_unveraenderte_datei_wird_nicht_wieder_geschickt():
stand = (6420480, 1753000000)
assert schluessel.hat_sich_geaendert(stand, stand) is False
def test_geaenderte_datei_wird_geschickt():
assert schluessel.hat_sich_geaendert(
(6420480, 1753000000), (6500000, 1753009999)) is True
def test_fehlende_datei_loest_keinen_upload_aus():
"""(0, 0) heißt „gibt es nicht" — dann gibt es auch nichts zu schicken."""
assert schluessel.hat_sich_geaendert(None, (0, 0)) is False
assert schluessel.hat_sich_geaendert((6420480, 1753000000), (0, 0)) is False
# --- Der Upload -------------------------------------------------------------
def test_upload_schickt_den_rohen_koerper(tmp_path):
"""Kein JSON, kein Multipart: genau so nimmt POST /system/keystore die Datei
an (der API fehlt python-multipart)."""
datei = tmp_path / "_private_data.tar"
datei.write_bytes(b"TAR-INHALT")
gesehen = {}
class Antwort:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return b""
def oeffner(anfrage, timeout=None):
gesehen["url"] = anfrage.full_url
gesehen["daten"] = anfrage.data
gesehen["typ"] = anfrage.headers.get("Content-type")
return Antwort()
assert schluessel.hochladen("1.2.3.4", str(datei), oeffner=oeffner) == ""
assert gesehen["url"] == "http://1.2.3.4/api/system/keystore"
assert gesehen["daten"] == b"TAR-INHALT"
assert gesehen["typ"] == "application/octet-stream"
def test_upload_ohne_adresse_oder_datei_meldet_klartext():
assert "Adresse" in schluessel.hochladen("", "/x.tar")
assert "Schlüsseldatei" in schluessel.hochladen("1.2.3.4", "")
def test_upload_fehler_wird_gemeldet_nicht_geworfen(tmp_path):
"""Der Worker muss weiterarbeiten, auch wenn Rippy gerade weg ist."""
datei = tmp_path / "_private_data.tar"
datei.write_bytes(b"x")
def kaputt(anfrage, timeout=None):
raise OSError("Netz weg")
fehler = schluessel.hochladen("1.2.3.4", str(datei), oeffner=kaputt)
assert "Hochladen fehlgeschlagen" in fehler
# --- Eine ganze Runde -------------------------------------------------------
def test_runde_ohne_makemkv_tut_nichts():
"""Auf einem PC ohne MakeMKV darf die Automatik nicht herumfummeln."""
stand, was = schluessel.runde("1.2.3.4", None, programm="")
assert (stand, was) == (None, "kein-makemkv")
def test_runde_stoesst_erst_an_und_prueft_dann(monkeypatch, tmp_path):
"""Reihenfolge mit Absicht: Ein gerade geholter Schluessel soll in DERSELBEN
Runde mitgenommen werden, nicht erst eine Minute spaeter."""
datei = tmp_path / "_private_data.tar"
datei.write_bytes(b"neu")
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
ablauf = []
def laufen(cmd, **kwargs):
# disc:9999 = Laufwerke auflisten, disc:0 = Disc oeffnen
ablauf.append(cmd[-1])
class E:
stdout = DRV_MIT_DISC if cmd[-1] == "disc:9999" else ""
return E()
class Antwort:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return b""
meldungen = []
stand, was = schluessel.runde(
"1.2.3.4", None, melden=lambda lvl, t: meldungen.append((lvl, t)),
programm="makemkvcon.exe", laufen=laufen,
oeffner=lambda a, timeout=None: Antwort())
assert ablauf == ["disc:9999", "disc:0"]
assert was == "hochgeladen"
assert stand == schluessel.datei_stand(str(datei))
assert any("Disc erkannt" in t for _, t in meldungen)
assert any("wirkt ab dem nächsten Rip" in t for _, t in meldungen)
def test_runde_ohne_aenderung_schickt_nichts(monkeypatch, tmp_path):
datei = tmp_path / "_private_data.tar"
datei.write_bytes(b"alt")
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
vorher = schluessel.datei_stand(str(datei))
def laufen(cmd, **kwargs):
class E:
stdout = DRV_LEER
return E()
def darf_nicht(anfrage, timeout=None):
raise AssertionError("kein Upload erwartet")
stand, was = schluessel.runde(
"1.2.3.4", vorher, programm="makemkvcon.exe", laufen=laufen,
oeffner=darf_nicht)
assert (stand, was) == (vorher, "unveraendert")
+126
View File
@@ -0,0 +1,126 @@
"""Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche.
Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster
ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import verwaltung
# --- Was Rippy über diesen Worker weiß --------------------------------------
CAPS = {"workers": [
{"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
"info": {"cpu_kerne": "4"}},
{"name": "tobisnicerpc", "online": False, "encoders": ["cpu-x265", "vce"],
"info": {"cpu_modell": "AMD Ryzen 7 9700X", "cpu_kerne": "16",
"cpu_simd": "avx512f"}},
]}
def test_zustand_findet_den_eigenen_worker():
z = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
assert z["bekannt"] is True
assert z["online"] is False
assert z["encoders"] == ["cpu-x265", "vce"]
assert z["info"]["cpu_simd"] == "avx512f"
def test_zustand_bei_unbekanntem_worker():
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
def test_zustand_ohne_antwort_von_rippy():
"""Rippy nicht erreichbar → das Fenster muss trotzdem etwas sagen können."""
assert verwaltung.worker_zustand(None, "x")["bekannt"] is False
assert verwaltung.worker_zustand({}, "x")["bekannt"] is False
# --- Die Statuszeile --------------------------------------------------------
def test_status_nennt_das_wirkliche_problem():
"""Drei Lagen, drei klare Sätze — nicht ein „unbekannt" für alles."""
unbekannt = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
text, farbe = verwaltung.status_text(unbekannt, None)
assert "kennt diesen Worker nicht" in text
assert farbe == verwaltung.ROSE
offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
text, farbe = verwaltung.status_text(offline, None)
assert "NICHT erreichbar" in text
assert farbe == verwaltung.ROSE
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
text, farbe = verwaltung.status_text(online, None)
assert "bereit" in text
assert farbe == verwaltung.EMERALD
def test_status_zeigt_den_laufenden_job():
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
job = {"title": "Akira", "status": "transcoding", "progress": 42,
"eta_text": "noch ca. 3 h 07 min"}
text, farbe = verwaltung.status_text(online, job)
assert "Akira" in text and "42 %" in text and "3 h 07 min" in text
assert farbe == verwaltung.AMBER
# --- Aufgabenliste ----------------------------------------------------------
def test_job_text_uebersetzt_die_status_woerter():
"""„transcoding" sagt einem Nicht-Entwickler nichts."""
assert verwaltung.job_text(
{"title": "Akira", "status": "transcoding", "progress": 7}
) == "Akira — komprimiert, 7 %"
assert verwaltung.job_text(
{"title": "Alien", "status": "completed"}) == "Alien — fertig"
assert verwaltung.job_text(
{"title": "Alien", "status": "failed"}) == "Alien — Fehler"
assert verwaltung.job_text(
{"title": "Alien", "status": "pending"}) == "Alien — wartet"
def test_job_text_ohne_titel_nimmt_die_kurze_id():
text = verwaltung.job_text(
{"id": "95afdc89-2426-4d44", "status": "failed"})
assert text == "95afdc89 — Fehler"
def test_job_text_haengt_keine_restzeit_an_fertige_jobs():
text = verwaltung.job_text(
{"title": "X", "status": "completed", "progress": 100,
"eta_text": "noch ca. 5 min"})
assert "5 min" not in text
assert "%" not in text
# --- Log-Ausschnitt ---------------------------------------------------------
def test_letzte_zeilen_schneidet_hinten_ab():
import io as _io
inhalt = "\n".join(f"Zeile {i}" for i in range(500))
zeilen = verwaltung.letzte_zeilen(
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"]
def test_letzte_zeilen_ohne_datei_ist_leer():
def kaputt(p):
raise OSError("keine Datei")
assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == []
def test_hole_ohne_adresse_fragt_nicht():
"""Ohne RIPPY_TRAY_HOST darf nichts versucht werden — sonst hängt das
Fenster beim Öffnen am Timeout."""
assert verwaltung.hole("/jobs", host="") is None
+51
View File
@@ -188,3 +188,54 @@ def test_fehler_reisst_den_worker_start_nicht_mit():
db = KaputteDb([])
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
assert "Postgres weg" in bericht["uebersprungen"]
# --- Die Luecke, die die Erkennung nutzlos machte (Befund 26.07.2026) --------
def test_running_gilt_als_arbeitsstatus():
"""DER Fehler: Die Erkennung suchte "ripping", der Rip setzt aber "running".
Ergebnis: Ein abgestuerzter RIP wurde NIE gefunden - und die Meldung
`{'geprueft': 0}` sah bei jedem Worker-Start wie Gesundheit aus."""
assert "running" in zombies.ARBEITS_STATI
assert "transcoding" in zombies.ARBEITS_STATI
assert "canceling" in zombies.ARBEITS_STATI
def test_abgestuerzter_rip_wird_gefunden():
"""Der konkrete Fall vom 26.07.2026: Job 2182d525 stand auf running/12 %,
kein makemkvcon lief, die Rohdatei wuchs nicht mehr - und die Erkennung
pruefte null Jobs."""
offene = [{"id": "2182d525", "status": "running", "title": "Akira"}]
assert zombies.finde_zombies(offene, set()) == offene
def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
"""Mechanische Sperre gegen genau dieses Auseinanderlaufen.
Liest tasks.py und sammelt jeden Status, den der Worker per db.update_job in
eine Job-ZEILE schreibt. Jeder davon muss entweder ein Arbeitsstatus sein
oder ein Endzustand - sonst gibt es wieder einen Zustand, den niemand
aufraeumt. Ein Kommentar haette das nicht verhindert; dieser Test schon.
"""
import os
import re
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
quelle = open(pfad, encoding="utf-8").read()
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
geschrieben = set()
for aufruf in re.finditer(r"db\.update_job\((?:[^()]|\([^()]*\))*\)", quelle):
for treffer in re.finditer(r'status\s*=\s*"([a-z]+)"', aufruf.group(0)):
geschrieben.add(treffer.group(1))
endzustaende = {"completed", "failed"}
unbeaufsichtigt = geschrieben - set(zombies.ARBEITS_STATI) - endzustaende
assert not unbeaufsichtigt, (
f"Diese Job-Status schreibt der Worker, aber niemand raeumt sie auf: "
f"{sorted(unbeaufsichtigt)}. Entweder in ARBEITS_STATI aufnehmen oder "
f"als Endzustand behandeln."
)
# Gegenprobe, dass der Test wirklich etwas gesehen hat
assert "running" in geschrieben
-148
View File
@@ -1,148 +0,0 @@
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, Beenden. Gestartet
wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft
pythonw.exe auf kein Konsolenfenster). Der Worker-Prozess (celery,
Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray.
Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
(install.ps1) das Docker-Image bleibt davon unberührt.
"""
import os
import subprocess
import sys
import webbrowser
try:
import pystray
from PIL import Image, ImageDraw
except ImportError:
print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.")
sys.exit(1)
BASIS = os.path.dirname(os.path.abspath(__file__))
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
def _log_pfad() -> str:
"""Wohin das Worker-Log geschrieben wird.
NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der
Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker`
installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log
neben dem Programm hätte den Worker beim Starten scheitern lassen genau
die Art Fehler, die man erst merkt, wenn nichts passiert.
Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für
veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das
Programmverzeichnis der Rückfall bei einer Installation ins Benutzerprofil
(frei wählbar) ist es ohnehin beschreibbar.
"""
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
if basis_daten:
ordner = os.path.join(basis_daten, "Rippy Worker")
try:
os.makedirs(ordner, exist_ok=True)
return os.path.join(ordner, "worker.log")
except OSError:
pass
return os.path.join(BASIS, "worker.log")
LOG_PFAD = _log_pfad()
prozess = None
def disc_bild(laeuft: bool) -> Image.Image:
"""Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst."""
bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
zeichner = ImageDraw.Draw(bild)
farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255)
zeichner.ellipse([4, 4, 60, 60], fill=farbe)
zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255))
return bild
def worker_laeuft() -> bool:
return prozess is not None and prozess.poll() is None
def worker_starten(icon=None, item=None):
global prozess
if worker_laeuft():
return
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
prozess = subprocess.Popen(
[celery, "-A", "celery_app", "worker", "--loglevel=info",
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if icon:
icon.icon = disc_bild(True)
icon.update_menu()
def worker_stoppen(icon=None, item=None):
global prozess
if worker_laeuft():
prozess.terminate()
try:
prozess.wait(15)
except subprocess.TimeoutExpired:
prozess.kill()
prozess = None
if icon:
icon.icon = disc_bild(False)
icon.update_menu()
def umschalten(icon, item):
if worker_laeuft():
worker_stoppen(icon)
else:
worker_starten(icon)
def rippy_oeffnen(icon, item):
if RIPPY_HOST:
webbrowser.open(f"http://{RIPPY_HOST}")
def log_anzeigen(icon, item):
if os.path.exists(LOG_PFAD):
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
def beenden(icon, item):
worker_stoppen()
icon.stop()
menue = pystray.Menu(
pystray.MenuItem(
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
None, enabled=False,
),
pystray.Menu.SEPARATOR,
pystray.MenuItem(
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
umschalten,
),
pystray.MenuItem("Rippy öffnen", rippy_oeffnen),
pystray.MenuItem("Log anzeigen", log_anzeigen),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
)
if __name__ == "__main__":
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
worker_starten()
tray.icon = disc_bild(worker_laeuft())
tray.run()
+149
View File
@@ -0,0 +1,149 @@
"""Kleines Verwaltungsfenster für den Windows-Worker (Doppelklick aufs Tray).
Commander-Wunsch 26.07.2026: *Es wäre cool wenn man einfach mit nem Doppelklick
ne kleine Verwaltung hat wo man direkt Logs, Aufgaben, usw. sieht."*
## Warum ein eigener PROZESS und kein Fenster im Tray
pystray belegt mit `icon.run()` den Haupt-Thread, und tkinter will seine
Ereignisschleife ebenfalls dort haben. Beides in einem Prozess zu verschränken
ist eine bekannte Quelle für Fenster, die sich nicht mehr schließen lassen. Als
eigener Prozess (`pythonw.exe verwaltung.py`) gibt es das Problem gar nicht: Das
Fenster erbt die Umgebung vom Tray (Rippy-Adresse, Worker-Name) und kann
abstürzen, ohne den Worker mitzunehmen.
## Was es anzeigt — und woher
Alles Fachliche kommt von RIPPY, nicht aus eigener Rechnung: Ob dieser Worker als
erreichbar gilt, steht in `/capabilities`; was gerade läuft und wie lange es noch
dauert, in `/jobs`. So zeigt das Fenster dieselben Zahlen wie das Dashboard statt
einer zweiten, abweichenden Wahrheit.
Nur das Log kommt lokal: Es ist genau dann die einzige Auskunft, wenn Rippy nicht
erreichbar ist.
tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist
der Installer soll keine weitere Abhängigkeit brauchen.
"""
import json
import os
import subprocess
import urllib.request
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
SLOTS = os.getenv("RIPPY_SLOTS", "1")
BASIS = os.path.dirname(os.path.abspath(__file__))
# Farben aus dem Rippy-UI (docker/ui/src/lib/design.ts), damit das Fenster nicht
# wie ein Fremdkörper wirkt.
BG = "#0f172a"
PANEL = "#020617"
TEXT = "#e2e8f0"
GEDAEMPFT = "#94a3b8"
AMBER = "#f59e0b"
EMERALD = "#10b981"
ROSE = "#f43f5e"
LOG_ZEILEN = 200
TAKT_MS = 4000
def letzte_zeilen(pfad: str, anzahl: int = LOG_ZEILEN, oeffnen=None) -> list:
"""Die letzten `anzahl` Zeilen einer Datei (pure genug für einen Test).
Liest bewusst die GANZE Datei und schneidet ab: Das Worker-Log wird nicht
groß (die Brücke drosselt auf 30 Zeilen/Minute), und ein Rückwärts-Suchen
über Blockgrenzen wäre mehr Code als Nutzen. Fehler beim Lesen geben eine
leere Liste ein Fenster, das wegen des Logs nicht aufgeht, wäre schlimmer.
"""
macher = oeffnen or (lambda p: open(p, encoding="utf-8", errors="replace"))
try:
with macher(pfad) as f:
return f.read().splitlines()[-anzahl:]
except OSError:
return []
def hole(pfad: str, host: str = None, timeout: int = 8):
"""GET auf Rippys API — None, wenn es nicht klappt."""
ziel = host if host is not None else RIPPY_HOST
if not ziel:
return None
try:
with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a:
return json.load(a)
except Exception:
return None
def worker_zustand(capabilities, name: str) -> dict:
"""Was Rippy über DIESEN Worker weiß (pure Funktion).
Rückgabe: {"bekannt": bool, "online": bool, "encoders": [...], "info": {...}}
"""
for w in ((capabilities or {}).get("workers") or []):
if w.get("name") == name:
return {
"bekannt": True,
"online": bool(w.get("online")),
"encoders": w.get("encoders") or [],
"info": w.get("info") or {},
}
return {"bekannt": False, "online": False, "encoders": [], "info": {}}
def job_text(job: dict) -> str:
"""Eine Zeile für die Aufgabenliste (pure Funktion)."""
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
status = {
"transcoding": "komprimiert",
"processing": "rippt",
"running": "rippt",
"pending": "wartet",
"completed": "fertig",
"failed": "Fehler",
"canceling": "bricht ab",
}.get(job.get("status") or "", job.get("status") or "?")
zeile = f"{titel}{status}"
if job.get("status") in ("transcoding", "processing", "running"):
zeile += f", {job.get('progress') or 0} %"
if job.get("eta_text"):
zeile += f" ({job['eta_text']})"
return zeile
def status_text(zustand: dict, aktueller_job: dict) -> tuple:
"""(Text, Farbe) für die Statuszeile (pure Funktion)."""
if not zustand.get("bekannt"):
return ("Rippy kennt diesen Worker nicht — läuft er? Ist die Adresse richtig?", ROSE)
if not zustand.get("online"):
return ("Bei Rippy als NICHT erreichbar gemeldet", ROSE)
if aktueller_job:
return (job_text(aktueller_job), AMBER)
return ("Erreichbar und bereit — nichts in Arbeit", EMERALD)
def deinstallieren() -> None:
"""Startet den Deinstaller im selben Ordner (fragt dort selbst nach)."""
skript = os.path.join(BASIS, "uninstall.ps1")
if not os.path.isfile(skript):
return
subprocess.Popen(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", skript],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
def _log_pfad() -> str:
"""Dieselbe Wahl wie im Tray (dort ausführlich begründet)."""
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
if basis_daten:
kandidat = os.path.join(basis_daten, "Rippy Worker", "worker.log")
if os.path.exists(kandidat):
return kandidat
return os.path.join(BASIS, "worker.log")
+32
View File
@@ -0,0 +1,32 @@
"""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)
+55 -6
View File
@@ -28,7 +28,22 @@ Ampel sie ohne Infrastruktur prüfen kann.
"""
# Zustände, die behaupten: hier arbeitet gerade jemand.
ARBEITS_STATI = ("ripping", "transcoding", "canceling")
#
# ⚠️ „running" FEHLTE bis zum 26.07.2026 — und das war der ganze Witz: Die
# Zombie-Erkennung aus v3.14 wurde gebaut, um genau einen abgestürzten RIP zu
# finden, und hat ihn nie gesehen. Der Rip setzt `status="running"`
# (tasks.py, db.update_job), gesucht wurde aber „ripping". Dieser Wert steht
# ausschließlich in Celerys Task-Meta und NIE in einer Job-Zeile.
#
# Die Folge war besonders tückisch: Bei jedem Worker-Start meldete die Erkennung
# `{'geprueft': 0, ...}` — und das sah wie „nachgesehen, alles gesund" aus,
# während sie in Wahrheit nach einem Status suchte, den es nicht gibt. Gefunden
# am 26.07.2026, als ein Rip mitten im Lauf abbrach und der Job danach dauerhaft
# auf „processing 12 %" stand, mit einer Restzeit-Schätzung obendrauf.
#
# „ripping" bleibt bewusst drin: Es schadet nicht und deckt eine etwaige
# Bestandsinstallation ab, in der es doch gesetzt wurde.
ARBEITS_STATI = ("running", "ripping", "transcoding", "canceling")
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
@@ -84,16 +99,50 @@ def finde_zombies(offene_jobs, belegte) -> list:
return [job for job in offene_jobs if job.get("id") not in belegte]
# In welcher Phase starb der Job? Entscheidet, was danach hilft.
RIP_STATI = ("running", "ripping")
def war_im_rip(job) -> bool:
"""Starb der Job WÄHREND des Rippens? (pure Funktion)
Der Unterschied ist für den Nutzer der ganze Punkt: Nach einem toten
TRANSCODE liegt eine vollständige Roh-MKV da Neu komprimieren" genügt und
spart eine Stunde Rippen. Nach einem toten RIP liegt ein ABGESCHNITTENES
Bruchstück da (im Vorfall 26.07.2026: 5,1 GB von rund 40), und Komprimieren
ergäbe einen Film, der bei 12 % aufhört.
"""
return (job.get("status") or "") in RIP_STATI
def fehlertext(job) -> str:
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft.
Der Text unterscheidet die Phase (26.07.2026). Vorher stand hier für JEDEN
Fall mit 'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch
einmal zu rippen" — geschrieben für einen toten Transcode. Seit „running" mit
zu den Arbeitsstati gehört, traf derselbe Satz auch abgebrochene RIPS, und da
ist er schlicht falsch: Wer ihn befolgt, bekommt ein Bruchstück komprimiert.
"""
zuletzt = job.get("status") or "unbekannt"
return (
kopf = (
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
"einmal zu rippen."
"mitten im Lauf. "
)
if war_im_rip(job):
fortschritt = job.get("progress") or 0
return kopf + (
f'Der Rip war bei {fortschritt} % — die Roh-Datei ist damit '
'UNVOLLSTÄNDIG. Sie wurde nicht gelöscht, aber „Neu komprimieren" '
'ergäbe einen Film, der mitten drin aufhört. Richtig ist: Disc '
'wieder einlegen und neu rippen.'
)
return kopf + (
'Der Rip war fertig, nur die Kompression nicht. Die Roh-Dateien wurden '
'NICHT gelöscht: mit „Neu komprimieren" läuft sie erneut, ohne die Disc '
'noch einmal zu rippen.'
)
+34 -3
View File
@@ -47,6 +47,25 @@ problem() { PROBLEME="${PROBLEME}$1"$'\n'; rot " X $1"; }
hinweis() { HINWEISE="${HINWEISE}$1"$'\n'; gelb " ! $1"; }
ok() { gruen " OK $1"; }
# Welches Paketwerkzeug hat dieser Host? (Commander-Frage 26.07.2026: „Was wenn
# der Unterbau nicht Debian ist?") Vorher nannte das Skript bei fehlendem
# Compose stur `sudo apt install …` — auf Arch, Fedora oder openSUSE ist das
# schlicht falsch, und der Nutzer stand vor einem Befehl, den es nicht gibt.
#
# Erkannt wird über das VORHANDENE Werkzeug, nicht über /etc/os-release: Ein
# Derivat kann sich anders nennen als sein Unterbau, aber sein Paketwerkzeug
# liegt im PATH. Findet sich keines, wird nichts behauptet.
paket_befehl() {
if command -v apt-get >/dev/null 2>&1; then echo "sudo apt install docker-compose-plugin"
elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install docker-compose-plugin"
elif command -v yum >/dev/null 2>&1; then echo "sudo yum install docker-compose-plugin"
elif command -v pacman >/dev/null 2>&1; then echo "sudo pacman -S docker-compose"
elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install docker-compose"
elif command -v apk >/dev/null 2>&1; then echo "sudo apk add docker-cli-compose"
else echo ""
fi
}
cd "$(dirname "$0")" || exit 1
echo "======================================================================"
@@ -85,7 +104,14 @@ elif command -v docker-compose >/dev/null 2>&1; then
hinweis "Nur das alte docker-compose gefunden — funktioniert, ist aber veraltet."
else
problem "Docker Compose fehlt."
rot " Installation (Debian/Ubuntu): sudo apt install docker-compose-plugin"
PAKET_BEFEHL="$(paket_befehl)"
if [ -n "$PAKET_BEFEHL" ]; then
rot " Auf diesem System: $PAKET_BEFEHL"
fi
# Der Weg, der auf JEDER Distribution funktioniert — Dockers eigenes Skript
# bringt das Compose-Plugin mit. Steht immer da, auch wenn oben schon ein
# passender Paketbefehl genannt wurde: Paketnamen wandern, dieses Skript nicht.
rot " Überall gültig: curl -fsSL https://get.docker.com | sudo sh"
fi
if [ "$(id -u)" = "0" ]; then
@@ -307,8 +333,9 @@ elif [ "$PROPAGATION_NOETIG" = 1 ]; then
ok "Propagation eingerichtet"
# Neustart-fest machen, sonst ist es nach dem nächsten Reboot wieder weg
# und das Einhängen aus dem UI scheitert stillschweigend.
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)"
if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then
if command -v systemd-escape >/dev/null 2>&1 && command -v systemctl >/dev/null 2>&1; then
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR")"
if [ ! -e "$UNIT" ]; then
cat > "$UNIT" <<UNITEOF
[Unit]
Description=Rippy-Ablage als shared mount (für NAS-Freigaben aus dem UI)
@@ -331,6 +358,10 @@ UNITEOF
gelb " dieses Skript einfach erneut ausführen."
fi
fi
else
gelb " ! Kein systemd gefunden — Mount-Propagation ist nicht neustart-fest."
gelb " Nach einem Reboot dieses Skript einfach erneut ausführen."
fi
else
gelb " ! Propagation ließ sich nicht einrichten."
gelb " Rippy läuft trotzdem — nur das Einhängen von NAS-Freigaben AUS DEM UI"