159 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
Hitonabi fd1feaaee3 fix(ui): die Ablage war im UI ueberhaupt nicht einstellbar
Ampel / ampel (push) Successful in 28s
Der Grund, warum der erste Rip mit externem Encoder scheitern MUSSTE - und
Commander-Vorgabe: "Wenn ein externes Ziel eingehaengt ist, soll Rippy das
ausgewaehlte als Arbeitsziel verwenden. Das stellt man ja sowieso in den
Einstellungen ein. Das muss auch fuer den Automatik-Modus so sein."

Genau das ging nicht. outputDir stand in den Einstellungen, wurde von der
Vollautomatik (main.py) und der Schnellwahl (RipTargetModal) gelesen - aber es
gab NIRGENDS ein Eingabefeld. Die Ablage klebte auf /app/media, der
Container-Platte. Das Arbeitsverzeichnis hatte eine Auswahlliste der
eingehaengten Ziele, das Ziel nicht.

Folge im Praxistest: Rohdaten auf der NAS (die der PC erreicht), Ziel auf der
VM-Platte (die er nicht erreicht, kein Samba dort). Der externe Encoder konnte
lesen, aber nicht schreiben - und das haette man mit den vorhandenen
Bedienelementen gar nicht anders einstellen koennen.

JETZT: Ablage-Auswahl in Einstellungen -> Ripping, mit derselben Liste
eingehaengter Ziele wie das Arbeitsverzeichnis. Damit wirkt sie automatisch
auch in der Vollautomatik und in der Schnellwahl, weil beide outputDir lesen -
also genau so, wie der Commander es beschrieben hat.

DAZU die Pruefung, die den Vorfall verhindert haette: Sobald ein EXTERNER
Worker gemeldet ist, warnt das UI, wenn Ablage und Arbeitsverzeichnis nicht
beide auf einer erreichbaren Freigabe liegen. Drei Faelle, jeweils mit der
Konsequenz im Klartext:
  - Arbeitsverzeichnis auf der Container-Platte -> externer Encoder kann nicht lesen
  - Arbeit auf Freigabe, Ablage lokal -> kann lesen, nicht schreiben (der Vorfall)
  - beide auf VERSCHIEDENEN Freigaben -> laeuft, kostet aber eine Vollkopie

"Extern" ist dabei keine Heuristik ueber IPs oder Namen: caps.py meldet es
selbst (`extern`), weil /app im Rippy-Image immer existiert und ausserhalb nie.
Zusaetzlich meldet der Worker jetzt seine `pfad_map` - damit kann das UI im
naechsten Schritt sagen, ob die Uebersetzung ueberhaupt gesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:58:58 +02:00
Hitonabi fe12401e34 fix(worker): Hardware-Encoder und CPU-Merkmale auf Windows - plus ehrliche Transcode-Fehler
Aus dem gescheiterten Test-Rip des Commanders (Job 95afdc89) und seiner
UI-Durchsicht. Der Reihe nach.

## 1. Der externe Encoder wurde SEHR WOHL gesehen (Diagnose-Korrektur)

Der Commander schloss aus der Meldung, das System sehe seinen PC nicht. Live
gemessen sagt das Gegenteil:

    meta.transcode_node = tobisnicerpc@TobisNicerPC
    VM-Worker-Log        = nur rip_disc, KEIN transcode_files
    22:19:41.271  Kompression eingereiht
    22:19:41.453  "Keine Roh-MKVs gefunden"   <- 182 ms spaeter

Das Routing hat funktioniert. Sein PC nahm die Aufgabe an und lehnte sie sofort
ab, weil er /app/media/rippy/<job> nicht finden kann - ein Pfad, der nur INNEN
im Container existiert. Der Rip war vollstaendig (79,6 GB auf der NAS).

## 2. Der Hardware-Encoder-Bug war meiner

leite_backends_ab() verlangte zusaetzlich ein Geraet: /dev/dri fuer
VAAPI/QSV/VCE bzw. nvidia-smi fuer NVENC. Unter WINDOWS gibt es beides nicht -
der PC des Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl
HandBrakes Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
Anwendungsfall, fuer den externe Worker gedacht sind.

Die Pruefung war ausserdem ueberfluessig: HandBrake probiert Hardware beim
Start selbst an und listet nur Nutzbares - auf der VM belegt durch
"qsv: not available on this system" bei gleichzeitig fehlendem qsv_* in --help.
Jetzt ist HandBrakes Liste die einzige Auskunft.

Dazu nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) statt alles in "vaapi" zu
werfen - eine AMD-Karte lief unter dem Intel-Namen. Hardware-AV1 bekommt eine
eigene Kennung, weil es die beste Kombination aus Tempo und Groesse ist und
sonst unsichtbar bliebe.

## 3. Vektorbefehle unter Windows - gemessen, nicht "unbekannt"

Es gibt dort kein /proc/cpuinfo, also meldete der Windows-Worker
"Vektorbefehle unbekannt" - gerade auf der Maschine, die encodieren soll.
Jetzt ueber IsProcessorFeaturePresent (kernel32, winnt.h dokumentiert) plus
CPU-Name aus der Registry. Auf dem Commander-PC gegengeprueft:

    vorher: AMD64 Family 26 Model 68 Stepping 0  /  Vektorbefehle unbekannt
    jetzt:  AMD Ryzen 7 9700X 8-Core Processor   /  avx512f  /  16 Kerne

Damit verschwindet die AVX2-Warnung fuer diesen Worker von selbst - genau das
hatte der Commander gefordert.

## 4. Ehrliche Fehlermeldung statt "Keine Roh-MKVs gefunden"

Neues _erreichbarkeit_pruefen() unterscheidet jetzt drei Faelle statt einem:
Pfad nicht erreichbar UND kein Mapping gesetzt (nennt RIPPY_PATH_MAP samt
Beispiel), Mapping gesetzt aber greift nicht, oder Pfad einfach nicht da. Und
es prueft BEIDE Pfade - im Fall des Commanders lag die Quelle auf der
erreichbaren NAS, das ZIEL aber auf der VM-Platte ohne Samba; das waere erst
beim Schreiben nach Stunden Rechenzeit aufgefallen.

Jede Meldung sagt jetzt ausdruecklich, dass die Rohdaten NICHT verloren sind
und "Neu komprimieren" genuegt.

## 5. Persoenliche Werte raus

Vier Platzhalter trugen die Umgebung des Commanders (192.168.178.20,
TOBIS-PC, seine Jellyfin- und Rippy-IP). In einem Repo, das weitergegeben
wird, hat das nichts verloren.

Nebenbei dreimal in die eigene Falle getappt: deutsche Anfuehrungszeichen in
DOPPELT gequoteten Python-Strings beenden den String vorzeitig. Der Bestand
nutzt dafuer einfach gequotete f-Strings - jetzt auch meine.

NOCH OFFEN und wichtig: RIPPY_PATH_MAP wird von niemandem GESETZT (Installer,
UI, Compose). Ein Windows-Worker kann damit grundsaetzlich nicht transcodieren.
Das ist der naechste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:51:47 +02:00
Hitonabi 61c38a0d14 docs(readme): eigener Abschnitt fuer Dockge, Portainer, Arcane und Shell
Ampel / ampel (push) Successful in 28s
Commander-Plan fuer die Uebergabe: "Repo auf den Dockge-Server laden, Compose
reinhauen, Profit?!" - und genau da sitzt die Falle, in die jeder zuerst tappt.

DER KERN, der jetzt oben im Abschnitt steht: Rippy hat KEINE Registry-Images.
api, worker und ui werden aus dem Repo gebaut (build: context: .). Die
Compose-Datei allein ist deshalb wertlos - in ein leeres Verzeichnis kopiert
gibt es sofort "failed to read dockerfile". Daraus folgt fuer JEDE Oberflaeche
dieselbe Regel: das Repo muss dort liegen, wo die Oberflaeche den Stack baut.

Je Werkzeug der konkrete Weg, weil sie sich genau darin unterscheiden:

- Dockge kann das Repo NICHT selbst holen -> hinein in den Stacks-Ordner
  klonen (Standard /opt/stacks). Dann ist die Compose des Repos die des Stacks,
  und man darf den Stack gerade NICHT neu in Dockge anlegen - sonst liegt eine
  leere Compose in einem anderen Ordner.
- Portainer KANN es selbst holen -> Stacks > Add stack > Repository mit
  Git-URL. Web-Editor und Upload funktionieren nicht (kein Build-Kontext).
  Geraeteknoten dort als Stack-Umgebungsvariablen statt .env.
- Arcane: Repo auf dem Host, Deploy per Shell - der Git-Sync zieht nicht
  selbststaendig (auf dieser Installation seit Wochen die geuebte Praxis).
- Shell: unveraendert git clone + sudo ./install.sh.

Dazu die Vorab-Pruefung als gemeinsamer Einstieg: ./install.sh --nur-pruefen
braucht kein root, aendert nichts und nennt vor allem die ECHTEN
Geraeteknoten - der einzige Punkt, der zuverlaessig zuschlaegt.

RICHTIGSTELLUNG an mir selbst: Ich hatte die Mount-Propagation als Huerde
dargestellt. Auf den meisten systemd-Hosts ist / schon rshared und /srv/rippy/
media erbt das - da ist nichts zu tun. Nur wenn Docker ueber "not a shared
mount" klagt, braucht es die Shell. Steht jetzt so drin.

Stoerungstabelle von vier auf sechs Faelle: "failed to read dockerfile" und
"Worker startet nicht, /dev/sgN fehlt" ergaenzt - die beiden, die ein
Oberflaechen-Nutzer als erste sieht. Interne Verweise gegengeprueft, alle vier
loesen auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:02:46 +02:00
Hitonabi 29444805a8 fix(build): der MakeMKV-Fallback der VM war seit Monaten tot - Kette jetzt dreistufig
Ampel / ampel (push) Successful in 28s
Beim Aufraeumen der VM-.env aufgefallen und nachgemessen (25.07.2026):

    https://www.makemkv.com/download        HTTP 200   <- Repo-Standard
    https://www.makemkv.com/download/old    HTTP 525   (Cloudflare)
    web.archive.org-Schnappschuss           HTTP 404   <- stand in der VM-.env

Die VM zeigte also auf eine KAPUTTE Adresse. Aufgefallen ist es nie, weil dort
die vendor/-Tarballs liegen und der Download-Zweig gar nicht erreicht wird.
Nimm die Tarballs weg, und jeder Bau scheitert mit 404 - waehrend die
Konfiguration gesund aussieht. Genau diese Sorte Fehler ist bei einer Uebergabe
teuer, weil sie erst beim Fremden zuschlaegt.

## Bereinigt (auf Commander-Wunsch, Sicherung liegt auf der VM)

.env.sicherung-vor-aufraeumen-20260725 angelegt, dann:
- MAKEMKV_URL_BASE raus -> es gilt der Compose-Standard, der liefert.
  Von Docker gegengeprueft: MAKEMKV_URL_BASE: https://www.makemkv.com/download
- JWT_SECRET_KEY raus -> Ueberrest der in v3.4 ausgebauten Anmeldung, wirkungslos.

## Fallbacks BEHALTEN, aber echt gemacht (Commander-Vorgabe)

Vorher war der "Fallback" eine Zeile, die man von Hand eintragen musste - und
die hier ins Leere zeigte. Jetzt eine automatische Kette:

  1. vendor/-Tarballs        (braucht kein Netz, zuverlaessigster Weg)
  2. MAKEMKV_URL_BASE
  3. MAKEMKV_URL_FALLBACK    (NEU, wird automatisch versucht wenn 2 versagt)

Stufe 3 ist ABSICHTLICH leer vorbelegt. Es gibt derzeit keine belegbare zweite
Quelle (siehe Messung oben), und eine einzutragen, die nicht liefert, waere
schlimmer als keine - genau das lag auf der VM vor. Wer eine eigene Quelle hat
(Spiegel im LAN), traegt sie ein und sie wird automatisch genutzt.

Scheitert alles, nennt die Fehlermeldung jetzt die beiden Wege, die
FUNKTIONIEREN, statt nur einen curl-Rueckgabewert zu hinterlassen.

## Geprueft, beide Zweige

- vendor-Pfad:   Bau durch, 828 MB
- Download-Pfad: im FREMDEN Klon ohne Tarballs gebaut - Bau durch, 777 MB,
  makemkvcon liegt unter /usr/local/bin/. Die leere Fallback-Stufe wird
  korrekt uebersprungen, "Erste Quelle lieferte nicht" erschien nicht.

docker compose config validiert. README und .env.example nennen die gemessenen
HTTP-Antworten, damit niemand wieder eine 404-Adresse als Fallback eintraegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:50:54 +02:00
Hitonabi b0205c42c9 refactor(install): erst ALLES pruefen, dann erst aendern
Ampel / ampel (push) Successful in 28s
Commander-Einwand: "Warum macht das install script den pre-check nicht selbst
vor der Installation?" Zu Recht - es tat es nur halb.

VORHER war Pruefen und Aendern verschraenkt:
  1 Voraussetzungen  pruefen (bricht ab)
  2 Laufwerk         pruefen (warnt)
  3 Verzeichnisse    AENDERT
  4 Mount            AENDERT
  5 .env             AENDERT
  6 Bauen            AENDERT

Nur die harten Voraussetzungen liefen vorab. Verzeichnisse wurden angelegt,
bevor klar war, ob der Rest durchlaeuft - scheiterte etwas in der Mitte, blieb
ein halb eingerichteter Rechner zurueck. Und --nur-pruefen war als if-Zweig
durch FUENF Schritte gefaedelt: so etwas laeuft zwangslaeufig aus dem Ruder,
weil jede neue Aktion daran denken muesste.

JETZT zwei klar getrennte Phasen:

  Phase 1  PRUEFEN     - fuenf Pruefungen, aendert NICHTS
  Weiche               - Probleme -> Abbruch mit Liste, nichts angefasst
  Phase 2  EINRICHTEN  - Verzeichnisse, Mount, .env, bauen, starten

Es gibt damit GENAU EINEN Pruef-Pfad, den beide Modi benutzen: --nur-pruefen
heisst schlicht "nach Phase 1 aufhoeren". Die Pruefung kann nicht mehr etwas
anderes behaupten als die Installation tut.

Die Weiche unterscheidet PROBLEME (verhindern die Installation) von HINWEISEN
(halten nicht auf) und fasst am Ende beides als Liste zusammen - vorher musste
man die Ausgabe rueckwaerts lesen, um zu wissen, ob es gereicht hat.

Neu mitgeprueft, weil es jetzt an einer Stelle passt:
- Ist der Elternpfad der Verzeichnisse ueberhaupt beschreibbar? (Vorher waere
  das erst beim mkdir aufgefallen - mitten in der Aenderungsphase.)
- Freier Plattenplatz mit Schwelle 60 GB, samt Hinweis auf die Groessen
  (Blu-ray roh ~40 GB, 4K-UHD bis 100 GB).
- Ist ueberhaupt .env.example da, also stehen wir im Rippy-Repo?
- Was in die .env geschrieben WUERDE, steht jetzt in Phase 1 als Ansage.

Auf der VM gegengeprueft: aus /tmp gestartet meldet es korrekt "kein
Rippy-Repo" und bricht ab, ohne etwas anzufassen; aus dem Repo heraus laeuft
die Pruefung sauber durch ("Alles in Ordnung, keine Einschraenkungen").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:42:31 +02:00
Hitonabi 35cfcbcb07 style: echte Umlaute im ganzen Projekt + Installer im Rippy-Look
Ampel / ampel (push) Successful in 27s
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.

## Die .ps1-Falle war loesbar, nicht unumgehbar

Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:

    ohne BOM:  $s = "Größe: äöü"   + Unerwartetes Token -> Skript kaputt
    mit  BOM:  Groesse: aeoeue         + laeuft, Length 16 korrekt

Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".

## Umstellung: Text ja, Bezeichner nein

Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:

- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
  STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
  kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
  `GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
  der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.

ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:

1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
   f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
   "Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
   "uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
   wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.

## Installer im Rippy-Look

Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.

Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:37:23 +02:00
Hitonabi 65edf8369e feat(worker-windows): Installation nach "Programme", Zielordner frei waehlbar
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: nicht ins Benutzerprofil, sondern wie jedes andere Programm
nach C:\Program Files - und selbst waehlbar, mit diesem Standard.

## Was neu ist

- Feld "Installieren nach" mit Knopf "Aendern ..." (Ordner-Dialog). Standard
  ist [Environment]::GetFolderPath("ProgramFiles") + "Rippy Worker" - der
  ECHTE Pfad; deutsche Windows-Versionen zeigen im Explorer "Programme", der
  Pfad heisst trotzdem "Program Files".
- Der Ordner-Dialog liefert den ELTERN-Ordner, "Rippy Worker" haengt der
  Installer selbst an. Sonst koennte die Deinstallation einen fremden Ordner
  mitloeschen - sie macht Remove-Item -Recurse auf das Zielverzeichnis.
- install.ps1 bekommt -InstallDir mit demselben Standard.

## Drei Folgen, die mitbehandelt werden mussten

1. RECHTE. In C:\Program Files darf nur ein Administrator schreiben. Die .exe
   traegt jetzt ein requireAdministrator-Manifest (-requireAdmin in
   build-exe.ps1), fragt also beim Doppelklick EINMAL per UAC - danach stimmen
   die Rechte fuer alles Weitere. Verifiziert: "requireAdministrator" steckt im
   Manifest, "asInvoker" nicht mehr. Zusaetzlich prueft der Installer VOR dem
   Entpacken, ob er dort schreiben darf, und nennt bei Nein die zwei Wege
   (als Administrator starten ODER Ziel ins eigene Profil legen) - statt
   irgendwo tief "Zugriff verweigert" zu produzieren.

2. DAS LOG. tray.py schrieb worker.log NEBEN das Programm. Unter Program Files
   darf ein normaler Benutzer das nicht - der Worker waere beim Starten
   gescheitert, und zwar still. Das Log liegt jetzt in
   %LOCALAPPDATA%\Rippy Worker\worker.log, also da, wo veraenderliche Daten
   hingehoeren. Rueckfall auf das Programmverzeichnis bleibt fuer
   Installationen ins eigene Profil.

3. DER AUTOSTART. Bei einer Installation unter Programme ist es eine
   Installation FUER DIE MASCHINE - der Autostart geht deshalb in den
   Autostart-Ordner aller Benutzer (CommonStartup). Das loest zugleich ein
   Rechte-Problem: erhoeht man ueber ein FREMDES Administratorkonto, waere
   GetFolderPath("Startup") der Ordner dieses Admins, also der falsche. Bei
   einem Ziel im eigenen Profil bleibt es persoenlich. uninstall.ps1 raeumt
   beide Orte ab.

## Geprueft

- Leerzeichen im Pfad: die erzeugte .bat mit cd /d "%~dp0", PATH-Erweiterung
  und relativem Aufruf laeuft aus "C:\...\Rippy Test Ordner" korrekt durch.
- Alle drei .ps1 ASCII-rein und fehlerfrei geparst.
- RippyWorkerSetup.exe neu gebaut (52736 -> 57344 Bytes, Version 1.0.1.0).

UI: Der Copy-Paste-Befehl fuer den CLI-Weg sagt jetzt, dass PowerShell als
Administrator laufen muss, und nennt -InstallDir als Ausweg ohne Adminrechte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:20:50 +02:00
Hitonabi 8f208ed0da fix(worker-windows): Autostart brach die ganze Installation ab
Ampel / ampel (push) Successful in 28s
Beim Commander live aufgetreten: Der Installer meldete
"FEHLER: FEHLER: Zugriff verweigert" samt dem Tipp, die IP koenne falsch sein -
und das, NACHDEM Worker-Code, Abhaengigkeiten und HandBrake schon fertig waren.
Die IP war also nie das Problem.

URSACHE: schtasks /create mit /tn "RippyWorker" legt die Aufgabe im
WURZELORDNER der Aufgabenplanung an, und das verlangt Administratorrechte. Der
Installer laeuft normal ohne. schtasks schrieb "FEHLER: Zugriff verweigert."
nach stderr, und weil im Skript $ErrorActionPreference = "Stop" steht, machte
PowerShell daraus einen TERMINIERENDEN Fehler - der catch-Block riss damit die
komplette Installation ab, obwohl nur der Autostart fehlte. Das doppelte
"FEHLER: FEHLER:" war der Hinweis: der aeussere Handler setzt sein Praefix vor
eine Meldung, die selbst schon mit "FEHLER:" begann, also aus schtasks kam.

FIX, zwei Teile:

1. Autostart ueber den Autostart-ORDNER statt schtasks. Eine Verknuepfung in
   [Environment]::GetFolderPath("Startup") braucht NIE Adminrechte - und der
   Nutzer kann sie dort selbst sehen und loeschen. Auf diesem Windows-PC
   gegengeprueft: Verknuepfung angelegt (1047 Bytes) mit Admin=False.
2. Ein Fehlschlag beim Autostart ist jetzt eine WARNUNG in eigenem try/catch,
   kein Abbruch. Der Worker ist zu dem Zeitpunkt fertig und startbar, und der
   Text sagt das auch - plus den Handweg (shell:startup).

In BEIDEN Installern (install-gui.ps1 und install.ps1, dort mit -Autostart) -
der CLI-Weg hatte denselben Fehler. uninstall.ps1 raeumt jetzt die
Verknuepfung weg UND versucht weiter schtasks /delete, damit aeltere
Installationen sauber verschwinden.

RippyWorkerSetup.exe neu gebaut (50688 -> 52736 Bytes): die GUI ist in der .exe
eingebettet, ohne Rebuild wirkt der Fix beim Nutzer nicht.

Nebenbei: build-exe.ps1 hatte zwei Gedankenstriche. Ausgelieferte .ps1 muessen
reines ASCII sein (PowerShell 5.1 liest sie als ANSI) - jetzt sind alle drei
Skripte ASCII-rein und parsen fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:11:39 +02:00
Hitonabi b76ca5d9be docs(savepoint): install.sh und README-Umbau nachgetragen
Ampel / ampel (push) Successful in 28s
Der Block dokumentiert jetzt auch die letzte Runde: ein Befehl statt
Checkliste, README neu aufgebaut, VM-CPU-Anleitung drin.

Ehrlich als UNGETESTET markiert (neuer Punkt 6): install.sh ist nie als root
durchgelaufen. Auf dieser VM sind alle root-Schritte No-Ops - Verzeichnisse
liegen da, die Propagation ist schon shared - und sudo verlangt hier ein
Passwort. Bewiesen sind Laufwerkserkennung (SCSI-Abgleich gegen die echte
Hardware), die .env-Logik in drei Faellen und die Syntax. Das Anlegen der
Verzeichnisse, mount --make-rshared und die systemd-Unit laufen erst bei einer
echten Neuinstallation - das gehoert in die Uebergabe statt in eine
Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:01:06 +02:00
Hitonabi ca91e5b693 feat(install): ein Befehl statt Checkliste - install.sh nimmt die Handarbeit ab
Ampel / ampel (push) Successful in 28s
Commander-Rueckmeldung: "Das Docker Deployment ist mir zu kompliziert." Zu
Recht - die Installation war eine Checkliste aus sechs Schritten, von denen
zwei nur mit Fachwissen zu schaffen waren.

## Neu: sudo ./install.sh

Nimmt genau die Schritte ab, an denen man scheitern konnte:

1. Prueft Voraussetzungen (Linux, Docker, Compose) und nennt bei jedem Mangel
   den Installationsbefehl.
2. FINDET DAS LAUFWERK SELBST. Das war der schlimmste Punkt: MakeMKV braucht
   ZWEI Geraeteknoten, und die sg-Nummer ist je Host anders. Das Skript gleicht
   sie ueber die SCSI-Adresse in /sys ab statt zu raten. Auf der VM
   gegengeprueft: sr0 -> 3:0:0:0, sg1 -> 3:0:0:0, also dasselbe Geraet -
   korrekt als /dev/sr0 + /dev/sg1 erkannt.
3. Legt Ablage- und MakeMKV-Verzeichnis an.
4. Richtet die Mount-Propagation ein UND macht sie per systemd-.mount-Unit
   neustart-fest. Vorher stand in der README nur "reboot-fest persistieren" -
   ohne zu sagen wie; nach einem Reboot scheiterte das Einhaengen von
   NAS-Freigaben aus dem UI stillschweigend.
5. Schreibt die .env, ueberschreibt aber NIE einen bestehenden Wert.
6. Baut und startet, und nennt bei Fehlschlag die drei haeufigsten Ursachen
   mit Diagnosebefehl.

Wiederholbar (mehrfach ausfuehren aendert nichts kaputt) und damit auch der
Update-Weg: git pull && sudo ./install.sh

## Zwei Fallgruben, die beim Testen auffielen - beide meine eigenen

- --nur-pruefen verlangte root und brach ab. Ein Pruef-Modus, der nichts
  aendert, darf daran nicht scheitern - sonst kann man vor der Installation
  nicht nachsehen, ob alles passt. Behoben.
- .env.example hatte OPTICAL_SG=/dev/sg1 UNKOMMENTIERT vorbelegt. Damit haette
  der Installer den erkannten Wert nicht eingetragen ("steht schon drin") und
  auf jedem fremden Host still eine kaputte Konfiguration hinterlassen - genau
  das, was er verhindern soll. Beide Geraetezeilen sind jetzt auskommentiert;
  Compose hat ohnehin Vorgaben. Dazu eine Gegenprobe im Skript: zeigt ein
  wirksamer Wert auf ein Geraet, das es hier nicht gibt (".env von einem
  anderen Rechner"), wird das benannt statt kryptisch von Docker gemeldet.

## README neu aufgebaut

Vorher 293 Zeilen, in denen der Schnellstart zwischen lsscsi, sg-Knoten,
USB-Passthrough und mount --make-rshared begraben war. Jetzt: Installation in
zwei Zeilen oben, dann eine Tabelle "Wenn etwas nicht geht" mit den vier
Faellen, die praktisch alles abdecken. Alles Technische steht darunter in
aufklappbaren Abschnitten - inklusive der Handarbeits-Variante fuer die, die
sie wollen.

NEU und ausdruecklich gewuenscht: Abschnitt "Rippy schneller machen" mit dem
VM-CPU-Typ. Erklaert, warum Virtualisierer eine generische CPU ohne AVX2
geben, was das kostet (gemessene 28-55 h je 4K-Film), die genauen Schritte in
Proxmox (herunterfahren - Hardware/Processors/Type auf 'host' - starten, bzw.
qm set <vmid> --cpu host), warum ein Neustart von innen NICHT genuegt, und wie
man nachprueft: Rippy zeigt die Vektorbefehle seit v3.14 selbst an. Dazu der
Nachteil (keine Live-Migration auf andere CPUs) und die Alternative x86-64-v3
fuer Cluster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:59:23 +02:00
Hitonabi 1eb1c91dd8 docs(savepoint): v3.15 - Aufraeum-Runde mit gemessenen Zahlen
Ampel / ampel (push) Successful in 28s
Fuenf Vorgaben, was daraus wurde:

Codebase sauber   -> vier tote Routen + zwei Module weg (main.py 1726 -> 1682
                     Zeilen), Tests halten sie draussen
UI schnell        -> /capabilities 1,010 s -> 0,003 s; Dashboard-Aufbau
                     1,03 s -> 0,028 s. Alle 15 Ladeendpunkte unter 10 ms
UI selbsterklaerend -> Wizard-Kasten "Was Rippy gerade sieht" mit
                     Handlungsanweisung je Problem, sichtbare Keys mit Pruefung
Idiotensicher     -> Wizard empfiehlt nach GEMESSENER CPU statt H.265 blind;
                     4K-Falle ist damit zu
Externe Worker    -> auf Windows gegengeprueft (Kerne/Modell korrekt, encoders
                     ohne HandBrake korrekt leer, SIMD ehrlich "unbekannt")

Dazu die offene 4K-Frage aus v3.14 beantwortet: Kompression ist je Disc-Typ
abwaehlbar, 4K kann verlustfrei bleiben waehrend DVD/Blu-ray weiter schrumpfen.

ARM-Vergleich drin: fast alles, was ARM automatisch macht, macht Rippy schon -
und meist gruendlicher. Zwei echte Luecken benannt und NICHT gebaut
(ISO-Sicherung fuer Datentraeger, mehrere Laufwerke gleichzeitig), weil das
Funktionen sind und keine Reparatur. Ehrlich dabei: ob Rippy parallel rippen
kann, ist unbewiesen - mit einem Laufwerk nicht testbar.

Offene Punkte stehen unter "NOCH OFFEN", darunter der VM-CPU-Typ (qemu64 statt
host - AVX2 waere ein VM-Neustart und 2-4x schneller).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:23 +02:00
Hitonabi 6a17af2118 feat(transcode,ui): Kompression je Disc-Typ abwaehlbar + Wizard rechnet mit der CPU
Ampel / ampel (push) Successful in 28s
Loest die offene Frage aus dem Savepoint ("UHD gar nicht komprimieren?") und
nimmt dem Wizard die Falle, in die er bisher fuehrte.

## Kompression je Disc-Typ abwaehlbar

Bisher gab es nur den globalen Schalter transcodeEnabled: alles komprimieren
oder nichts. Wer 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte,
hatte keine Moeglichkeit - obwohl genau das die vernuenftige Einstellung fuer
diese Maschine ist (4K-HEVC = gemessene 28-55 h je Film ohne AVX2).

Jetzt kann das Preset eines Disc-Typs auf den Reservewert "keine" stehen, dann
bleibt die verlustfreie Datei aus dem Rip stehen. Neue reine Funktion
komprimieren_fuer(disc_type, einstellungen); der globale Schalter schlaegt
weiter alles. preset_fuer() ueberspringt den Reservewert bewusst und gibt ihn
NIE als Preset-Namen zurueck - sonst bekaeme HandBrake `--preset keine` und
wuerde scheitern. Wer ueber "Neu komprimieren" ausdruecklich doch komprimieren
will, bekommt so ein brauchbares Preset statt eines Fehlers.

Der Reservewert kollidiert mit keinem echten Namen: gegengeprueft gegen alle
90 Presets aus `HandBrakeCLI --preset-list` im Worker-Image. Bei der
Gelegenheit auch die fuenf im UI angebotenen Namen geprueft - alle echt.

## Der Wizard empfiehlt nach GEMESSENER Rechenleistung

Vorher stand H.265 als Standard drin. Auf einer CPU ohne AVX2 sind das ein bis
zwei Tage pro 4K-Film - genau der Lauf, der am 25.07.2026 abgebrochen werden
musste. Der Wizard hatte die Zahlen sogar schon vorliegen (/capabilities
meldet cpu_simd und cpu_kerne), nur benutzt hat er sie nicht.

Jetzt: schwache CPU -> 4K wird nicht komprimiert, Blu-ray/DVD gehen auf H.264
(schneller als H.265). Starke CPU oder Hardware-Encoder -> H.265 durchgehend.
Der Wizard schreibt dabei ALLE vier Preset-Felder, nicht nur das allgemeine -
vorher fiel 4K auf ein 1080p-Preset zurueck und die Aufloesung war weg.

Gewarnt wird nur, wenn es belegt ist: schwacheEncoderCpu() verlangt mindestens
einen Worker mit BEKANNTER SIMD-Stufe und keinen mit Hardware-Encoder. Ein
Windows-Worker meldet "unbekannt" (dort gibt es kein /proc/cpuinfo) - dann wird
geschwiegen statt falsch gewarnt. Auf Windows gegengeprueft: 16 Kerne und
CPU-Modell kommen korrekt durch, encoders ist ohne HandBrake leer.

## Weitere Wizard-Haerten

- Kasten "Was Rippy gerade sieht": Laufwerk, Worker (mit Kernen/SIMD), freier
  Platz. Jede Zeile hat bei Problemen eine HANDLUNGSANWEISUNG statt nur eines
  Kreuzes - kein Laufwerk, kein Worker und wenig Platz sind die drei Faelle, in
  denen man vorher ratlos dastand.
- Laedt alle drei Quellen parallel (Promise.allSettled) und wiederholt im
  5-s-Takt: beim ersten Start laeuft der Worker noch hoch, vorher stand dort
  dauerhaft "Noch kein Worker gemeldet" ohne Aussicht.
- API-Keys sind SICHTBAR statt als Punkte: das sind kopierte Keys, keine
  Passwoerter, und einen Tippfehler sieht man in Punkten nicht.
- Keys werden direkt nach dem Speichern geprueft (/metadata/status). Ein
  falsch kopierter Key faellt sofort auf, statt erst beim ersten Rip als
  "Unknown Disc" - mit der Wahl "Key korrigieren" oder "Trotzdem fertigstellen".

## Einstellungen -> Verarbeitung

Alle drei Preset-Auswahlen bekommen "Nicht komprimieren", und beim 4K-Feld
erscheint die AVX2-Warnung mit der gemessenen Zahl - aber nur, wenn die
Maschine sie wirklich braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:48:31 +02:00
Hitonabi 11597eb00c perf+cleanup(api): /capabilities war 1,0 s; vier tote Endpunkte entfernt
Ampel / ampel (push) Successful in 28s
Beides in main.py, deshalb ein Commit.

## 1. Das "Laggen" hatte genau eine Ursache

Gemessen ueber alle 15 Endpunkte, die das UI beim Laden braucht:

    /capabilities      1,010 s
    /system/updates    0,491 s   (haengt am Knopf, nicht am Seitenaufbau)
    /metadata/status   0,412 s   (dito)
    die anderen 12   < 0,025 s

/capabilities ist der einzige langsame, der beim SEITENAUFBAU zuschlaegt - und
fuenf Stellen holen ihn (Dashboard, Einstellungen, Worker-Tab, Wizard,
Rip-Dialog). Jede Seite zahlte eine Sekunde.

Die Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er sammelt
Antworten bis zum Timeout und kann nicht frueher aufhoeren, weil er nicht
weiss, wie viele Worker noch antworten wollen. Den Timeout zu kuerzen wuerde
Antworten langsamer Remote-Worker verschlucken - also genau die Maschinen, um
die es beim externen Encoding geht.

Jetzt pingt eine Hintergrund-Schleife im 5-s-Takt (neben Disc-Watcher und
Key-Refresh, die es dort schon gibt), der Endpunkt liest nur ab. Vorrat aelter
als 30 s - Schleife noch nicht angelaufen oder gestorben - dann EINMAL synchron
pingen: lieber langsam als falsch ("alles offline", obwohl alles laeuft).

## 2. Vier tote Endpunkte raus

Jeder ein Ueberrest eines ersetzten Entwurfs, keiner mit Aufrufer (mechanisch
gegengeprueft: alle api.*-Aufrufe des UI gegen alle Routen):

  POST /prescan                  Metadaten-Vorschau-Seite ist seit v3.4 weg.
                                 Die PreScan-Klasse bleibt - sie hat 5 echte
                                 Fundstellen, der Watcher ruft sie im Prozess.
  POST /jellyfin/format          Macht seit v3.2 der Worker (medien.py), und
                                 zwar an der richtigen Stelle: er kennt den
                                 Ausgabeordner und ist nach dem Rip am Zug.
                                 Mit ihm fallen nfo_generator.py und
                                 image_downloader.py weg (sonst unbenutzt).
  GET  /stream/jobs              Der unangenehmste: erst Placebo, am 23.07.
                                 "repariert" statt entfernt - aber ein
                                 EventSource im UI gab es nie (das Dashboard
                                 nutzt setInterval(..., 4000)). Also keine
                                 harmlose Leiche, sondern eine Endlosschleife
                                 je Verbindung, die jeder aufmachen konnte.
  GET  /worker-setup/windows-gui Ohne Aufrufer seit die .exe den .bat-Umweg
                                 ersetzt hat (v3.9). install-gui.ps1 selbst
                                 lebt weiter, sie steckt in der .exe.

main.py: 1726 -> 1682 Zeilen, dazu 279 Zeilen in zwei geloeschten Modulen.

Tests halten beide Seiten fest: die vier Routen muessen WEG bleiben, und die
drei, an denen die Worker-Installation haengt (/worker-setup/paket, /windows,
/windows-exe), muessen DA sein. Ausserdem eine Doppelung entfernt - mein
eigener _sicherer_dateiname-Test aus dem Vorcommit pruefte dasselbe wie der
bestehende test_dateiname_validierung_blockt_pfad_tricks, und der war die
ganze Zeit korrekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:41:48 +02:00
Hitonabi b546c79b9e docs(savepoint): deployt und aufgeraeumt - Stand 19:30 mit Belegen
Ampel / ampel (push) Successful in 28s
Der Block behauptete "nichts ist deployt" und fuehrte Fragment wie
Rohschnitt als offene Punkte. Beides ueberholt - genau solche stehen
gebliebenen Zusagen waren das Thema dieser Sitzung.

Nachgezogen mit Belegen statt Behauptungen:
- a1aabd5 laeuft, alle 5 Container up
- GET /capabilities antwortet cpu-x264/cpu-x265/cpu-av1 - KEIN Phantom-vaapi
  mehr, dazu QEMU Virtual CPU 2.5+, 4 Kerne, sse4_2 (AVX2-Warnung greift)
- Zombie-Erkennung 19:26:09, exakt 120 s nach Start, geprueft=0
- 604 Disc-Schluessel haben den Rebuild ueberlebt
- Fragment (67.633.152 B) und der verwaiste 75-GB-Rohschnitt geloescht,
  Platte 37 GB -> 111 GB frei

Neuer Befund dabei, nicht gebaut: "Job aus der Liste entfernen" loescht
bewusst keine Dateien - richtig so, macht die Rohdaten aber unerreichbar,
weil beides nur ueber die Job-ID verbunden ist. Der Rohschnitt war danach im
UI unsichtbar und "Neu komprimieren" unmoeglich. Das UI sollte beim Loeschen
sagen, wie viel daneben liegen bleibt.

Bei den vier toten Endpunkten steht jetzt, WARUM sie tot sind (jeder ein
Ueberrest eines ersetzten Entwurfs) und dass /stream/jobs der unangenehmste
ist: keine harmlose Leiche, sondern eine Endlosschleife je Verbindung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:29:56 +02:00
Hitonabi a1aabd591d fix(test): Ampel rot - der Backslash-Test prueft nichts
Ampel / ampel (push) Successful in 28s
Meine eigene Zeile aus dem vorigen Commit. Im Quelltext stand "a\b.mkv" mit
EINEM Backslash - Python liest \b als Backspace-Zeichen, der String enthaelt
also gar keinen Backslash, und _sicherer_dateiname gab korrekt True zurueck.
Der Test behauptete, den Backslash-Pfad zu pruefen, und tat es nicht.

Jetzt ein Raw-String r"a\b.mkv".

Warum das lokal nicht auffiel: test_api_smoke.py ueberspringt sich unter
Windows selbst (main.py -> detection.py -> fcntl). Genau die Luecke, die im
Savepoint als offener Punkt steht - hier hat sie sofort zugeschlagen. Lehre:
Tests, die nur in der Ampel laufen, sind erst nach dem Push bewiesen, und
Backslashes gehoeren in Raw-Strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:12:39 +02:00
Hitonabi 8394de6926 fix(transcode): "Abbrechen" wirkt sofort statt erst beim naechsten Prozent
Ampel / ampel (push) Failing after 28s
Fund des Commanders am laufenden Akira-Job (Nachtrag im vorigen Savepoint):
Job stand auf 'canceling', HandBrake lief weiter. Gemessen 3,4 Minuten
zwischen Anforderung (18:30:17) und Bestaetigung (18:33:38) - bei langsamerem
Fortschritt entsprechend mehr.

Ursache genau wie dort beschrieben: Der Abbruch wurde nur in
datei_fortschritt geprueft, und diese Closure stieg oben sofort wieder aus,
wenn sich die Prozentzahl nicht geaendert hatte ("if gesamt == letzter[0]:
return"). Bei einem Prozent je halber Stunde hing der Abbruch also an einem
Ereignis, das eine halbe Stunde lang nicht eintrat.

run_handbrake bekommt jetzt einen eigenen abbruch_cb neben progress_cb -
dasselbe Muster, das run_makemkv schon fuer log_cb benutzt, und aus
demselben Grund: der Fortschritts-Kanal verwirft Aufrufe. Er wird bei JEDER
Ausgabezeile aufgerufen und im Worker auf 5 s gedrosselt.

Nebeneffekt, der genauso wichtig ist: Er greift auch waehrend des
Scan-Durchlaufs, der ueberhaupt keine Encode-Prozente liefert. Dort war ein
Abbruch vorher grundsaetzlich unmoeglich - und seit die Prozent-Regex den
Scan korrekt ignoriert, waere das sonst sogar schlimmer geworden.

Die Leseschleife ist als _handbrake_schleife() herausgezogen, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake pruefbar ist.
Der Test belegt: zwei Scan-Zeilen genuegen fuer den Abbruch, es muss NICHT
auf eine Encode-Zeile gewartet werden.

Savepoint nachgezogen: Der Encode laeuft nicht mehr, ein Deploy ist
gefahrlos moeglich, und die alte "SOFORT ENTSCHEIDEN"-Passage (Platte
laeuft voll, wenn der Job fertig wird) ist damit gegenstandslos. Neu darin:
die drei Wege fuer UHD, und die Warnung, dass eine /proc-Suche nach
"HandBrake" die eigene Shell mittrifft - zwei Fehlalarme in dieser Sitzung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:10:03 +02:00
Hitonabi 574354131c docs(savepoint): v3.14 - Durchsicht, mit den zwei eigenen Fehlschluessen
SAVEPOINT bekommt den Stand v3.14: der unwirksame Platten-Schutz zuerst
(wichtigster Fund), dann die gemessenen Werte, das Gebaute, die vier toten
Endpunkte als Entscheidungsvorlage - und ein Abschnitt "SOFORT ENTSCHEIDEN".

Der laufende Akira-Encode wird beim Fertigwerden mit dem DEPLOYTEN, alten
Code versuchen, 75 GB in 37 GB zu kopieren. keepOriginal steht auf true, und
den Wert hat die Aufgabe beim Start gelesen - jetzt umstellen aendert daran
nichts mehr. Der Savepoint nennt die drei Optionen mit Empfehlung.

Zwei Richtigstellungen an v3.13, beide durch Messung:
- "progress=99 ist ein Altwert aus dem Absturz" war falsch. Beide Startpfade
  setzen auf 0; der Wert kam frisch vom Scan-Durchlauf. Ein Bug, kein Ueberrest.
- Die Erwartung, _original_aufheben werde nur warnen, war falsch - siehe
  EXDEV-Fund im vorigen Commit.

Ehrlich als OFFEN markiert: Was die Platte am 25.07. mittags fuellte, ist
NICHT belegt. Es gibt keine Kopier-Reste, kein original-Verzeichnis und
keinen einzigen Log-Eintrag von _original_aufheben, das in beiden Zweigen
loggt. Der Mechanismus ist jetzt bewiesen, sein Zuschlagen an jenem Tag
nicht. Lieber offen lassen als eine dritte Vermutung aufstellen.

AGENTS bekommt Etappe 19 und einen neuen Abschnitt, der das wiederkehrende
Muster benennt: nicht aus einem Zustandswert auf einen Mechanismus
schliessen - vier belegte Faelle. Plus die Umkehrung, die diese Sitzung
gekostet hat: gleiches st_dev heisst NICHT gleicher Mount. Was ausprobierbar
ist, wird ausprobiert statt vorhergesagt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:05:38 +02:00
Hitonabi ef0a574a70 fix(worker,api): Platten-Schutz griff nicht, Fortschritt log, Auswurf tat nichts
Vier Funde aus der Durchsicht, alle auf der VM gemessen.

1. DER PLATTEN-SCHUTZ AUS c065967 WAR WIRKUNGSLOS

_original_aufheben() entschied per os.stat().st_dev, ob umgehaengt oder
kopiert werden muss. Im Worker-Container gemessen - beides gleichzeitig wahr:

    st_dev /app/temp  = 2050
    st_dev /app/media = 2050        → identisch
    os.rename(...)    → EXDEV, "Invalid cross-device link"

Der Kernel vergleicht bei rename() den MOUNT, nicht das Geraet. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die Pruefung sah "gleiches Dateisystem", uebersprang die
Platzpruefung, und shutil.move kopierte doch - 75 GB bei 37 GB frei. Der
Schutz haette genau den Schaden zugelassen, gegen den er gebaut wurde.

Jetzt wird os.rename VERSUCHT statt vorhergesagt: klappt es, ist es
umgehaengt und fertig; kommt EXDEV, steht die Kopie fest und ERST DANN wird
der Platz geprueft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels. Vier Tests in test_original_aufheben.py, darunter genau der Fall,
der die Platte fuellte. Die zwei alten Tests in test_medien.py sind dorthin
gewandert - sie taeuschten per gefaelschtem os.stat "verschiedene
Dateisysteme" vor, also genau die Annahme, an der der Schutz scheiterte.

2. DIE FORTSCHRITTSANZEIGE ZEIGTE DEN SCAN, NICHT DEN ENCODE

get_progress_from_line matchte jede Zahl vor einem Prozentzeichen. HandBrake
gibt Prozente aber in drei Phasen aus (Formatstrings aus dem Binary gelesen):

    Scanning title %d of %d, preview %d, %.2f %%          → laeuft VOR dem
                                                            Encode bis 100 %
    Encoding: task %d of %d, %.2f %%       (%.2f fps, avg  → der echte Wert
    Encoding: task %d of %d, Searching for start time, ... → Vorlauf

Dazu warf `if progress > 0` im Aufrufer jeden Wert unter 1,00 % weg. Live
beobachtet: Anzeige stand auf 99 %, der Encode bei 1,06 %; sie fiel erst auf
1, als der Encode die 1-%-Marke ueberschritt. Jetzt wird nur die
Encoding-Zeile gelesen, `task N of M` mitgerechnet (sonst springt die
Anzeige bei Zwei-Pass-Presets mitten in der Datei zurueck), und -1 heisst
"keine Angabe" - dasselbe Muster wie bei get_progress_from_prgv.

3. "AUTOMATISCHER AUSWURF" WURDE VON NIEMANDEM GELESEN

Die Einstellung (Standard: ein, "Disc nach erfolgreichem Ripping automatisch
auswerfen") kam in keiner Zeile Backend-Code vor. DVD/Blu-ray warfen deshalb
NIE aus, Audio-CDs IMMER, weil abcde `-x` fest verdrahtet bekam. Jetzt
entscheidet die Einstellung beides: wirf_disc_aus() per CDROMEJECT-ioctl
(fcntl-guarded, der native Windows-Worker laedt das Modul auch) und `-x` nur
noch, wenn gewuenscht.

4. PFAD-PRUEFUNG FIEL AUF PRAEFIX-NAMEN HEREIN

Elf Stellen prueften mit nacktem startswith(MEDIA_ROOT). "/app/media-boese/x"
beginnt mit "/app/media", liegt aber ausserhalb - betroffen waren auch
/browse und /browse/mkdir, wo der Pfad vom Nutzer kommt. Neuer
Zwillings-Helfer unter_wurzel() in api/main.py und worker/tasks.py, alle elf
Stellen umgestellt, Tests in beiden.

Nebenbefund: _zielbasis() benutzte os.path.normpath - unter Windows werden
daraus Backslashes, die MEDIA_ROOT-Pruefung greift nicht mehr, und das
gewaehlte Ziel faellt still auf den Standard zurueck. Genau die Falle, die
_arbeitsverzeichnis() drei Zeilen weiter dokumentiert und mit posixpath
vermeidet. Live war es nie (nur aus rip_disc, das auf Windows verriegelt
ist), jetzt konsistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
Hitonabi 883c1c290b fix(caps,ui): Encoder werden gemessen statt behauptet
Der Modulkopf von caps.py verspricht "ehrlich erkannt, nicht behauptet" -
erkenne_encoder() tat aber drei Mal das Gegenteil:

1. cpu-x264 und cpu-x265 standen fest verdrahtet in der Liste ("immer
   dabei"). Ein Rip-Worker OHNE HandBrake behauptete damit, komprimieren zu
   koennen; jeder Transcode dort endete sofort mit "nicht installiert".
2. 'vaapi' wurde allein wegen /dev/dri gemeldet, ohne zu pruefen, ob
   HandBrake das ueberhaupt kann. Auf der VM gemessen: das Worker-Image
   kennt svt_av1/x264/x265/mpeg4/mpeg2/VP8/VP9/theora und KEINEN einzigen
   Hardware-Encoder. Rippy haette VAAPI versprochen und dann versagt.
3. Ueber die Rechenleistung sagte es gar nichts. Genau deshalb war nicht zu
   sehen, dass ein 4K-Encode auf dieser Maschine Tage braucht: CPU-Modell
   ist das generische "QEMU Virtual CPU version 2.5+", grep -c avx2
   /proc/cpuinfo = 0, nur bis sse4_2. x265 lebt von AVX2. Gemessen an der
   Leseposition des Quellstroms: 28-55 h fuer einen Film, 3,84 von 4 Kernen
   gesaettigt. Abhilfe waere der CPU-Typ 'host' in Proxmox.

Jetzt wird die Encoder-Liste aus `HandBrakeCLI --help` geparst (Abschnitt
-e/--encoder, Formatstrings im Worker-Image gemessen - AGENTS Regel D), und
Hardware zaehlt nur, wenn Geraet UND HandBrake-Unterstuetzung da sind. Neu
gemeldet: cpu-av1 (svt_av1 ist wirklich verfuegbar) sowie CPU-Modell,
Kernzahl und Vektorbefehlsstufe je Worker.

UI: Kerne/Vektorbefehle stehen bei jedem Worker (Worker-Tab und
Einstellungen -> System), dazu die ungefilterte HandBrake-Auskunft und eine
sichtbare Warnung, wenn AVX2 fehlt - inklusive des Hinweises auf den
VM-CPU-Typ. Der Warntext liegt in lib/encoder.ts, damit er nicht doppelt
im Code steht.

Mit im gleichen Zug (dieselbe Datei): der Schalter "Alle Tracks rippen" ist
entfallen. Er war ein Placebo - abcde bekommt keine Track-Auswahl und es gab
auch keine Oberflaeche, um eine Teilmenge zu waehlen. Eine Audio-CD wurde
also immer vollstaendig gerippt, egal wie der Schalter stand. An seiner
Stelle steht jetzt die Wahrheit.

7 Tests, Testdaten sind echte HandBrake-Ausgaben von der VM. Der Parser ist
zusaetzlich gegen die vollen 697 Zeilen der echten --help gegengeprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:04:42 +02:00
Hitonabi e0cb7b3ddc feat(worker): Zombie-Erkennung - Jobs, an denen niemand arbeitet
Nach einem Absturz oder Rebuild blieb ein Job auf 'transcoding' stehen,
obwohl weder ein Prozess lief noch etwas in den Queues stand. Folge: keine
Anzeige, kein Download - und der Knopf "Neu komprimieren" fehlte, weil
_kann_neu_komprimieren (api/main.py) status=='failed' verlangt. Der Job war
unerreichbar, obwohl die Rohdateien vollstaendig dalagen.

zombies.py haelt beim Worker-Start die Jobs in 'ripping'/'transcoding'/
'canceling' gegen Celerys active/reserved/scheduled und setzt sie ehrlich
auf 'failed', wenn niemand daran arbeitet.

Drei Sicherungen, weil ein falsch getoeteter Job teurer ist als eine
stehengebliebene Leiche:
- nur beim Start (da ist "es lief nichts" eindeutig; ein periodischer Lauf
  koennte einen Job erwischen, der legitim in der Warteschlange wartet)
- 120 s Gnadenfrist (Celery stellt unbestaetigte Aufgaben erneut zu)
- Vollzaehligkeit: antworten weniger Knoten als laut Herzschlag online sind,
  wird NICHTS gewertet - sonst waere der laufende Job eines beschaeftigten
  Remote-Workers eine falsche Leiche

Die Job-ID wird per Textsuche ueber die Inspektions-Antwort gefunden, nicht
per Position: sie steht bei rip_disc an zweiter, bei transcode_files an
erster Stelle, und Celery liefert args je nach Version als Liste oder Text.

13 Tests ohne Postgres/Redis, darunter "laufender Job wird nicht angetastet"
und "schweigender Worker verhindert jedes Urteil".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:04:19 +02:00
Hitonabi 5cd1876c90 docs(savepoint): Nachtrag - 4K-Transcode abgebrochen, zwei neue Befunde
Ampel / ampel (push) Successful in 48s
Der im Uebergabestand als "laeuft" beschriebene 4K-Lauf wurde um 18:31 vom
Commander abgebrochen. Damit war die Aussage im Block darueber ueberholt -
nachgezogen, statt sie stehen zu lassen.

Zwei Befunde, beide gemessen:

1. 4K-HEVC ist auf dieser CPU nicht machbar: 18:02 bis 18:31 ergab 1 Prozent,
   hochgerechnet rund 50 Stunden fuer den Film. Zu entscheiden: UHD gar nicht
   komprimieren (Roh-MKV behalten), Hardware-Encoder (Remote-Worker mit GPU),
   oder bewusst bei 1080p bleiben. Preset-je-Disc-Typ ist richtig, reicht
   allein aber nicht.

2. Abbruch wirkt verzoegert (FEHLER, nicht behoben): Job steht seit 18:31 auf
   "canceling", HandBrake lief um 18:35 immer noch. Der Abbruch wird nur in
   datei_fortschritt geprueft, und die Closure laeuft nur bei geaenderter
   Prozentzahl ("if gesamt == letzter[0]: return"). Bei 1 Prozent alle 30
   Minuten sieht "Abbrechen" bis zu eine halbe Stunde lang wirkungslos aus.
   Der Abbruch gehoert unabhaengig vom Fortschritt geprueft.

Ausserdem festgehalten: die 1080p-Fassung wurde von HandBrake beim Start auf
0 Bytes gekuerzt, im Akira-Ordner liegt derzeit ein unbrauchbares Fragment.
Der 75-GB-Rohschnitt ist unversehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:32:34 +02:00
Hitonabi b526a0a033 docs(savepoint): Uebergabestand v3.13 - gemessen vs. vermutet getrennt
Ampel / ampel (push) Successful in 46s
Uebergabe an eine neue Sitzung. Der Block trennt bewusst, was per Befehl auf
der VM geprueft wurde, von dem was offen bzw. nur erwartet ist - inklusive
zweier Fehlschluesse dieser Sitzung, damit die naechste Sitzung weiss, welchen
Aussagen sie nicht blind glauben soll.

Kern:
- Ein 4K-HandBrake-Lauf laeuft GERADE (Akira, Preset "H.265 MKV 2160p60 4K").
  Die vorherige 1080p-Datei wurde dabei auf 0 Bytes gekuerzt; der 75-GB-
  Rohschnitt liegt unversehrt in /app/temp/raw.
- progress=99 am Job ist ein Altwert aus dem Absturz, NICHT der echte Stand.
- Noch nicht bewiesen: dass _original_aufheben() im Ernstfall nur warnt statt
  die Platte vollzuschreiben. Genau dieser Pfad ist neu.
- Gefunden, nicht gebaut: Zombie-Erkennung. Nach dem Absturz stand der Job auf
  "transcoding", obwohl kein Prozess lief und beide Celery-Queues leer waren.
  _kann_neu_komprimieren verlangt status == "failed", deshalb fehlt dem Nutzer
  auch der "Neu komprimieren"-Knopf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:08:51 +02:00
Hitonabi 8bb075c656 feat(rip): Arbeitsverzeichnis je Rip waehlbar statt global vorgegeben
Ampel / ampel (push) Successful in 28s
Commander-Vorgabe 25.07.2026: "Du sollst es nicht automatisch setzen, es soll
auswaehlbar sein - z. B. beim Rippen starten, oder VORHER wenn Vollautomatik
eingeschaltet ist."

Bisher gab es nur die globale Einstellung workDir, und die war ein freies
Textfeld - man musste den Container-Pfad (/app/media/...) kennen. Beim
Akira-Rip landeten deshalb 74 GB Rohdaten auf der 148-GB-VM-Platte, obwohl
eine NAS-Freigabe mit 2,3 TB eingehaengt war.

- RipTargetModal: neue Auswahl "Arbeitsverzeichnis fuer die Rohdaten",
  gespeist aus /storage-targets (mit Kennzeichnung als Netzwerk-Freigabe und
  freiem Platz je Ziel). Leer = "Standard aus den Einstellungen", der Wert
  wird zur Orientierung mit angezeigt. Bei Musik ausgeblendet - CD-Rips gehen
  direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
- POST /jobs nimmt work_dir entgegen, mit derselben Pfad-Haerte wie das Ziel
  (_validiere_ziel: muss unter /app/media liegen), und legt es in die
  Job-Metadaten.
- _arbeitsverzeichnis(einstellungen, job_wahl) im Worker: Wahl dieses Rips ->
  Setting -> Container-Default. Damit bleibt die Einstellung genau das, was
  bei Vollautomatik-Rips greift, weil dort niemand gefragt wird. Der Text im
  Einstellungen-Tab sagt das jetzt auch so.
- posixpath statt os.path in _arbeitsverzeichnis: das sind immer
  Container-Pfade, auch wenn ein nativer Windows-Worker das Modul laedt (der
  uebersetzt erst spaeter per pfad_lokal). os.path.normpath machte unter
  Windows Backslashes daraus, wodurch die MEDIA_ROOT-Pruefung nicht mehr
  griff - lokal als Testfehler aufgefallen.
- Test deckt die Reihenfolge und die Ausbruchsversuche ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:59:37 +02:00
Hitonabi c065967f4d fix(ui,transcode): Dialog-Falle, Arbeitsverzeichnis waehlbar, Original-Aufheben entschaerft
Ampel / ampel (push) Successful in 28s
Drei Befunde aus dem ersten echten UHD-Durchlauf (Akira). Der Rip und die
Kompression liefen sauber durch - danach hat "Original behalten" die Platte
vollgeschrieben und den fertigen Job als fehlgeschlagen markiert.

1. ORIGINAL-AUFHEBEN DARF DEN JOB NICHT MEHR TOETEN (tasks.py)
   Vorher stand dort ein nacktes shutil.move(raw_dir, ziel). Arbeits-
   verzeichnis (/app/temp, Docker-Volume) und Ziel (/app/media, Bind-Mount)
   sind VERSCHIEDENE Dateisysteme - os.rename scheitert mit EXDEV, shutil.move
   faellt auf Kopieren zurueck. Ergebnis am 25.07.: 74-GB-Vollkopie auf
   dieselbe Platte, Abbruch bei 41 GB mit ENOSPC, Platte 100 % voll, Worker-
   Container startete nicht mehr ("failed to mount: no space left on device"),
   und der Job galt als FEHLGESCHLAGEN - obwohl die komprimierte Datei
   (4,8 GB) fertig und in Ordnung war. Der Nutzer sah nur eine leere Queue.
   Jetzt: _original_aufheben() prueft erst, ob ueberhaupt kopiert werden muss
   (gleiches Dateisystem -> reines Umhaengen), prueft sonst den freien Platz
   VORHER, faengt jeden OSError ab, raeumt eine halbe Kopie weg und meldet das
   als WARNUNG. Der Job bleibt erfolgreich, die Roh-Datei bleibt liegen.
   Zwei Tests decken beide Wege ab.

2. ARBEITSVERZEICHNIS IST JETZT WAEHLBAR (Settings.tsx)
   Es war ein freies Textfeld - man musste den Container-Pfad (/app/media/...)
   KENNEN, um eine Netzwerk-Freigabe zu treffen. Genau daran ist es
   gescheitert, weshalb der 74-GB-Rohschnitt ueberhaupt erst auf der VM-Platte
   landete. Jetzt eine Auswahl aus /storage-targets (dieselbe Liste wie bei
   den Speicherzielen), inklusive Kennzeichnung als Netzwerk-Freigabe und
   freiem Platz je Ziel.

3. DIALOGE KLEBTEN IM PANEL (ui/Modal.tsx)
   "Rippen starten" im Laufwerke-Tab oeffnete den Dialog INNERHALB des
   Bereichs, teils abgeschnitten. Ursache: .glass-panel in index.css setzt
   backdrop-filter: blur(16px), und ein Element mit backdrop-filter wird zum
   Bezugsrahmen fuer position: fixed seiner Nachfahren - das Modal war damit
   an der Card ausgerichtet statt am Fenster. Modal rendert jetzt per
   createPortal an document.body. Behebt es fuer ALLE Dialoge auf einmal.

Aufgeraeumt: die abgebrochene 41-GB-Teilkopie unter
"/app/media/movies/Akira (1988)/original/" geloescht (nachweislich
unvollstaendig - 41 GB gegen 79,6 GB Quelle, Quelle intakt). Platte wieder
bei 78 %, Container laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:48:10 +02:00
Hitonabi e84afc1718 feat(transcode): ein HandBrake-Preset je Disc-Typ statt eines fuer alles
Ampel / ampel (push) Successful in 29s
Rueckfrage des Commanders beim ersten echten UHD-Rip: "merkt Rippy, dass es
eine UHD ist, und nimmt direkt das 4K-Preset?" Antwort war nein. Die
Kompression fragte den Disc-Typ ueberhaupt nicht:

    preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET

Live eingestellt war "HQ 1080p30 Surround". Der gerade laufende Akira-Rip
waere also verlustfrei in 4K gerippt und danach auf 1080p heruntergerechnet
worden - und mit keepOriginal=False waere der 4K-Rohschnitt danach geloescht
worden. Umgekehrt wurde eine DVD auf 1080p hochskaliert, was nichts bringt.

- preset_fuer(disc_type, einstellungen) in ripping.py, pure und getestet.
  Reihenfolge: Preset des Disc-Typs -> allgemeines transcodePreset ->
  DEFAULT_HB_PRESET. Bestandsinstallationen aendern ihr Verhalten NICHT,
  solange die neuen Felder nicht gespeichert sind.
- Drei Einstellungen: transcodePresetDvd / transcodePresetBluray /
  transcodePresetUhd. transcodePreset bleibt als Rueckfall bestehen.
- transcode_files holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
  mit ins Log ("Disc-Typ 'uhd', Preset '...'").
- UI: drei Auswahlfelder statt einem, mit Klartext dazu, warum eine 4K-UHD
  auf ein 2160p-Preset gehoert.

Preset-Namen stammen aus "HandBrakeCLI --preset-list" im Worker-Image
(HandBrake 1.6.1) - nicht aus dem Kopf (AGENTS Regel D):
H.265 MKV 2160p60 4K, HQ 2160p60 4K HEVC Surround,
Super HQ 2160p60 4K HEVC Surround, H.265 MKV 1080p30, HQ 1080p30 Surround,
Super HQ 1080p30 Surround, H.265 MKV 576p25, H.265 MKV 480p30,
HQ 576p25 Surround.

Sofortmassnahme am laufenden Job (auf Ansage des Commanders): keepOriginal
auf True gesetzt - nur dieses eine Feld, gegengeprueft dass kein anderer
Schluessel veraendert wurde. Damit ueberlebt der 4K-Rohschnitt die
Kompression.

ACHTUNG - Deploy bewusst NICHT ausgefuehrt: docker compose up -d --build
wuerde den Worker-Container neu erstellen und den laufenden Akira-Rip
abbrechen. Erst nach Abschluss des Jobs deployen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:38:52 +02:00
Hitonabi f449c4ee34 fix(uhd): 4K-UHD geloest - makemkvcon holt Schluessel unter Linux nie
Ampel / ampel (push) Successful in 28s
Richtigstellung des Vortags-Befunds. Dort stand, MakeMKVs Schluessel-Kanal
sei abgeschaltet. Das war FALSCH: die Herleitung stuetzte sich auf zwei
Hostnamen aus alten Forumsbeitraegen (hkdata.fairuse.org,
hkdata.crabdance.com), die zwar wirklich nicht mehr aufloesen, von MakeMKV
aber laengst nicht mehr benutzt werden. Aufgedeckt durch den Einwand des
Commanders, unter Windows ginge es sofort.

Gegenprobe mit demselben Laufwerk und derselben Disc (Akira UHD, MKB v76):

                        Linux (Worker)      Windows
  Verbindungen          KEINE EINZIGE       185.84.108.20:443
  Meldung 3338          nie                 "Downloading latest HK"
  _private_data.tar     2048 B, 0 Keys      6,4 MB, 604 Keys
  Disc                  volume key unknown  TCOUNT:5, geht auf

Gegengeprueft mit leerem UND gefuelltem Speicher, mit und ohne --noscan,
mit dev:/dev/sr0 und disc:0, mit geloeschter update.conf. Linux fragt nie.
Die Meldungsvorlage "Downloading latest %1 to %2 ..." steckt sehr wohl im
Linux-Binary - sie loest nur nicht aus. Gleiches Symptom im MakeMKV-Forum,
seit Jahren offen (t=25782, t=34022). Der Dienst lebt; der Worker erreicht
185.84.108.20:443 sogar problemlos.

BEWIESEN: Nach Uebernahme des Windows-Schluesselspeichers oeffnet
makemkvcon auf der VM die Akira-UHD - "Operation successfully completed",
TCOUNT:5, fuenf Titel, identisch zum Windows-Ergebnis. Erster belegter
UHD-Disc-Zugriff auf der Rippy-Maschine.

- makemkv_daten.py (beide Zwillinge): zaehle_schluessel,
  private_data_pruefen, schluesselspeicher_status, private_data_schreiben.
  Die Pruefung lehnt einen Speicher OHNE hkd_*.bin ab - sonst laedt jemand
  den leeren Vorrat einer frischen Installation hoch, nichts aendert sich,
  und niemand versteht warum. Modulkopf komplett neu, inkl. der
  Fehldiagnose als Warnung fuer spaeter.
- API: GET/POST /system/keystore. Der Rohkoerper der Anfrage IST die Datei
  (binaer - JSON/Base64 waere Ballast, Multipart kann die API nicht).
  Groessengrenze 64 MB = client_max_body_size in nginx.conf.
- UI: neuer Block "Disc-Schluessel fuer 4K-UHD" UEBER dem KEYDB-Block, mit
  Schluessel-Anzahl, Upload und Anleitung fuer den Windows-Weg. KEYDB.cfg
  ist jetzt als Notnagel beschriftet. Worker-Plakette zeigt die Anzahl;
  0 heisst sichtbar "4K-UHD scheitert".
- tasks.py: UHD-Fehlertext sagt den Windows-Weg an und nennt die Anzahl
  bekannter Schluessel dieses Workers.
- caps.py meldet schluessel je Worker.
- Alle Falschaussagen korrigiert: UI (3), Anleitung (2), README (3),
  KONZEPT §8 + §10, Worker-Dockerfile, makemkv_key.py (dort stand "Den
  AACS-Schluessel zieht MakeMKV via LibreDrive ohnehin selbst aus dem
  Laufwerk" - gilt fuer Blu-ray, NICHT fuer UHD).
- SAVEPOINT v3.11, ROADMAP Etappe 18 (Etappe 17 mit Nachtrag), AGENTS.

Offen: voller UHD-Rip inkl. Transcode-E2E; und ob sich der Abruf unter
Linux doch anstossen laesst.

Quellen (AGENTS Regel D):
- Linux laedt keine Hashed Keys, gleiches Symptom:
  https://forum.makemkv.com/forum/viewtopic.php?t=25782
  https://forum.makemkv.com/forum/viewtopic.php?t=34022
- Schluessel als hkd_*.bin in _private_data.tar:
  https://forum.makemkv.com/forum/viewtopic.php?t=32675
- Meldungsformat: https://www.makemkv.com/developers/usage.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:18:09 +02:00
Hitonabi 0935766f61 feat(uhd): KEYDB.cfg-Unterstuetzung - MakeMKVs Schluessel-Kanal liefert nichts mehr
Ampel / ampel (push) Successful in 28s
4K-UHD scheiterte an "The volume key is unknown for this disc". Am 25.07.2026
im Worker-Container nachgemessen: Laufwerk und MakeMKV sind in Ordnung
(LibreDrive v06.3 / Meldung 1011, Disc wird gelesen, AACS-Dump geschrieben) -
MakeMKV versucht gar nicht erst, online einen Schluessel zu holen. Belege:
_private_data.tar enthaelt nur die Index-Datei und KEINE hkd_*.bin; auch mit
geloeschter update.conf (Meldung 5074 belegt den Web-Kontakt) und mit
app_UpdateEnable="1" kam keiner; hkdata.fairuse.org und hkdata.crabdance.com
loesen weltweit nicht mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8, 1.1.1.1).
Betroffen war Akira UHD (MKB v76, Pressung Dez. 2020) - also gerade KEINE
Neuerscheinung. Der bisherige Fehlertext ("Disc neuer als die
Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war falsch.

Einziger heute funktionierender Weg ist eine vom Nutzer selbst mitgebrachte
KEYDB.cfg. Rippy liefert KEINE Schluessel mit, laedt keine herunter und
verteilt keine - es stellt nur den Platz bereit und zeigt an, was dort liegt.

- Datenverzeichnis persistent gemountet (MAKEMKV_DATA_HOST, Default
  /srv/rippy/makemkv): KEYDB.cfg und AACS-Dumps ueberleben jeden Rebuild.
  Vorher loeschte jeder "up -d --build" beides - inklusive des Dumps, auf den
  die Fehlermeldung selbst verwies.
- entrypoint.sh und tasks.py schreiben settings.conf ergaenzend statt
  zerstoerend. Der entrypoint bricht bei nicht beschreibbarem Verzeichnis
  nicht mehr ab - mit "restart: unless-stopped" waere das ein Crashloop
  gewesen, in dem auch reines DVD-Rippen tot ist.
- Neues Zwillings-Modul makemkv_daten.py (docker/api + docker/worker,
  byteweise identisch; test_zwillinge_sind_byteweise_identisch wacht darueber
  und wurde durch absichtliches Verstellen als wirksam nachgewiesen).
- API: GET/POST/DELETE /system/keydb, GET /system/aacs-dumps(/{dateiname}).
  JSON-Body statt Multipart - python-multipart ist bewusst nicht installiert
  und wuerde die API beim Import toeten. nginx client_max_body_size 64m,
  sonst scheitert der Upload mit 413, bevor die API ihn sieht.
- UI (Einstellungen -> System): Status, Hochladen per Datei-Dialog, Entfernen,
  Dump-Download, KEYDB-Plakette je Worker (nur wo das Verzeichnis wirklich
  gemountet ist - ein Remote-Transcode-Worker truege sonst eine Warnung,
  die ihn nichts angeht).
- parse_msg() + log_cb: MakeMKV-Meldungen landen im Rippy-Log (gedrosselt:
  Code 1003 raus, keine Wiederholungen, max. 40 je Rip). Nebenbei behoben:
  der alte Parser (split(",", 4)[3]) schnitt jede Meldung am ersten Komma ab.
- Fuenf Stellen richtiggestellt, die behaupteten, MakeMKV-Updates braechten
  die neueste Disc-Schluessel-Datenbank mit (UI, Anleitung, README,
  Worker-Dockerfile, makemkv_key.py).

NICHT bewiesen: ein erfolgreicher UHD-Rip - es lag keine KEYDB.cfg mit dem
Akira-Schluessel vor. Belegt sind der Befund und die neue Mechanik. So steht
es auch im SAVEPOINT und in der ROADMAP.

Quellen (AGENTS Regel D):
- Datenverzeichnis + Dateiname GROSS/case-sensitiv:
  https://forum.makemkv.com/forum/viewtopic.php?t=30636
- hkd_*.bin in _private_data.tar:
  https://forum.makemkv.com/forum/viewtopic.php?t=32675
- headless settings.conf / app_UpdateEnable:
  https://forum.makemkv.com/forum/viewtopic.php?t=20364
- KEYDB.cfg-Zeilenformat (libaacs):
  https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
- MSG-/PRGV-Format: https://www.makemkv.com/developers/usage.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:26:08 +02:00
Hitonabi f74f4e54f6 fix(api): Job-Meta (poster_path) an die Jobliste durchreichen
Ampel / ampel (push) Successful in 32s
GET /jobs nutzt response_model=List[Job]; das Job-Model hatte kein meta-Feld,
also schnitt FastAPI die Disc-Metadaten (poster_path) weg -> Dashboard.tsx
bekam job.meta = undefined -> Filmstreifen-Platzhalter statt TMDB-Poster,
sowohl in der Jobliste als auch im aktiven Rip-Header (beide aus /jobs).

Additiv: meta: Optional[Dict] ins Job-Model + in _job_row_to_model parsen
(json.loads wie im Detail-Endpunkt, defensiv gegen kaputtes JSON). Keine
UI-Aenderung noetig -- posterUrl() rendert dann die vorhandenen Poster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:03:41 +02:00
Hitonabi 0832ef777c chore(handoff): portabel machen + Doku/Onboarding glaetten (Review-Umsetzung)
Ampel / ampel (push) Successful in 27s
Aus dem Weitergabe-Review. Kein Verhaltenswechsel fuer den Ursprungs-Host
(alle Defaults = bisheriges Verhalten):

Portabilitaet:
- Laufwerk-Geraeteknoten via .env parametrisiert (OPTICAL_SR/OPTICAL_SG,
  Defaults sr0/sg1) - der #1-Blocker: auf Fremdhosts liegt das sg-Node woanders.
- deploy.sh generisch (RIPPY_VM/RIPPY_REPO_URL/... aus der Umgebung) - keine
  festen Besitzer-Adressen (interne IP + DDNS) mehr im Repo.
- POSTGRES_PASSWORD in compose durchverdrahtet (Default rippy) - war No-op.

Aufraeumen (toter/irrefuehrender Code):
- udev/ entfernt (fehlende Regeldatei, falscher Container-Name; durch ioctl-
  Polling ersetzt - reine Altlast).
- docker/postgres/*, docker/redis/*, init.sql entfernt (nie eingebunden).

Onboarding:
- FirstRunWizard verdrahtet: App.tsx fragt GET /setup und zeigt den
  Einrichtungs-Assistenten beim ersten Start (war gebaut, aber nie gerendert).

Doku:
- README: Laufwerk-Abschnitt auf .env, ENV-Tabelle (OPTICAL_*/POSTGRES),
  rshared-Anleitung, neuer Abschnitt "Haertung fuer fremde/exponierte Netze".
- config_validation.py: TMDB Pflicht->empfohlen, Zeichensalat-Tippfehler gefixt.
- .env.example: TMDB-Wording, OPTICAL_*-Variablen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:32:59 +02:00
Hitonabi 6a082cf77c fix(mounts): mounten() idempotent - stale/tote Mounts vor Re-Mount loesen
Ampel / ampel (push) Successful in 28s
Folgefix zum remount-Vorfall 24.07.: mounten() fiel bei einem TOTEN Mount
(os.path.ismount wirft OSError) auf den echten `mount` durch und stapelte auf die
Leiche. Ueber viele Neustarts (via rshared propagiert, ueberlebt Container-Recreate)
wuchs das auf 12 Schichten; die tote oberste blockierte jeden Zugriff (ls-Timeout,
obwohl SMB-445 offen) -> Medien-Mount unbrauchbar.

Fix: _stale_mounts_loesen(ziel) loest per lazy `umount -l` alle Schichten, bevor neu
gemountet wird -> kein Stapeln mehr, Re-Mount idempotent. Ein gesunder Mount wird
weiterhin frueh erkannt (os.path.ismount) und unangetastet gelassen.

Tests (test_mounts_helpers.py): Loesch-Schleife bis leer (monkeypatch) + Verdrahtung.
Ruff gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:04:00 +02:00
Hitonabi 46a8f50c34 fix(api): remount nicht-blockierend (Netz-Mount darf API-Start nicht haengen)
Ampel / ampel (push) Successful in 28s
Vorfall 24.07.: Beim API-Start blockierte der synchrone CIFS-Schreibtest in
alle_remounten()/mounten() im Kernel (wait_for_response), als der SMB-Server
langsam war -> ~5 min "Waiting for application startup", kein Endpoint bedient
(bis der soft-Mount per Timeout abbrach). startup_event() lief isoliert sauber,
also war es der blockierende Netz-Mount, nicht die App-Logik.

Fix: remount als Hintergrund-Task (asyncio.create_task) statt await -> die API
kommt sofort hoch, die Mounts stellen sich her sobald der Server antwortet.
Test (test_api_smoke.py): haelt den Nicht-blockierend-Vertrag per Quelltext-
Inspektion fest, im Stil der anderen Verdrahtungs-Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:50:32 +02:00
Hitonabi 3f47b504c2 feat(api): MakeMKV-Beta-Key automatisch erneuern
Ampel / ampel (push) Successful in 28s
Der kostenlose Beta-Key wechselt ~monatlich und laeuft zum Monatsende ab; bisher
musste er von Hand nachgetragen werden, sonst blockt Blu-ray-Ripping irgendwann
still. Neu: taeglicher Forum-Abgleich (t=1053) -> Key in die Settings
(makemkvAppKey). Der Worker liest ihn VOR jedem Rip (tasks.py) -> wirkt ohne
Rebuild ab dem naechsten Rip.

- docker/api/makemkv_key.py: extract_key (pure, testbar) + fetch_current_key +
  refresh_once/refresh_loop; loggt in die App-Logs (add_log).
- docker/api/main.py: refresh_loop als startup-Task.
- docker/api/test_makemkv_key.py: Parser-Tests - fingen den Bug, dass Beta-Keys
  laenger als 64 Zeichen sind (Regex {50,} statt {64}).

Betrifft nur die Software-Lizenz (oeffentlicher Gratis-Key), NICHT Disc-Schluessel
- die zieht MakeMKV via LibreDrive selbst. Ruff gruen, 4 Tests gruen, Live-Fetch
gegen das echte Forum verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:27:38 +02:00
Hitonabi 71c618073e docs: Anleitung auf .exe-Installer, SAVEPOINT v3.9
Ampel / ampel (push) Successful in 36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:20:28 +02:00
Hitonabi 20cdfedb85 feat(installer): fertige .exe mit Rippy-Icon + ohne Konsolenfenster (statt .vbs)
Ampel / ampel (push) Successful in 28s
Commander-Einwand: .vbs ist abgekuendigt (Win11 24H2 Feature-on-Demand) und
wird oft als gefaehrlich geflaggt. Loesung: echte .exe.
- RippyWorkerSetup.exe (50 KB): install-gui.ps1 via ps2exe kompiliert —
  eingebettetes Rippy-Disc-Icon (deploy/worker-windows/rippy.ico), kein
  Konsolenfenster (-noConsole), WinForms (-STA). E2E getestet: Fenster
  oeffnet, conhost-Zaehler unveraendert (kein Konsolenfenster).
  Vorgebaut auf Windows (build-exe.ps1) + committet — eine Windows-.exe
  kann nicht von Linux (Rippy-Host) gebaut werden. WICHTIG: friert KEINE
  Versionen ein — HandBrake wird zur Laufzeit (GitHub latest) und der
  Worker-Code live von Rippy geholt; nur bei GUI-Aenderungen neu bauen.
- API: GET /worker-setup/windows-exe (FileResponse); Dockerfile kopiert die
  .exe ins Image. .gitattributes: *.exe/*.ico als binary.
- UI (Worker -> Windows): 'Installer herunterladen (.exe)' als Haupt-Weg
  (generisch, GUI fragt Adresse ab; die einzutragende IP wird als Hinweis
  angezeigt). .vbs-Erzeugung entfernt. CLI bleibt Profi-Alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:15:42 +02:00
Hitonabi b1d9ece9b2 feat(installer): kein Konsolenfenster (.vbs-Starter) + Rippy-Icon im Installer-Fenster
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: kein Konsolenfenster + Icon.
- Starter ist jetzt eine .vbs (statt .bat): wscript startet PowerShell mit
  Fensterstil 0 = KOMPLETT versteckt, kein Konsolenfenster. Nur das
  WinForms-Installer-Fenster erscheint. Download-Datei: rippy-worker-setup.vbs
  (weiter CLIENT-seitig mit eingetragener LAN-IP erzeugt).
- install-gui.ps1: Fenster-Icon = Rippy-Disc (zur Laufzeit gezeichnet,
  Indigo-Kreis + weisses Loch) — erscheint in Titelleiste + Taskleiste.
  (Die Datei selbst kann Windows-bedingt kein Icon tragen — nur .exe/.lnk.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:03:19 +02:00
Hitonabi b337525bed docs+ui: grafischen Windows-Installer in Anleitung, SAVEPOINT v3.8
Ampel / ampel (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:52:57 +02:00
Hitonabi 4fb29d16cf feat(worker): grafischer Windows-Installer (GUI statt CLI)
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: die CLI-Installation ist grausam. Jetzt:
- install-gui.ps1 (WinForms, ASCII-only): Fenster mit Feldern (Rippy-IP,
  Worker-Name, Autostart-Checkbox), Install-Knopf mit Live-Log, danach
  'Worker starten'-Knopf. Gleiche Schritte wie der CLI-Installer
  (Worker-Code + HandBrake latest + venv + Tray/Start/Uninstall).
  Braucht nur Python 3.10+, keine externe GUI-Runtime.
- API: GET /worker-setup/windows-gui serviert die GUI; Dockerfile kopiert
  sie ins worker_dist/.
- UI (Worker-Tab, Windows): 'Grafischen Installer herunterladen (.bat)' als
  Haupt-Weg — die .bat wird CLIENT-seitig mit der eingetragenen LAN-IP
  erzeugt (Blob-Download), Doppelklick laedt+startet die GUI von Rippy.
  Der CLI-Befehl bleibt als Profi-Alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:47:57 +02:00
Hitonabi 57171b4c88 ui(system): Versionslage exakt darstellen — MakeMKV aktualisierbar, Docker-HandBrake bewusst Debian-stabil
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: das UI soll die Werkzeug-Versionslage genau so zeigen,
wie sie ist. Klarstellung im System-Tab:
- Hinweis bei 'Installierte Werkzeuge': MakeMKV aktuell haltbar (wichtig
  wegen Schluessel-DB), HandBrake im Docker-Worker bewusst Debian-stabil
  (kein separates Update), native Worker holen die neueste.
- Update-Check: MakeMKV mit gruenem '✓ aktuell' bzw. Update-Befehl (echtes
  To-do). HandBrake NICHT mehr als 'Update noetig' alarmieren — neutral als
  'Debian-Version, bewusst so' + Info, dass die nativen Worker die neueste
  nutzen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:42:01 +02:00
Hitonabi 3aef344b95 docs: SAVEPOINT v3.7 — Mount-Reparatur, HandBrake-Konsistenz, Encoder-Wahl (live bewiesen)
Ampel / ampel (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:34:12 +02:00
Hitonabi dc1ceda0c2 fix(capabilities): Node-Zuordnung eindeutig bei mehreren Workern pro Host
Ampel / ampel (push) Successful in 28s
Laufen zwei Worker auf demselben Rechner (Befund 24.07.: alter + neuer
Windows-Worker auf TobisNicerPC), war die Node-Zuordnung mehrdeutig — der
Hostname-Match traf irgendeinen. Jetzt disambiguiert der Name-Teil des
Celery-Knotens (WORKER_NAME), sonst Fallback auf den ersten Host-Treffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:33:22 +02:00
Hitonabi 2de0899ac6 fix(installer): install.ps1 ASCII-sauber (Gedankenstrich zerschoss das Skript)
Ampel / ampel (push) Successful in 28s
PowerShell 5.1 liest .ps1 als ANSI, nicht UTF-8. Der Gedankenstrich '—' in
einem Write-Host-String dekodierte zu 'â€"' — das eingebettete Anfuehrungs-
zeichen zerriss die Zeichenkette und der Parser scheiterte (unerwartetes
Token). Alle Nicht-ASCII-Zeichen durch ASCII ersetzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:27:51 +02:00
Hitonabi 47d07d6168 fix(mounts): Reparatur crashte an makedirs + reachable-Check auf 3s begrenzt
Ampel / ampel (push) Successful in 28s
- mounten(): FileExistsError von os.makedirs abgefangen und ismount OSError
  toleriert — ein toter Mount taeuschte isdir, die Reparatur brach ab.
- ist_erreichbar(): 'timeout 3 ls' statt os.listdir — der direkte Zugriff
  blockierte sonst ~10s bis zum CIFS-Timeout (traege Speicherziele-Seite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:23:12 +02:00
Hitonabi 4ba02047db Drei Praxis-Bugs: tote NAS-Mounts reparierbar, HandBrake-Versionen konsistent, Encoder-Wahl beim Rip
Ampel / ampel (push) Successful in 28s
1) Speicher-Mounts robust (Befund: toter CIFS-Mount nach NAS-Ausfall/Rebuild —
   mounted:false, verschwand aus 'Verfuegbare Ziele', Neu-Anlegen -> 409, man
   sass fest):
   - mounts.py: ist_erreichbar() (listdir, soft-Mount bricht schnell ab),
     ist_gemountet() faengt OSError toter Mounts, aushaengen() mit
     umount -l Fallback, reparieren() (lazy abhaengen + frisch mounten).
   - /storage-mounts liefert 'reachable'; POST bei existierendem Namen:
     aktiv -> 409, tot -> automatische Reparatur mit neuen Angaben;
     neuer POST /storage-mounts/{name}/repair (gespeicherte Zugangsdaten).
   - /storage-targets crasht nicht mehr an totem Mount (os.path.ismount
     OSError abgefangen).
   - UI: eigene 'Netzwerk-Mounts'-Liste mit Status (aktiv/nicht erreichbar/
     getrennt) + Reparieren- und Entfernen-Knopf — tote Mounts sind sichtbar
     und wiederherstellbar statt zu verschwinden.

2) HandBrake-Versionen konsistent (Befund: Docker 1.6.1, Windows-Skript
   fest 1.9.2, Update-Check meldet 1.11.2 — verwirrend):
   - Windows-Installer zieht jetzt DYNAMISCH die neueste Version (GitHub
     latest, Fallback 1.11.2) — passt zum Update-Check.
   - Update-UI erklaert klar: Docker = stabiles Debian-Paket (bewusst aelter,
     kein Fehler), Windows = neueste. MakeMKV-Update zeigt den Befehl.

3) Encoder-/Worker-Auswahl beim Rip (Feature):
   - Celery worker_direct=True: jeder Worker konsumiert zusaetzlich seine
     Direkt-Queue. API-Helper transcode_queue(node) routet gezielt an den
     gewaehlten Worker, faellt aber sicher auf die geteilte transcode-Queue
     zurueck, wenn er offline ist (kein Haengenbleiben).
   - /capabilities liefert den Celery-Node je Worker; POST /jobs nimmt
     transcode_node (-> Job-meta); rip_disc + retry-transcode routen danach.
   - Rip-Dialog: Encoder-/Worker-Dropdown, sichtbar ab 2 Online-Workern.
   - ping_worker-Task zum Verifizieren des gezielten Routings.
   - Nebenfund gefixt: DeviceDiscovery leitete die Titel-Auswahl (titles)
     gar nicht an die API weiter — jetzt titles + transcode_node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:19:57 +02:00
Hitonabi fe1387b1ec fix(ui): typLabel in DeviceDiscovery definiert (Laufwerks-Details-Button gefixt) & Settings Tab-Wechsel robuster gemacht
Ampel / ampel (push) Successful in 28s
2026-07-24 17:04:18 +02:00
Hitonabi 67a4a76afa fix(ui): Worker-Adressfeld nicht mehr mit window.location vorbelegen
Ampel / ampel (push) Successful in 28s
Befund (Commander, Screenshot): Das Feld 'Rippy-Adresse' fuellte sich
automatisch mit window.location.hostname — also der Adresse, ueber die man
das UI geoeffnet hat (bei ihm die eigene PC-IP hinter einem Forward/Proxy,
192.168.178.98). Das ist NICHT zwingend die direkte LAN-IP der
Rippy-Maschine, die der Worker fuer Redis/Postgres (6379/5432) braucht —
der generierte Befehl zeigte damit auf den falschen Host ('Verbindung
verweigert').

Fix:
- Kein Auto-Default mehr (Feld leer); leeres Feld -> sichtbarer
  Platzhalter <RIPPY-IP> im Befehl (erkennbar unvollstaendig statt still
  falsch).
- Label auf 'LAN-IP der Rippy-Maschine (Docker-Host)' geschaerft +
  dauerhaft sichtbarer Hilfetext: die IP der Rippy-Maschine, NICHT dieses
  PCs, und auch dann die direkte LAN-IP wenn das UI ueber Domain/Proxy
  geoeffnet wurde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:53:12 +02:00
Hitonabi b130eeb967 feat(ui): Master-Panel 'Ripping Operations' DIREKT unter Laufwerke verschoben
Ampel / ampel (push) Successful in 28s
2026-07-24 16:44:33 +02:00
Hitonabi bf80f9dfd4 refactor(ui): Current Job, Ripping Queue und Job-Historie in ein einziges Master-Panel bündig unter Laufwerke zusammengeführt
Ampel / ampel (push) Successful in 27s
2026-07-24 16:43:10 +02:00
Hitonabi e0864828c2 style(ui): Ripping Queue Kachel per flex-1 vertikal gestreckt fuer exakt buendigen unteren Abschluss mit Recently Ripped
Ampel / ampel (push) Successful in 28s
2026-07-24 16:41:26 +02:00
Hitonabi 60abf42359 feat(ui): Laufwerke & im Laufwerk erkannte Disc nach ganz oben verschoben, Ripping Queue exakt nach SC1 aufgebaut
Ampel / ampel (push) Successful in 28s
2026-07-24 16:39:56 +02:00
Hitonabi e18315814c feat(ui): 5-Kachel-Banner ohne schwarze Loecher gefuellt, Vollstaendige Job-Tabelle mit TMDB-Beschreibungen wiederhergestellt
Ampel / ampel (push) Successful in 28s
2026-07-24 16:36:12 +02:00
Hitonabi 9b19b74d39 fix(ui): 100% Dark Mode erzwungen (White Mode entfernt), Echte API-Daten fuer Server Status gebunden & Tabs-Breite/Scrollbalken behoben
Ampel / ampel (push) Successful in 28s
2026-07-24 16:33:50 +02:00
Hitonabi 9bc8ad11b2 fix(ui): Zentrierung fuer Anleitung & Einstellungen gefixt, zufaellige Mockup-Filme durch echte API-Daten ersetzt
Ampel / ampel (push) Successful in 28s
2026-07-24 16:31:45 +02:00
Hitonabi 0f20d9d8f4 feat(ui): Rebuild Layout EXACTLY 1:1 matching SC1 Mockup (Top Header Nav, 5-Panel Movie Wall, 2-Column Dashboard Grid with Sparkline & Recently Ripped)
Ampel / ampel (push) Successful in 28s
2026-07-24 16:25:21 +02:00
Hitonabi f0a8455fc2 style(ui): Visuelle Hero Showcase Banner & Poster Cards Grid auf dem Dashboard
Ampel / ampel (push) Successful in 28s
2026-07-24 16:22:40 +02:00
Hitonabi 3643f74eb1 style(ui): Visuelles High-End Overhaul fuer Cinematic Cinema OS (3D Spin-Disc, Hero Banner, Radial Gauges & Poster Grid)
Ampel / ampel (push) Successful in 27s
2026-07-24 16:18:58 +02:00
Hitonabi b8162753a9 chore: Single Source of Truth = main (stable-Branch + Gruen-Gate abgeschafft)
Ampel / ampel (push) Successful in 27s
Commander-Entscheid 24.07.: nur noch EIN Branch. Der stable-Zwischenbranch
war vestigial — die VM deployt ohnehin aus main (git pull), das Gruen-Gate
hat den Live-Deploy nie real gegated.

- ci.yml: Beförderungs-Schritt (push -> stable) entfernt; die Ampel prueft
  nur noch (Ruff/pytest/Vite-Build), Rot heisst weiterhin: nicht deployen.
- deploy.sh: klont/resettet auf main statt stable.
- AGENTS §C, README (Entwicklung), DESIGN-2.0-Briefing: auf main-only
  umgeschrieben. Design-2.0-Briefing als ERLEDIGT markiert.
- SAVEPOINT v3.6.

Branches stable / design-2.0 / kernumbau-2026-07-23 werden nach diesem
Push geloescht (Inhalte vollstaendig in main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:13:10 +02:00
Hitonabi bd00d4c728 refactor(ui): Dashboard, DeviceDiscovery & Settings auf Cinematic Cinema OS umgestellt
Ampel / ampel (push) Successful in 27s
2026-07-24 16:04:50 +02:00
Hitonabi 90b708302e refactor(ui): Modals & Unterseiten auf Primitives & dark: umgestellt 2026-07-24 16:04:46 +02:00
Hitonabi 7004d798d6 feat(ui): Primitives & Design Tokens fuer Cinematic Cinema OS 2026-07-24 16:04:41 +02:00
Hitonabi 7b6f836475 docs: Design-2.0-Briefing zur Uebergabe an Gemini + ROADMAP-Zeiger
Ampel / ampel (push) Successful in 27s
Vollstaendige Auftragsbeschreibung fuer den UI-Umbau: 428 theme-Ternaries
-> Tailwind-dark: + UI-Primitives, Ist-Zustand vermessen (Datei-Inventar
mit Ternary-Zahlen), harte Regeln (nur UI, Funktion 1:1, Deutsch, Ampel
gruen), empfohlener Weg, Definition of Done, Copy-Paste-Startprompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:56:44 +02:00
Hitonabi fdbe1748e5 docs: SAVEPOINT — Windows-Worker Tray + Deinstallation E2E bewiesen
Ampel / ampel (push) Successful in 27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:48:53 +02:00
Hitonabi 404a002d5c Windows-Worker: Tray-Symbol + rueckstandsfreie Deinstallation
Ampel / ampel (push) Successful in 27s
- tray.py (nur nativer Windows-Worker; Docker unberuehrt): pystray+Pillow-
  Tray neben der Uhr — Status, Worker starten/stoppen, Rippy oeffnen,
  Log anzeigen, Beenden (stoppt den Worker mit). Celery laeuft als
  Kind-Prozess ohne Konsolenfenster (CREATE_NO_WINDOW), Log in worker.log.
- install.ps1 erzeugt jetzt start-tray.bat (pythonw, empfohlen),
  start-worker.bat (Konsole/Debug) und uninstall.ps1: stoppt alle
  Prozesse aus dem Ordner, loescht die Autostart-Aufgabe, meldet den
  Worker per DELETE /workers/{name} in Rippy ab und entfernt den Ordner
  komplett. -Autostart registriert die Tray-Variante (onlogon).
- UI-Beschreibung + Anleitung entsprechend ergaenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:44:29 +02:00
Hitonabi 3987c31b1e Update-Check im System-Tab, Worker-Adressfeld mit Heimnetz-Warnung, Save-Knopf raus aus dem Worker-Tab
Ampel / ampel (push) Successful in 28s
1. Update-Erkennung (Commander-Frage 'wie kriegen wir Updates mit?'):
   GET /system/updates vergleicht installierte Versionen (Worker-Info) mit
   makemkv.com (Versionsnummer der Download-Seite) und der HandBrake-
   GitHub-Release-API (releases/latest), 12 h gecacht. Einstellungen ->
   System: 'Auf Updates prüfen' + fertiger Update-Befehl bei MakeMKV.
   Selbst-Update gibt es bewusst NICHT (waere Docker-Socket-Zugriff);
   dafuer ist MAKEMKV_VERSION jetzt per .env uebersteuerbar — Update ohne
   Code-Aenderung. HandBrake-Abweichung wird als Info dargestellt
   (Debian-Paket hinkt der offiziellen Version bewusst hinterher).
2. Worker-Anbindung: editierbares Adressfeld statt stillschweigend
   window.location.hostname — Befund: ueber die externe Domain kopierte
   Befehle liefen in den Reverse-Proxy (401) und Worker koennen Redis/
   Postgres eh nur im Heimnetz erreichen. Amber-Warnung bei oeffentlich
   aussehender Adresse, Anleitung ergaenzt.
3. 'Einstellungen speichern' ist im Worker-Tab ausgeblendet — dort gibt
   es nichts zu speichern, der Knopf verwirrte nur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:39:20 +02:00
Hitonabi 76b4286ad3 Windows-Worker: Pfad-Mapping fuer echte Transcodes + leere-Titel-Meldung + E2E-Beweis dokumentiert
Ampel / ampel (push) Successful in 27s
- pfad_lokal (tasks.py, mit Tests): RIPPY_PATH_MAP uebersetzt
  Container-Pfade (/app/temp, /app/media) auf Netzlaufwerke des nativen
  Workers — ohne Mapping unveraendert (Docker-Worker).
- Rip-Dialog: 'done' mit 0 Titeln bekommt Klartext (Disc vermutlich
  nicht entschluesselbar) statt leerer Tabelle.
- SAVEPOINT: E2E-Beweis des nativen Windows-Workers dokumentiert
  (test-windows-nativ online, HandBrake 1.9.2, echte LAN-IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:29:56 +02:00
Hitonabi 3a4eb25392 Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s
Volle Titel-Auswahl vor dem Rip (Etappe-12-Rest):
- worker.tasks.scan_tracks: makemkvcon info -> Titel-Liste (Dauer/Groesse/
  Kapitel, apdefs-Attr 8/9/11) in die settings-Tabelle 'tracks:<device>';
  API: POST /devices/{n}/scan-tracks + GET /devices/{n}/tracks (Polling).
- Rip-Dialog: 'Disc scannen' -> Tabelle mit Checkboxen, Vorauswahl ab
  5 min, Groessen-Summe; gewaehlte Titel als meta.titles in den Job.
- rip_titel_auswahl: ein makemkvcon-Aufruf je Titel (mkv kann nur einen
  Titel oder all), Fortschritt anteilig. Parser-Tests dabei.

Nativer Windows-Worker OHNE Docker (Commander-Wunsch):
- Die Rippy-Instanz versorgt sich selbst: GET /worker-setup/windows
  liefert install.ps1, /worker-setup/paket den Worker-Code als Zip
  (api-Dockerfile kopiert docker/worker/*.py als worker_dist/ ins Image).
- install.ps1: Python-Check, venv + requirements, HandBrakeCLI 1.9.2 vom
  offiziellen GitHub-Release (URL per HEAD verifiziert), start-worker.bat
  mit celery -Q transcode --pool=solo (Celery-Doku: Windows nur solo),
  optional -Autostart via schtasks onlogon.
- tasks.py laedt jetzt auch ohne fcntl (Import-Guard) — rip_disc ist auf
  solchen Workern hart verriegelt (Klartext-Fehler statt Crash).
- Einstellungen -> Worker: Varianten-Umschalter Linux(Docker)/Windows(nativ)
  mit passenden Copy-Paste-Befehlen.

Anleitungs-Tab: Rippy erklaert sich selbst (Disc-Weg, Serien, NAS,
Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:18:08 +02:00
Hitonabi 325d95af6f build(worker): lokale MakeMKV-Tarballs (vendor/) schlagen den Download
Ampel / ampel (push) Successful in 34s
Cloudflare drosselt BuildKit-Downloads von makemkv.com hartnaeckig (24.07.
viermal, auch MIT --retry-all-errors, waehrend dieselben URLs ausserhalb
des Builds laden). Dauerhafter Ausweg statt Mirror-Hack: Tarballs einmal
nach docker/worker/vendor/ legen (untracked, .gitignore) — der Build nutzt
sie dann direkt und faellt sonst auf curl+Retry zurueck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:26:24 +02:00
Hitonabi 8eb5653848 Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s
AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/
python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie
einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt-
Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft
jetzt ganz ohne .env-Pflichtwerte.

Serien-Flow (Etappe-12-Kern, ARM-Wunde #395):
- Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN
  (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im
  Serien-Ordner, bei Staffel 2 nicht ueberschrieben.
- Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan
  ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten
  (GET /metadata/tv/{id}/season/{n}; tv-season-details-API).
  Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei
  uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei
  eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests.

Weitere Punkte:
- Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip
  (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) —
  URL/Key + Test-Knopf in Einstellungen -> Ripping.
- Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses
  + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt',
  Vollautomatik ueberspringt Duplikate.
- 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten
  (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses
  Setting; pro Rip im Dialog uebersteuerbar. Mit Tests.
- OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id,
  de-DE; find-by-id-API).
- Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export
  (GET /jobs/export, Semikolon+BOM fuer deutsches Excel).
- Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl.
  Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und
  /metadata/confirm (schrieb nie gelesenen Cache-Key).
- Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen.
- Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE
  veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports
  jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:22:19 +02:00
Hitonabi e7a7d5fdb4 build(worker): MakeMKV-Download mit Retry + dokumentierter Lokal-Mirror-Ausweg
Ampel / ampel (push) Successful in 31s
Cloudflare drosselte am 24.07. wiederholt GENAU die BuildKit-Downloads
(ausserhalb des Builds gingen dieselben URLs mit 200 durch) — drei
Deploy-Anlaeufe starben an curl exit 22. Zwei Gegenmittel:
- curl --retry 5 --retry-delay 15 --retry-all-errors im Download-RUN
  (curl-Doku; --retry-all-errors wiederholt auch bei 4xx/5xx)
- Lokal-Mirror-Rezept im Kommentar: Tarballs von Hand laden, per
  nginx:alpine servieren, MAKEMKV_URL_BASE aufs LAN zeigen — so wurde
  der 1.18.4-Build auf der VM letztlich gebaut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:54:37 +02:00
Hitonabi c9270d08e1 MakeMKV 1.18.4 + UHD-Fehler-Ursachen im Klartext (Laufwerk ist geflasht — Disc-Key war das Problem)
Ampel / ampel (push) Successful in 29s
Diagnose auf der VM (Summer-Wars-UHD, MKB v82):
- 'Using LibreDrive mode (v06.3)' — der BU40N-Crossflash IST erledigt,
  das Laufwerk liest UHD. Der Fehlschlag kam von 'The volume key is
  unknown for this disc': die Disc ist neuer als MakeMKVs
  Schluessel-Datenbank — mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen
  (beide Versionen live gegen die Disc getestet).

Konsequenzen:
- MakeMKV-Pin von 1.17.7 auf 1.18.4 gehoben: der 1.18er Scan-Haenger
  (Forum t=38128) wird ueberall mit --noscan umgangen (info-Lauf mit
  1.18.4 auf der BU40N sauber durchgelaufen), die Flash-Faehigkeit von
  1.17.7 wird nicht mehr gebraucht. Aktuelle Version = aktuellste
  AACS-Key-DB. URL-Base -> /download (dort liegt nur die aktuelle).
- run_makemkv reicht KRITISCHE Meldungen (volume key unknown, Key
  abgelaufen, too old version) in den Fehlertext durch — vorher stand
  da nur die nichtssagende letzte Zeile 'Failed to open disc'.
- UHD-Klartext in tasks.py unterscheidet jetzt die zwei Faelle:
  volume-key-unknown (MakeMKV/Disc-Alter, AACS-Dump aus /root/.MakeMKV/
  im MakeMKV-Forum einreichen) vs. Failed-to-open ohne LibreDrive
  (Firmware-Hinweis).
- SAVEPOINT: Flash-Status korrigiert (war als offen dokumentiert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:44:42 +02:00
Hitonabi 5778ac4645 Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Ampel / ampel (push) Successful in 29s
Zwei bewiesene Bug-Fixes:
- SMB-Mount 'Unable to apply new capability set': mount.cifs hebt
  CAP_DAC_READ_SEARCH an, die in Dockers Default-Caps fehlt (auf der VM
  reproduziert: Bounding-Set a82425fb ohne Bit 2; mit der Capability
  verschwindet der Fehler). Fix: cap_add DAC_READ_SEARCH fuer den
  api-Container.
- TMDB fiel still aus: Client konnte nur v4-Bearer-Tokens, der uebliche
  32-Hex-v3-Key bekam 401 und die Suche lieferte nur OMDb. Jetzt beide
  Key-Arten (ist_v4_token + api_key-Query-Param lt.
  developer.themoviedb.org, mit Tests) — damit kommen auch die deutschen
  Texte an (language=de-DE war ueberall schon gesetzt). Neu:
  GET /metadata/status + 'Verbindung pruefen' in Einstellungen -> APIs
  (Live-Check am Cache vorbei).

Features aus dem Commander-Feedback:
- 4K UHD als eigener Disc-Typ: classify >= 55 GiB (BD-66/BD-100; BD-50
  bleibt bluray), beide detection.py + Tests, eigene Badge-Farbe in
  Dashboard/Laufwerken, Prescan-Label '4K UHD'. UHD-Rip-Fehler 'Failed to
  open disc' (Code 11) bekommt Klartext: LibreDrive-Firmware noetig.
- Vollautomatik (Setting autoRipStart): Disc erkannt -> Rip startet ohne
  Popup in den Schnellwahl-Ordner (Serie->Serien, sonst Filme, CD->Musik).
- Job-Verwaltung: 'Neu komprimieren' nur noch mit can_retry (Rohdaten
  liegen wirklich da), DELETE /jobs/{id} + 'Erledigte aufraeumen'
  (Dateien bleiben immer), 'Alle herunterladen' im Job-Detail
  (gestaffelte Einzel-Downloads statt Server-Zip von 40-GB-Dateien).
- Worker zuordenbar: WORKER_NAME-Env als stabiler Anzeigename (fixt auch
  die Offline-Leichen nach Rebuilds), info.hostname/ip gemeldet,
  Online-Abgleich ueber hostname, DELETE /workers/{name} + Papierkorb im
  UI, Quelle-Anzeige an der Disc-Karte ('Quelle: TMDB - 99 % sicher').
- Ripping-Tab nach Medium gegliedert (Video/Audio-CD/Allgemein) +
  Untertitel-Klartext (--all-audio/--all-subtitles bleiben komplett),
  CD -> Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung beschriftet
  und standardmaessig eingeklappt.
- Docs: SAVEPOINT v3.3, ROADMAP Etappe 14 + Ideen (nativer
  Windows-Worker, deutsche OMDb-Texte via TMDB-Find), README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:29:10 +02:00
Hitonabi ab75134931 feat: Download-Knopf fuer fertige Rips — Dateien direkt im Browser statt scp
Ampel / ampel (push) Successful in 29s
- GET /jobs/{id}/files: Dateiliste aus job.output_path (Name + Groesse)
- GET /jobs/{id}/files/{name}: FileResponse-Stream; Validierung strikt —
  output_path muss unter /app/media liegen, nackter Dateiname (kein
  Slash/.., kein Dotfile), realpath-Check gegen Symlink-Ausbrueche.
  Mit Test (test_dateiname_validierung_blockt_pfad_tricks).
- UI: 'Download'-Knopf in der Aktion-Spalte bei fertigen Jobs; die
  Dateiliste mit Groessen + Download-Links lebt im Job-Detail-Popup
  (ein Dropdown wuerde im overflow-x-auto-Tabellencontainer clippen).
- nginx: proxy_buffering off + proxy_read_timeout 3600s waren fuer SSE
  schon gesetzt — grosse Downloads brauchen keine Aenderung.

Wunsch aus der Uebernahme-Session (Commander-Sammelliste 24.07.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:01:38 +02:00
Hitonabi 5d9be4d046 docs: SAVEPOINT v3.2, ROADMAP Etappe 13 + Ideen-Katalog, README-Ausbau, KONZEPT-Fortschreibungen
Ampel / ampel (push) Successful in 29s
- README: Media-Server-Ablage, Benachrichtigungen (Tabelle je Dienst),
  System/MakeMKV-Key, UHD-Arbeitsverzeichnis, neuer Abschnitt 'Rippy
  woanders bereitstellen' (beliebiger Docker-Host, was NICHT mitmuss).
- ROADMAP: Etappe 13 (Universal-Komfort-Runde) dokumentiert, erledigte
  Punkte aus Etappe 11/12 abgehakt, priorisierter Ideen-Katalog.
- KONZEPT: Abschnitt 10 'Fortschreibungen' — Media-Server-Neutralitaet
  erweitert das Jellyfin-Muss (keine Abweichung), Benachrichtigungen,
  udev->ioctl.
- SAVEPOINT v3.2 mit dem Kernbefund: Ampel war seit 23.07. rot, stable
  hing 10 Commits zurueck (bcrypt/passlib + veralteter Test) — behoben.
- AGENTS: Stand-Sektion entschlackt (Details leben im SAVEPOINT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:55 +02:00
Hitonabi 13c1ae6064 feat(ui): Toast-Feedback ueberall, Job-Detail-Popup, System-Tab, Wizard-Media-Server, Favicon, Footer
- ToastContext: jede Aktion quittiert sichtbar ('Erfolgreich aktualisiert'
  ploppt oben rechts rein) — Refresh-Knoepfe drehen jetzt wirklich
  (Laufwerke, Logs, Worker, Speicherziele).
- JobDetailModal: Klick auf Titel in 'Neueste Jobs' -> Poster, Jahr,
  Beschreibung, Genres, Ablagepfad, Fehler.
- Logs: Filter-Pills um Warnung + Fehler ergaenzt (vorher nur
  Alle/Info/Erfolg).
- Speicherziele: Benutzer/Passwort VOR dem 'Freigaben auflisten'-Knopf
  (Windows/NAS lehnen Gast-Abfragen ab — genau daher kam das
  NT_STATUS_ACCESS_DENIED), Hinweistext, Toasts beim Einhaengen/Entfernen.
- Datei-Browser (Speicherziele + Rip-Ziel-Dialog) zeigt Dateien grau mit
  Groesse — Ordner wirkten faelschlich leer.
- Einstellungen: neuer System-Tab (Werkzeug-Versionen je Worker, Key-Status,
  Plattenplatz, MakeMKV-Beta-Key-Feld), Media-Server-Wahl im Ripping-Tab,
  Arbeitsverzeichnis im Verarbeitung-Tab, Benachrichtigungs-Anleitung
  (Discord/ntfy/Slack) + 'Test senden'.
- First-Run-Wizard: Media-Server-Schritt (Jellyfin/Emby/Kodi/Plex/Keins).
- Favicon (Disc im Logo-Verlauf, public/favicon.svg — /vite.svg war 404)
  + Footer 'Created with (Herz) by LucyAI, Claude and KrBrZ'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:42 +02:00
Hitonabi e032a9df1c feat(backend): Media-Server-Aufbereitung, echte Webhooks, SMB-Klartext, UHD-Arbeitsverzeichnis, MakeMKV-Key via UI
- jobs.meta (Migration in beiden db.py): Disc-Metadaten wandern in den Job —
  Quelle fuer Job-Detail-Popup (GET /jobs/{id}/detail), Ordner-Benennung, NFO.
- medien.py (Worker, mit Tests): Zielordner 'Titel (Jahr)' statt Job-UUID;
  movie.nfo/tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg
  fuer jellyfin/emby/kodi; plex nur Benennung; Kollision -> Job-ID-Suffix.
- notify.py (identisch in API+Worker, mit Tests): Webhook bei Job-Ende —
  Discord ({content}, discord.com/developers), Slack ({text},
  api.slack.com/messaging/webhooks), ntfy (Rohtext + ?title=, docs.ntfy.sh),
  generisches JSON. Vorher war das Setting ein Placebo: nichts sendete je.
  POST /notifications/test beweist die Anbindung sofort.
- mounts.py: NT_STATUS-Fehler -> handelbarer Klartext (ACCESS_DENIED ohne
  Credentials = Gast-Abfrage verweigert), Timeout-Meldung, mount-Hinweis
  bei error(13). Mit Tests.
- tasks.py: Platz-Check per Disc-Groesse (ioctl BLKGETSIZE64) VOR dem Rip;
  Arbeitsverzeichnis workDir (unter /app/media, z.B. NAS) statt fix
  /app/temp/raw — 4K-UHD-Rohdaten (bis 100 GB) sprengen sonst die VM-Platte;
  MakeMKV-Key aus UI-Settings (~/.MakeMKV/settings.conf, Format wie
  entrypoint.sh) gilt ab dem naechsten Rip ohne Rebuild.
- caps.py: Werkzeug-Versionen (MakeMKV aus ENV MAKEMKV_VERSION im Image,
  makemkvcon hat keinen --version-Schalter lt. usage.txt; HandBrakeCLI
  --version lt. handbrake.fr/docs) + Key-Quelle; workers.info-Spalte.
- /browse liefert jetzt auch DATEIEN (Name + Groesse) — der 'leere'
  bluray-Ordner war voll, der Browser zeigte nur Unterordner.
- GET /system/info: Versionen, Plattenplatz, Key-/Webhook-Status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:22 +02:00
Hitonabi 048562a6ba fix(ci): Ampel entrostet — bcrypt-Pin + cache_keys-Test ans Fingerprint-Format angepasst
Die Ampel war seit 23.07. rot, stable hing 10 Commits hinter main:
1. bcrypt >= 4.1 entfernt __about__ — passlib 1.7.4 crasht beim Backend-
   Selbsttest ('password cannot be longer than 72 bytes'), jedes Hashing
   schlug fehl. Fix: bcrypt==4.0.1 gepinnt (passlib-kompatibel, dokumentiert
   im requirements-Kommentar).
2. test_prescan_key_audio_vs_video erwartete das Key-Format OHNE den
   Disc-Fingerprint vom 23.07. Test an das echte (gewollte) Format
   angepasst + neuer Testfall: zwei Discs im selben Laufwerk -> zwei Keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:03 +02:00
Hitonabi 731ed316df SAVEPOINT v3.1: E2E bewiesen (BD-50 -> 4,8 GB) + Universal-Runde dokumentiert
Ampel / ampel (push) Failing after 28s
Uebergabe-Stand fuer die naechste Session — naechste Schritte im SAVEPOINT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:25:29 +02:00
Hitonabi 6517644d1f Dashboard-Korrektur-Popup, Worker-Tab mit Live-Ping, Herzschlag, Anzeige-Fixes
Ampel / ampel (push) Failing after 29s
Commander-Wuensche (23.07. Nacht):
- "Nicht korrekt?!"-Knopf direkt an der Disc-Karte: Korrektur-Suche als
  Popup (MetadataKorrektur) — kein Seitenwechsel mehr noetig; Wahl gilt
  per Disc-Fingerabdruck weiter 30 Tage
- Einstellungen -> Worker: verbundene Encoding-Maschinen mit ECHTER
  Erreichbarkeit (Celery-Ping + minuetlicher Herzschlag/last_seen),
  Encoder-Badges, Copy-Paste-Anbindung fuer neue Maschinen
  (Linux/Docker + Docker Desktop; SSH-Ein-Klick als Ausbaustufe)
- JSX-0-Falle gefixt: {0 && ...} renderte nackte "0" unter GENRE/LAUFZEIT
- ruff.toml: Regelsatz gepinnt — lokal aktualisierte Ruff-Version zog
  ploetzlich Stilregeln und faerbte den Baum rot; Ampel und lokal
  urteilen jetzt identisch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:18:44 +02:00
Hitonabi 6b7d391737 Korrektur-Flow + Freigaben-Auswahl + Schreibtest + Schnellwahl-Klartext
Ampel / ampel (push) Failing after 52s
Commander-Befunde (23.07. spaet):
- MANUELLE KORREKTUR (KONZEPT Schritt 5 endlich komplett): Metadaten-Seite
  hat jetzt Titelsuche ueber ALLE Quellen (GET /metadata/search) mit
  Poster-Kacheln; ein Klick uebernimmt den Treffer (POST /metadata/override)
  fuer Karte, Jobs UND merkt ihn sich 30 Tage per Disc-Fingerabdruck —
  dieselbe Disc wird beim naechsten Einlegen sofort richtig erkannt
- PC/NAS per Klick: SMB-Freigaben eines Rechners auflisten
  (GET /storage-mounts/shares via smbclient) statt //host/share raten
- Berechtigungs-PRUEFUNG beim Einhaengen: Schreibtest, Warnung bei
  nur-lesbaren Zielen (API + UI + Log)
- "Standard-Unterordner" verstaendlich gemacht: heisst jetzt "Schnellwahl
  beim Rippen" mit Erklaertext; kryptisches Basis-Feld raus
- Preview zeigt Poster; Fingerprint fuer Cache/Override vereinheitlicht
  (billiges Label+Groesse statt Mount-Titel)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:34:34 +02:00
Hitonabi 37711f87c1 Jikan-Retry bei 504 + Cache-Verfall fuer mittelgute Prescan-Treffer
Ampel / ampel (push) Failing after 39s
Jikan (Gratis-Dienst) wirft sporadisch 504 — ein Retry mit 2s Pause;
aktuell ist der Dienst ganz down, die Kette faellt sauber auf OMDb.
Prescan-Cache: 0.6-0.9 verfaellt nach 1 Tag, ab 0.9 nach 7 Tagen —
sonst wuerde ein frueher OMDb-Treffer bessere Quellen fuer immer blocken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:25:10 +02:00
Hitonabi 07be54e62a Jikan/MAL-Quelle + Job-Titel + Abbrechen-Knopf + Dark-Hover + Ordner-Verwaltung
Ampel / ampel (push) Failing after 38s
Commander-Befunde (Screenshots 23.07. abends):
- Jikan (MyAnimeList, kostenlos OHNE Key) als Anime-Quelle vor OMDb —
  waehlt per Titel-Aehnlichkeit (SequenceMatcher, Schwelle 0.55) statt
  blind Treffer 1; damit trifft "Evangelion 2.22" den exakten Film
- Job-Titel: POST /jobs uebernimmt den erkannten Disc-Titel aus dem
  DISC_CACHE — Schluss mit "Unbekannt" in der Queue
- Kooperativer Job-Abbruch: POST /jobs/{id}/cancel setzt canceling,
  der Worker prueft das Flag bei jedem Fortschritts-Update und killt
  den Encoder-Prozess sauber (RipAbbruch); UI-Knopf "Abbrechen" fuer
  laufende, Status-Badge "Wird abgebrochen"
- Dark-Mode: Job-Zeilen-Hover war hart hell (hover:bg-slate-50)
- Speicherziele: lokaler Ordner-Browser mit "Ordner anlegen"
  (GET /browse + POST /browse/mkdir, nur unter /app/media)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:22:57 +02:00
Hitonabi 85f352b14f OMDb-Qualitaets-Gate: ohne Poster UND Plot = Nicht-Treffer, Kette laeuft weiter
Ampel / ampel (push) Failing after 36s
Der Datenmuell-Eintrag ("Episode 110 - ...") war der EINZIGE s=-Treffer
fuer den vollen Titel und brach die Kandidaten-Kette bei 0.8 ab, bevor
"Evangelion" allein probiert wurde. N/A-Werte werden jetzt normalisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:13:03 +02:00
Hitonabi 241ce3ec78 OMDb-Ergebnis-Waehler: Filme vor Episoden, Poster vor ohne, kurz vor lang
Ampel / ampel (push) Failing after 39s
Die unscharfe Suche nahm blind Treffer 1 — fuer "Evangelion" war das
"Episode 110 - ..." (ein Review-Podcast ueber den Film, ohne Poster).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:11:42 +02:00
Hitonabi f2d5961c33 Metadaten-Feinschliff: Wort-Strip-Degradation + kein Caching von "unknown"
Ampel / ampel (push) Failing after 40s
- titel_kandidaten strippt hintere Worte iterativ bis auf eins
  ("Evangelion 2.22" wird auch als "Evangelion" gesucht — OMDb braucht das)
- Nur Treffer ab Confidence 0.6 landen im Redis-Cache: ein gecachtes
  "unknown" wurde sonst auch nach Key-Eintrag ewig wieder serviert
  (Redis ist persistent; Alt-Eintraege auf der VM geloescht)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:10:15 +02:00
Hitonabi 90eb17d407 UX-Runde: echte Disc-Titel via BD-Metadaten, Preview-Crash, Dashboard, Ziele
Ampel / ampel (push) Failing after 38s
Commander-Befunde 23.07. abends:
1. Metadaten "klappen nicht": Wurzel = kryptisches Volume-Label + kein
   TMDB-Key. Jetzt: API mountet die Disc read-only (CAP_SYS_ADMIN ist da)
   und liest den KLARTEXT-Titel aus BDMV/META/DL/bdmt_*.xml; dazu
   progressive Suchdegradation (Titel-Varianten) und OMDb-Unscharf-Suche
   (s= + imdbID-Nachladen) — mit dem vorhandenen OMDb-Key gibt es damit
   Titel/Jahr/Poster auch ohne TMDB
2. Weisses Fenster beim Pre-Scan: Lucide-Icons sind forwardRef —
   Direktaufruf getIcon(...)({size}) crashte die Seite; als JSX gerendert
3. Dashboard sortiert: System-Leiste (Encoder + aktiver Job) oben,
   Disc/Geraete -> Stats -> Jobs, Live-Log ans Ende
4. Ziel-Dialog mit ORDNER-BROWSER (GET /browse, nur unter /app/media);
   Schnellwahl nutzt die Settings-Unterordner statt Hartkodierung;
   Tab "Verzeichnisse" in "Speicherziele" aufgegangen (redundant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:07:55 +02:00
Hitonabi 3d384c1e86 Fix: db.update_job fehlte im API-Modul (retry-transcode warf 500)
Ampel / ampel (push) Failing after 36s
Die Job/Log-Tabellen leben bewusst doppelt in api/ und worker/ —
update_job gab es nur im Worker. Der Import-Smoke-Test prueft Routen,
aber keine Modul-Attribute; Lehre fuer die Etappe "geteiltes Paket".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:55:57 +02:00
Hitonabi 7652afe955 Deploy-Befunde gefixt: Disc-Typ-Verlust, giftiger Prescan-Cache, caps-Import
Ampel / ampel (push) Failing after 28s
- Pre-Scan verlor den erkannten Typ (BDs hiessen immer "DVD"): scan()
  reicht disc_type jetzt in die Zweige durch
- GIFTIG: Cache-Key nur aus Geraetepfad — die naechste Disc im selben
  Laufwerk haette die Metadaten der vorherigen geerbt. Key enthaelt jetzt
  einen Disc-Fingerabdruck (Label|Groesse)
- Worker-Faehigkeiten: import caps auf Modulebene — im worker_ready-Signal
  war /app nicht mehr im sys.path (ModuleNotFoundError im Deploy-Log)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:54:14 +02:00
Hitonabi b584cc29ad Universal-Sprint: Wizard, UI-Mounts, Encoder-Erkennung, Task-Split, README
Ampel / ampel (push) Successful in 29s
Commander-Ziel: All-in-one, universell, weitergebbar.

- First-Run-Wizard: startet automatisch bei neuer Installation (Keys,
  Verarbeitung, erkannte Hardware); /setup + /setup/complete
- Data-Mounts via UI: Einstellungen -> Speicherziele haengt NFS/SMB direkt
  ein (mounts.py, CAP_SYS_ADMIN + rshared-Propagation, Auto-Remount beim
  Start, CIFS-Creds via Datei statt Kommandozeile); nfs-common/cifs-utils
  im api-Image
- Encoder-Erkennung: jeder Worker meldet beim Start ehrlich seine
  Faehigkeiten (caps.py -> workers-Tabelle), GET /capabilities, Anzeige
  in Wizard + Verarbeitung-Tab
- Task-Split: transcode_files als eigener Task auf Queue "transcode"
  (Basis fuer optionale Remote-GPU-Worker, deploy/remote-transcode-worker.yml
  EXPERIMENTELL) + POST /jobs/{id}/retry-transcode + UI-Knopf
  "Neu komprimieren" bei fehlgeschlagenen Jobs
- API-Keys aus der DB: Settings-UI/Wizard ueberstimmen Env — vorher waren
  die Key-Felder im UI reine Dekoration (Clients lasen nur Env)
- README komplett neu: generischer Schnellstart, Laufwerk-Override via
  docker-compose.override.yml, Architektur, Env-Tabelle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:49:08 +02:00
Hitonabi e350523f7e Dashboard-Disc-Karte: Auto-Pre-Scan beim Einlegen — man SIEHT was drinliegt
Ampel / ampel (push) Successful in 41s
Commander-Befund: Disc wurde erkannt, aber das UI verriet nicht WAS.
- Watcher stoesst beim Einlegen (und beim Start mit bereits eingelegter
  Disc) automatisch den Pre-Scan an, Ergebnis landet im DISC_CACHE und
  im Log ("Disc erkannt: Titel (Jahr) [typ, Confidence]")
- GET /devices liefert das disc-Feld mit Titel/Jahr/Typ/Confidence/Poster
- UI: prominente Karte "Im Laufwerk erkannt" mit Poster (TMDB relativ
  oder OMDb absolut), Beschreibung und Rippen-Start (Ziel-Dialog)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:55:18 +02:00
Hitonabi dfef585ec8 Speicherziel-Wahl: Rip-Ziel pro Job frei waehlbar (Etappe 16 / Task 16)
Ampel / ampel (push) Successful in 28s
- POST /jobs nimmt target_dir (validiert unter /app/media, .. fliegt raus)
- GET /storage-targets: Verzeichnisse unter /app/media inkl. Mount-Flag
  und freiem Platz — NFS/SMB-Shares unter /srv/rippy/media erscheinen
  dank rslave-Bind automatisch
- jobs.target_dir (Mini-Migration via ADD COLUMN IF NOT EXISTS)
- Worker: rip_disc(target_dir) mit eigener Validierung; CD/Video/
  Transcode-Pfade legen im gewaehlten Ziel ab
- UI: "Rippen starten" oeffnet den Ziel-Dialog (Filme/Serien/Musik oder
  eigener Pfad); Modal-Bugfix: customPath wurde still ignoriert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:20:36 +02:00
Hitonabi 06cab545da Compose: MAKEMKV_URL_BASE als Build-Arg aus .env — Deploy-Abbruch gefixt
Ampel / ampel (push) Successful in 34s
Der plain "compose up --build" kannte das Wayback-Build-Arg nicht, griff
auf das tote makemkv.com zu (curl 22) und liess den halben Stack gestoppt
zurueck (nginx fand den api-Upstream nicht mehr). Jetzt steuert die .env
die Quelle; Default bleibt die offizielle URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:14:54 +02:00
Hitonabi ef137b64cd Media als /srv/rippy/media-Bind mit rslave — Fundament fuer freie Rip-Ziele
Ampel / ampel (push) Successful in 32s
Commander-Anforderung: Ablageziel via Rippy waehlbar (Share/NFS/UNC).
Statt anonymem Docker-Volume liegt /app/media jetzt auf /srv/rippy/media
der VM; rslave-Propagation laesst NACH Container-Start eingehaengte
NFS/SMB-Mounts live im Container auftauchen. Naechster Schritt:
target_dir pro Job + Ziel-Auswahl im UI (Task 16).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:12:00 +02:00
Hitonabi 2474b683e2 Transcode-Stufe: HandBrake komprimiert NACH dem MakeMKV-Rip + --noscan-Fix
Ampel / ampel (push) Successful in 34s
Commander-Entscheid 23.07.: 40-GB-Rohdateien sind kein brauchbares
Endprodukt. Architektur bleibt zweistufig, weil HandBrake AACS nicht
lesen kann: MakeMKV rippt verlustfrei nach /app/temp/raw, HandBrake
macht daraus x265 auf Arbeitsgroesse in /app/media, Roh-Verzeichnis
wird erst NACH komplettem Erfolg geloescht (keepOriginal behaelt es).

- Worker: handbrake-cli im Image, run_handbrake + _komprimiere,
  Job-Status "transcoding", Settings aus Postgres (transcodeEnabled/
  Preset/keepOriginal), Fortschritt je Datei aggregiert
- makemkvcon: --noscan im Kommando verankert — der Geraete-Scan haengt
  (1.18.4) bzw. crasht (1.17.7) im Container, mit --noscan + dev:-Pfad
  laeuft es (Befund 23.07., DRV-Zeile beweist Laufwerks-Erkennung)
- UI: Settings-Tab "Verarbeitung" (Toggle/Preset/Original behalten),
  Status-Badge "Komprimieren", LiveLog erkennt transcoding als aktiv
- KONZEPT/ROADMAP entsprechend aktualisiert; Tests fuer HandBrake-Cmd
  (arbeitet auf DATEI, nie am Geraet) und Progress-Regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:56:37 +02:00
Hitonabi 009898efae MakeMKV auf 1.17.7 gepinnt — 1.18.x haengt unter Linux beim Laufwerks-Scan
Ampel / ampel (push) Successful in 40s
Bei uns reproduziert (100% CPU, keine Ausgabe, auch mit sg-Knoten), im
MakeMKV-Forum vielfach bestaetigt (t=38128, t=38239, t=38249); Community-
Workaround ist 1.17.7. Bonus: exakt die Version, die spaeter die Firmware
flashen kann. MAKEMKV_URL_BASE als Build-Arg, weil makemkv.com gerade
per Cloudflare-525 down ist (Wayback-Snapshot als Ausweichquelle);
sha256 der Tarballs wird im Build-Log festgehalten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:39:56 +02:00
Hitonabi bd6409038e UDF-Volume-Label-Leser (Blu-rays haben keine ISO-Bridge) + Etappe 12 (ARM)
Ampel / ampel (push) Successful in 35s
Praxis-Befund mit echter BD-50: reines UDF, der ISO-PVD-Leser griff nicht.
Jetzt ECMA-167-Weg (AVDP Sektor 256 -> Main VDS -> PVD Tag-ID 1 ->
Volume Identifier als d-string), mit Tests fuer das d-string-Parsing.

ROADMAP Etappe 12 aus der ARM-Vollanalyse: was wir uebernehmen
(Fingerprint-DB, bdmt_eng.xml, Suchdegradation, Manual Mode, Apprise,
Multi-Drive, Backup-Modus) und wo wir ARM schlagen (Serien-Episoden-
Matching, Main-Feature per Laufzeitabgleich).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:33:27 +02:00
Hitonabi 1aa51ca943 UI-Feinschliff: Settings-Tabs funktionieren, Details-Knopf tut etwas
Ampel / ampel (push) Successful in 33s
- Settings: Tabs waren reine Dekoration (kein onClick, isActive fest) —
  jetzt echte Tab-Navigation (Ripping/Verzeichnisse/APIs/Benachrichtigungen)
  + OMDb-Key-Feld im APIs-Tab
- Laufwerk-Details: aufklappbares Panel (Pfad/Modell/Seriennummer/Disc)
  mit "Rippen starten" (POST /jobs) und "Auswerfen" (Eject-Endpoint)
- Placebos raus: 4 tote Schnellaktionen, doppelte Geraete-Sektion mit
  zweitem totem Details-Knopf, toter Abmelden-Knopf

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 +02:00
Hitonabi 5528e0f652 Metadaten-Fallback OMDb + Pre-Scan repariert + Eject-Endpoint
- clients/omdb.py: OMDb als zweite Quelle (Fallback-Kette: TMDB exakt ->
  OMDb -> bester TMDB-Vorschlag mit niedriger Confidence -> unknown)
- Pre-Scan-Reparatur: Titel kam nie an — makemkvcon existiert nur im
  Worker, isosize war nirgends installiert (fiel still auf "DVD" zurueck).
  Jetzt: ISO-9660-Volume-Label direkt vom Medium + Label-Normalisierung
  (PULP_FICTION -> Pulp Fiction), Disc-Typ ueber zentrale detection.py
- POST /devices/{name}/eject (CDROMEJECT-ioctl) mit Job-Schutz (409 wenn
  auf dem Laufwerk gerade gerippt wird)
- OMDB_API_KEY in compose/.env.example; .env.example komplett ehrlich
  dokumentiert (JWT-Pflicht, MakeMKV-Beta-Key-Rhythmus)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 +02:00
136 changed files with 24708 additions and 2893 deletions
+72 -3
View File
@@ -1,4 +1,73 @@
POSTGRES_PASSWORD=rippy123 # Rippy-Umgebung — nach .env kopieren und Werte eintragen.
SECRET_KEY=change-me-in-production # Die .env liegt NUR auf der VM (gitignored), nie im Repo.
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
POSTGRES_PASSWORD=rippy
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
# generischen sg-Knoten des Laufwerks.
#
# BEWUSST AUSKOMMENTIERT: Die sg-Nummer ist JE HOST ANDERS. Ein vorbelegter Wert
# wäre auf den meisten Rechnern falsch — und zwar unbemerkt, weil er dann „schon
# gesetzt" aussieht. `install.sh` findet beide Knoten selbst (Abgleich über die
# SCSI-Adresse in /sys) und trägt sie hier ein.
#
# Von Hand ermitteln, falls gewünscht: lsscsi -g
# Ohne Eintrag gelten die Compose-Vorgaben /dev/sr0 und /dev/sg1.
#OPTICAL_SR=/dev/sr0
#OPTICAL_SG=/dev/sg1
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
# Metadaten-APIs
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
# später im UI setzbar): kostenlos auf themoviedb.org
TMDB_API_KEY= TMDB_API_KEY=
THEtvdb_API_KEY= # TVDb (optional, Serien-Fallback)
THETVDB_API_KEY=
# OMDb (optional, Fallback-Quelle): kostenloser Key auf omdbapi.com/apikey.aspx
OMDB_API_KEY=
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
# Bequemer: im UI unter Einstellungen → System eintragen (gilt ab dem
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
MAKEMKV_APP_KEY=
# MakeMKV-Datenverzeichnis auf dem HOST (bleibt über Rebuilds hinweg erhalten).
# Hier hinein gehört die KEYDB.cfg für 4K-UHD-Discs; hier landen auch die
# AACS-Dumps fehlgeschlagener Discs. Beides ist ab Einstellungen → System
# im Browser erreichbar — dieser Pfad ist nur für den Fall, dass du die
# Dateien direkt auf der Maschine anfassen willst.
#MAKEMKV_DATA_HOST=/srv/rippy/makemkv
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
# Update: Version hier anheben, dann auf der Rippy-Maschine
# docker compose build worker && docker compose up -d worker
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
#MAKEMKV_VERSION=1.18.4
# ---------------------------------------------------------------------------
# MakeMKV-Bezug beim Image-Bau (nur nötig, wenn der Download klemmt)
# ---------------------------------------------------------------------------
# Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern:
# https://www.makemkv.com/download HTTP 200 <- der Standard, funktioniert
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
# web.archive.org-Schnappschuss HTTP 404
# Normalerweise ist hier NICHTS einzutragen.
#MAKEMKV_URL_BASE=https://www.makemkv.com/download
#
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird.
# Trage hier nur etwas ein, das du selbst geprüft hast — eine Adresse, die nicht
# liefert, lässt die Konfiguration gesund aussehen und den Bau später scheitern.
# Genau so lag es auf der Rippy-VM: dort stand eine 404-Adresse, und nur die
# vendor-Tarballs retteten jeden Bau, ohne dass es jemandem auffiel.
#MAKEMKV_URL_FALLBACK=http://192.168.178.10:8099
#
# Der zuverlässigste Weg bleibt ohne Netz: Tarballs von makemkv.com/download
# laden und nach docker/worker/vendor/ legen — der Bau nimmt sie dann von dort.
+2
View File
@@ -1,3 +1,5 @@
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container # Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux. # ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
*.sh text eol=lf *.sh text eol=lf
*.exe binary
*.ico binary
+6 -7
View File
@@ -72,13 +72,12 @@ jobs:
fi fi
exit $rot exit $rot
- name: Grün-Gate — grünen Stand auf 'stable' befördern (Arcane deployt von dort) - name: Ergebnis
if: success() if: success()
shell: bash shell: bash
run: | run: |
if [ "${GITHUB_REF_NAME:-}" = "main" ]; then # Single Source of Truth: 'main' (24.07.2026, Commander-Entscheid —
git push origin +HEAD:refs/heads/stable # der stable-Zwischenbranch ist weg). Die Ampel PRÜFT nur noch und
echo "✅ main → stable befördert. Arcane-GitSync zieht 'stable' und deployt." # befördert nichts; deployt wird direkt aus 'main' (git pull auf der
else # VM bzw. ./deploy.sh). Rot heißt weiterhin: nicht deployen.
echo "Kein main-Push — keine Beförderung." echo "✅ AMPEL GRÜN. Deploy-Quelle ist 'main' — von dort deployen."
fi
+120 -9
View File
@@ -17,10 +17,12 @@ abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel,
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen. gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.) (Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
**C. Deployment läuft AUTOMATISCH über das Grün-Gate.** Ampel grün auf `main` **C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und (seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen. nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.) (Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder **D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
@@ -53,9 +55,118 @@ 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 Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf. - Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
## Aktueller Stand (21.07.2026) ## Aktueller Stand (26.07.2026)
-**Etappe 8 abgeschlossen:** Dark Mode mit Theme-Toggle, React-UI mit TailwindCSS -**E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
-**Git Sync:** Arcane VM (192.168.178.162) mit Git Repository konfiguriert -**Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
-**Savepoint v1.8:** Separation of Concerns geplant, Dark Mode implementiert echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
- 📝 **Nächste Etappe:** SoC-Refactoring (Theme-Context auslagern) MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
-**Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
-**Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
-**Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt
-**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
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99`
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev`
`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
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.
+68 -4
View File
@@ -35,8 +35,8 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | | | AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | | | Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
| SQLite-Cache für API-Rate-Limits | ✔ M | | | | SQLite-Cache für API-Rate-Limits | ✔ M | | |
| JWT-Auth (Access 15min/Refresh 7 Tage) | ✔ M | | | | ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
| Rate-Limiting (100/min pro API-Key) | ✔ M | | | | Rate-Limiting pro Client-IP — ~~100/min~~ **600/min** (26.07.2026, siehe Abweichungen) | ✔ M | | |
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | | | Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
| React-UI (statisch via Nginx) | ✔ M | | | | React-UI (statisch via Nginx) | ✔ M | | |
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | | | Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
@@ -44,7 +44,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | | | MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
| GPL-v3-Compliance-Checkliste | ✔ M | | | | GPL-v3-Compliance-Checkliste | ✔ M | | |
| Source-Release-Endpoint | ✔ M | | | | Source-Release-Endpoint | ✔ M | | |
| HandBrake-Transcoding (optional) | | ✔ K | | | HandBrake-Kompression NACH dem Lossless-Rip (Stufe 2) | ✔ M | | |
| Prometheus+Grafana Monitoring | | ✔ K | | | Prometheus+Grafana Monitoring | | ✔ K | |
| PBS-Snapshot-Backup-Hooks | | ✔ K | | | PBS-Snapshot-Backup-Hooks | | ✔ K | |
| Multi-Disc-Parallelisierung | | | ✔ S | | Multi-Disc-Parallelisierung | | | ✔ S |
@@ -68,7 +68,12 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
- Commander bestätigt oder korrigiert manuell. - Commander bestätigt oder korrigiert manuell.
6. **Ripping** (nach Bestätigung): 6. **Ripping** (nach Bestätigung):
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz. - CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
- DVD/Blu-ray: `makemkvcon --all --progress` → MKV. - DVD/Blu-ray: `makemkvcon` → verlustfreies MKV als ZWISCHENPRODUKT in /app/temp
(MakeMKV ist der einzige Weg durch AACS — HandBrake kann verschlüsselte
Discs nicht lesen), danach **HandBrake-Kompression auf Arbeitsgröße**
(x265; Commander-Entscheid 23.07.2026: 40-GB-Rohdateien sind kein
brauchbares Endprodukt). Roh-Datei wird nach Erfolg gelöscht
(Setting keepOriginal behält sie).
7. **Post-Processing & Jellyfin-Formatierung**: 7. **Post-Processing & Jellyfin-Formatierung**:
- Dateien in Jellyfin-konformer Ordnerstruktur. - Dateien in Jellyfin-konformer Ordnerstruktur.
- NFO-Dateien im Kodi/NFO-Schema. - NFO-Dateien im Kodi/NFO-Schema.
@@ -122,6 +127,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| Risiko | Status | Behandlung | | Risiko | Status | Behandlung |
|--------|--------|------------| |--------|--------|------------|
| MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE | | MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE |
| **Disc-Schlüssel für 4K-UHD (AACS 2.0)** | **Gelöst mit Handgriff** | Am 25.07.2026 auf beiden Maschinen gemessen: `makemkvcon` unter **Linux** ruft Disc-Schlüssel nie ab (kein einziger Verbindungsversuch, mit leerem wie gefülltem Speicher, mit und ohne `--noscan`, `dev:` wie `disc:`), die **Windows**-Version tut es (Meldung 3338). Rippy stellt ein persistentes Datenverzeichnis bereit und nimmt den Schlüsselspeicher `_private_data.tar` einer Windows-Installation sowie ersatzweise eine `KEYDB.cfg` entgegen; damit ging Akira UHD auf der VM auf. Rippy liefert, lädt und verteilt KEINE Schlüssel. Siehe §10 (25.07.2026) |
| LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) | | LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) |
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence | | Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
| Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert | | Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert |
@@ -156,3 +162,61 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
- Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis) - Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis)
**Ergebnis:** Konzept ist wasserdicht für MVP-Phase. **Ergebnis:** Konzept ist wasserdicht für MVP-Phase.
## 10. Konzept-Fortschreibungen (dokumentierte Erweiterungen, keine Abweichungen)
- **24.07.2026 — Media-Server-Neutralität:** Die Jellyfin-Muss-Features
(Ordnerstruktur, NFO, Poster) bleiben vollständig bestehen; sie sind jetzt
über das Setting `mediaServer` auf Emby und Kodi ausgeweitet (identisches
Kodi-NFO-Schema) und für Plex auf die reine Benennung reduziert. Jellyfin
bleibt Referenz- und Empfehlungssystem im Einrichtungs-Assistenten.
- **24.07.2026 — Benachrichtigungen:** Webhook bei Job-Ende (Discord/Slack/
ntfy/generisch) als gebautes Feature — im UX-Flow Punkt 10 („Abschluss")
war das als Protokollierung angelegt, jetzt meldet Rippy aktiv.
- **23./24.07.2026 — udev → ioctl:** Der im Konzept beschriebene udev-Daemon
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
Kernel-ioctl (3 s) — gleiches Verhalten, universell lauffähig.
- **24.07.2026 — AUTH GESTRICHEN (Commander-Entscheid):** Das Muss-Feature
„JWT-Auth" ist komplett entfernt (Endpoints, auth.py, Abhängigkeiten,
Env-Pflicht). Begründung: Rippy läuft ausschließlich im Heimnetz, das UI
hatte nie einen Login-Flow — die Auth-Oberfläche war Placebo und die
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**
(`MAKEMKV_DATA_HOST`, im Worker `/root/.MakeMKV`, in der API
`/app/makemkv-data`) samt Bedienung im UI. Begründung: Am 25.07.2026 auf
BEIDEN Maschinen nachgemessen — `makemkvcon` unter Linux ruft Disc-Schlüssel
nie ab, die Windows-Version tut es. (Erste Fassung dieses Eintrags behauptete,
MakeMKVs Schlüssel-Kanal sei abgeschaltet; das war falsch und wurde am selben
Tag richtiggestellt.) Rippy nimmt deshalb den Schlüsselspeicher
`_private_data.tar` einer MakeMKV-Installation entgegen und ersatzweise eine
`KEYDB.cfg`. **Rippy liefert und verteilt KEINE Disc-Schlüssel und
lädt auch keine herunter** — es hält nur den Platz für Dateien bereit,
die der Nutzer selbst mitbringt, zeigt ehrlich an, was dort liegt, und gibt
die AACS-Dumps heraus, die MakeMKV ohnehin selbst schreibt. Das ist genau
die Grenze, die `docker/api/makemkv_key.py` in Zeile 14 zieht: die
kostenlose Beta-LIZENZ der Software ist etwas anderes als das
Entschlüsseln oder Verteilen von Disc-Schlüsseln.
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
EINDEUTIGER Zuordnung wird umbenannt).
+443 -87
View File
@@ -1,119 +1,475 @@
# README — Rippy # Rippy — die All-in-one Disc-Ripping-Maschine
> **Moderner Ripping-Daemon mit Metadaten-Preview, Jellyfin-Formatierung und JWT-Auth.** Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
--- abgelegt, wo du willst (lokal, NAS, jede Freigabe). Modernes Web-UI, komplett
in Docker, komplett lokal — keine Cloud, keine Telemetrie.
## Features
| Feature | Status |
|---------|--------|
| Disc-Erkennung (CD/DVD/Blu-ray) | ✅ |
| Metadaten-Lookup (TMDB/MusicBrainz/TheTVDB) | ✅ |
| Pre-Scan ohne Ripping | ✅ |
| Jellyfin-Formatierung (NFO + Images) | ✅ |
| JWT-Auth + Rate-Limiting | ✅ |
| React-UI mit Dashboard | ✅ |
| SQLite-Cache für API-Rate-Limits | ✅ |
| Multi-Disc-Set-Handling | ✅ |
---
## Architektur
```
┌─────────────────────────────────────────────┐
│ Proxmox LXC (Debian 12) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ API │ │ Worker │ │ UI │ │
│ │ FastAPI │◄─►│ Celery+ │ │ React+ │ │
│ │ │ │ Redis │ │ Nginx │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ PostgreSQL│ │ udev │ │ Media │ │
│ │ │ │ Daemon │ │ Store │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
```
--- ---
## Installation ## Installation
### Docker Compose 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 ```bash
git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git git clone <repo-url> rippy && cd rippy
cd rippy sudo ./install.sh
docker compose up -d
``` ```
### Port-Übersicht Das war es. Am Ende steht die Adresse im Terminal — im Browser öffnen, der
**Einrichtungs-Assistent** übernimmt den Rest.
| Service | Port | URL | Zuerst nur nachsehen, ob alles passt, ohne etwas zu ändern:
|---------|------|-----|
| UI | 80 | http://localhost:80 | ```bash
| API | 8000 | http://localhost:8000 | ./install.sh --nur-pruefen
| PostgreSQL | 5432 | localhost:5432 | ```
| Redis | 6379 | localhost:6379 |
<details>
<summary><b>Was <code>install.sh</code> für dich macht</b> (aufklappen)</summary>
Es nimmt genau die Schritte ab, an denen man vorher scheitern konnte:
1. **Prüft die Voraussetzungen** — Linux, Docker, Compose. Fehlt etwas, steht
der Installationsbefehl dabei.
2. **Findet dein Laufwerk selbst.** MakeMKV braucht **zwei** Geräteknoten:
`/dev/srN` und den passenden `/dev/sgM`. Welche sg-Nummer dazugehört, ist
**je Rechner anders** — das Skript gleicht sie über die SCSI-Adresse ab
statt zu raten. (Genau daran ging vorher die Handarbeit schief.)
3. **Legt die Verzeichnisse an** (`/srv/rippy/media` für die Filme,
`/srv/rippy/makemkv` für MakeMKVs Daten).
4. **Richtet die Mount-Propagation ein** und macht sie neustart-fest — sonst
funktioniert das Einhängen von NAS-Freigaben aus dem UI nach jedem Reboot
nicht mehr.
5. **Schreibt die `.env`** mit den gefundenen Werten. Bestehende Werte werden
**nie** überschrieben.
6. **Baut und startet** alles.
Das Skript ist **wiederholbar**: mehrmals ausführen ändert nichts kaputt. Es
ist auch der Update-Weg — siehe unten.
</details>
### Aktualisieren
```bash
git pull && sudo ./install.sh
```
--- ---
## API ## Mit einer Docker-Oberfläche (Dockge, Portainer, Arcane)
### Endpoints **Die eine Sache, die du wissen musst:** Rippy hat **keine fertigen Images in
einer Registry.** Die drei Dienste `api`, `worker` und `ui` werden aus dem Repo
gebaut (`build: context: .`). Die Compose-Datei allein ist deshalb wertlos —
wer sie in ein leeres Verzeichnis kopiert, bekommt sofort
*„failed to read dockerfile"*.
| Endpoint | Method | Description | Daraus folgt für **jede** Oberfläche: **das Repo muss dort liegen, wo die
|----------|--------|-------------| Oberfläche den Stack baut.** Alles andere ist Kleinarbeit.
| `/health` | GET | Health check |
| `/jobs` | GET | Alle Jobs | | | kann das Repo selbst holen? | Weg |
| `/devices` | GET | Alle Geräte | |---|---|---|
| `/prescan` | POST | Pre-Scan durchführen | | **Shell** | — | `git clone` + `sudo ./install.sh` |
| `/metadata/lookup` | POST | Metadaten lookup | | **Dockge** | nein | Repo **in** den Stacks-Ordner klonen |
| `/metadata/confirm` | POST | Metadaten bestätigen | | **Portainer** | **ja** | Stack aus *Repository* (Git-URL) |
| `/jellyfin/format` | POST | Für Jellyfin formatieren | | **Arcane** | nein | Repo auf dem Host, Deploy per Shell |
| `/token` | POST | Login (JWT) |
| `/token/refresh` | POST | Refresh Token | ### Vorab-Prüfung — braucht kein root, ändert nichts
| `/api-keys` | POST/GET/DELETE | API-Key Management |
Egal welche Oberfläche: Führe nach dem Klonen einmal das hier aus. Es sagt dir
in fünf Sekunden, ob die Maschine passt — vor allem, **welche Geräteknoten dein
Laufwerk wirklich hat**:
```bash
./install.sh --nur-pruefen
```
### Dockge
Dockge verwaltet Stacks als Verzeichnisse. Klone das Repo **in** den
Stacks-Ordner (Standard `/opt/stacks`, bei dir ggf. anders):
```bash
cd /opt/stacks
git clone <repo-url> rippy
cd rippy && ./install.sh --nur-pruefen
```
Dockge zeigt `rippy` danach als Stack an, und „Start" baut die Images. Du musst
**keine Compose-Datei einfügen** — die des Repos ist die des Stacks. Lege den
Stack also **nicht** neu in Dockge an, sonst landet eine leere Compose-Datei in
einem anderen Ordner und der Bau scheitert.
### Portainer
Portainer kann das Repo selbst klonen, das ist hier der bequemste Weg:
**Stacks → Add stack → Repository**, Git-URL eintragen, Compose-Pfad
`docker-compose.yml`. Der Web-Editor und „Upload" funktionieren **nicht**
beide liefern keinen Build-Kontext.
Die Geräteknoten trägst du als Stack-Umgebungsvariablen ein (statt in eine
`.env`), falls sie von `/dev/sr0` und `/dev/sg1` abweichen:
```
OPTICAL_SR=/dev/sr0
OPTICAL_SG=/dev/sg0
```
Welche es sind, sagt dir auf dem Host `lsscsi -g` — oder die Vorab-Prüfung oben.
### Arcane
Das Repo liegt auf dem Host (z. B. `~/projects/rippy`), Arcane verwaltet die
Container. Deployt wird per Shell — Arcanes Git-Sync zieht **nicht**
selbstständig:
```bash
cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build
```
### Was keine Oberfläche für dich tun kann
Drei Dinge passieren auf dem **Host**, nicht im Container — deshalb gibt es
`install.sh` überhaupt:
1. **Geräteknoten.** MakeMKV braucht `/dev/srN` **und** den passenden
`/dev/sgM`; die sg-Nummer ist je Rechner anders. Stimmt sie nicht, **startet
der Worker-Container gar nicht.** Das ist der Fehler, der praktisch immer als
erster kommt.
2. **Ablage-Ordner** `/srv/rippy/media` und `/srv/rippy/makemkv`.
3. **Mount-Propagation** (`rshared`) für das Einhängen von NAS-Freigaben aus dem
UI. Auf den meisten systemd-Hosts ist `/` schon `rshared` und es ist nichts
zu tun — nur wenn `docker compose up` über *„not a shared mount"* klagt,
braucht es die Shell.
Punkt 1 ist der einzige, der zuverlässig zuschlägt. `sudo ./install.sh` erledigt
alle drei; danach kannst du den Stack dauerhaft über die Oberfläche fahren.
--- ---
## Tech-Stack ## Wenn etwas nicht geht
| Layer | Tech | Die sechs Fälle, die praktisch alles abdecken:
|-------|------|
| Backend | Python, FastAPI, Celery | | Symptom | Ursache & Lösung |
| Database | PostgreSQL, SQLite, Redis | |---|---|
| Frontend | React, Vite, TailwindCSS | | **`failed to read dockerfile`** | Die Compose-Datei liegt ohne das Repo da. Rippy hat **keine Registry-Images**, die drei Dienste werden gebaut. Siehe **[Mit einer Docker-Oberfläche](#mit-einer-docker-oberfläche-dockge-portainer-arcane)**. |
| Ripping | MakeMKV, abcde, FFmpeg, HandBrake | | **Worker-Container startet nicht, `/dev/sgN` nicht gefunden** | Die sg-Nummer ist **je Rechner anders**; die Vorgaben `sr0`/`sg1` passen nur zufällig. `./install.sh --nur-pruefen` sagt dir die richtigen (oder `lsscsi -g`), dann in die `.env` bzw. als Stack-Variablen eintragen. **Der Fehler, der praktisch immer als erster kommt.** |
| Auth | JWT, OAuth2 | | **„Kein optisches Laufwerk gefunden"** | In einer **VM**? Das Laufwerk muss per **USB-Passthrough** durchgereicht werden, nicht als emuliertes CD-ROM (`media=cdrom`) — das kann keine SCSI-Kommandos, MakeMKV sieht es nie. Proxmox: `qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1`. Auf echter Hardware: `ls /dev/sr*` prüfen. |
| **Build bricht beim MakeMKV-Download ab** | Cloudflare drosselt manchmal. Der zuverlässige Weg: Tarballs von makemkv.com/download händisch nach `docker/worker/vendor/` legen, dann `sudo ./install.sh` erneut — der Build nimmt sie von dort und braucht kein Netz. Hast du eine eigene Quelle (Spiegel im LAN), trage sie als `MAKEMKV_URL_FALLBACK` in die `.env` ein; sie wird automatisch versucht, wenn makemkv.com nicht liefert. |
| **Kompression läuft ewig** | Deine CPU kann kein AVX2. Rippy zeigt das jetzt selbst an (Einstellungen → System, „Vektorbefehle"). Siehe **[Rippy schneller machen](#rippy-schneller-machen)**. |
| **4K-UHD: „The volume key is unknown"** | Erwartbar und **kein Fehler in Rippy**. Siehe **[4K-UHD](#4k-uhd)**. |
Logs ansehen: `docker compose logs -f` · Status: `docker compose ps`
--- ---
## Roadmap ## Rippy schneller machen
- [x] Etappe 1: Container-Infrastruktur **Läuft Rippy in einer virtuellen Maschine, ist das hier der wirksamste
- [x] Etappe 2: Ripping-Pipeline Handgriff überhaupt** — und er kostet fünf Minuten.
- [x] Etappe 3: Metadaten-Lookup + Pre-Scan
- [x] Etappe 4: Jellyfin-Formatierung Virtualisierer geben der VM standardmäßig eine **generische CPU** (bei
- [x] Etappe 5: API + Auth + WebUI Proxmox/KVM heißt sie `kvm64` bzw. `qemu64`). Die kann absichtlich nur alte
- [x] Etappe 6: Sicherheit + Compliance Befehlssätze, damit sich eine VM zwischen verschiedenen Wirten verschieben
- [x] Etappe 7: API UI Modernisiert lässt. Der Preis: **kein AVX2** — und genau davon lebt der Video-Encoder x265.
- [x] Etappe 8: Dark Mode & Separation of Concerns
- [ ] Etappe 9: Proxmox-Integration Was das ausmacht, ist auf der Rippy-Maschine gemessen worden: ein 4K-Film in
H.265 brauchte **28 bis 55 Stunden**, bei voll ausgelasteten Kernen. Mit AVX2
sind typisch **2- bis 4-mal schneller** drin.
**So stellst du es um (Proxmox):**
1. VM **herunterfahren.** Ein Neustart von innen genügt nicht — Proxmox
übernimmt Hardware-Änderungen nur bei einem echten Stopp.
2. Im Web-UI: VM auswählen → **Hardware****Processors** → Doppelklick →
**Type** auf **`host`** stellen → OK.
Oder auf der Proxmox-Konsole:
```bash
qm set <vmid> --cpu host
```
3. VM **starten.**
**Nachprüfen — Rippy sagt es dir selbst:** Einstellungen → System, Zeile
„Vektorbefehle" beim Worker. Steht dort jetzt `avx2` oder `avx512f` statt
`sse4_2`, hat es geklappt und die amberfarbene Warnung verschwindet.
Auf der Kommandozeile: `grep -o avx2 /proc/cpuinfo | head -1`.
<details>
<summary>Nachteile von <code>host</code> — der Vollständigkeit halber</summary>
Die VM sieht dann die echte CPU. Dadurch lässt sie sich nicht mehr auf einen
Wirt mit anderem Prozessor **live** verschieben. In einem Heim-Cluster mit
einem einzigen Wirt ist das ohne Bedeutung. Wer mehrere Wirte hat und
Live-Migration nutzt, wählt statt `host` das neueste Modell, das **alle**
Wirte beherrschen (z. B. `x86-64-v3` — das enthält AVX2).
</details>
**Kein AVX2 möglich?** Dann zwei Auswege, beide im UI:
- **4K nicht komprimieren** (Einstellungen → Verarbeitung → Preset für 4K-UHD →
„Nicht komprimieren"). Die verlustfreie Datei bleibt stehen: beste Qualität,
20100 GB je Film. Der Einrichtungs-Assistent wählt das bei schwacher CPU
von selbst.
- **Andere Maschine komprimieren lassen** — siehe
[Weitere Maschinen als Worker](#weitere-maschinen-als-worker).
--- ---
## License ## Der Alltag
GPL-v3 ### Rippen
Disc einlegen. Rippy erkennt sie, zeigt Titel und Poster, du wählst das Ziel —
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und es
läuft ohne Nachfrage los.
- **Alle Tonspuren und Untertitel bleiben erhalten** (wichtig für Anime/O-Ton).
- **Nur Hauptfilm** auf Wunsch — der längste Titel, Extras bleiben weg.
- **Serien:** Serienname + Staffel angeben → Ablage `<Serie>/Season NN`, und
die Episoden werden per Laufzeitabgleich (TMDB) zu „Serie S01E02.mkv"
benannt. Nur bei eindeutiger Zuordnung, sonst bleiben die Namen — mit Log.
- **Audio-CDs** laufen über abcde zu FLAC mit MusicBrainz-Tags.
- Schon gerippte Discs erkennt Rippy am Fingerabdruck und warnt.
- Vor dem Start wird der freie Platz gegen die Disc-Größe geprüft.
### Kompression
Ein **eigenes Preset je Disc-Typ** (Einstellungen → Verarbeitung) — Rippy
erkennt den Typ selbst. Das ist wichtig, weil ein 1080p-Preset eine 4K-UHD
stillschweigend herunterrechnet und eine DVD sinnlos hochskaliert.
Jeder Typ kann auch auf **„Nicht komprimieren"** stehen: dann bleibt der
verlustfreie Rip liegen. Sinnvoll für 4K, wenn die Ablage groß genug ist.
Die Rohdatei wird erst nach erfolgreicher Kompression gelöscht („Original
behalten" als Option). Fehlgeschlagene Kompressionen lassen sich ohne
Neu-Rip erneut anstoßen.
### Ablage & Media-Server
Einstellungen → Ripping: dein System wählen (Jellyfin, Emby, Kodi, Plex oder
keins). Fertige Rips heißen dann „Titel (Jahr)"; für Jellyfin/Emby/Kodi legt
Rippy zusätzlich `movie.nfo` + `poster.jpg` dazu. Bei **Jellyfin/Emby** mit
Server-URL + API-Key stößt Rippy nach jedem Rip sofort einen
Bibliotheks-Scan an — Disc rein, Film erscheint im Server.
**NAS-Freigaben** hängst du unter Einstellungen → Speicherziele direkt aus dem
UI ein (NFS/SMB). Sie erscheinen sofort in der Ziel-Auswahl und werden beim
Start automatisch wieder verbunden.
⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen Datenbank — bewusster
Heimnetz-Kompromiss. Lege fürs NAS einen eigenen, eingeschränkten Benutzer an.
### Benachrichtigungen
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen, „Test senden"
drücken, speichern. Rippy erkennt den Dienst an der URL selbst:
| Dienst | URL-Beispiel |
|---|---|
| Discord | `https://discord.com/api/webhooks/…` |
| Slack | `https://hooks.slack.com/services/…` |
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` |
| Eigenes (Home Assistant, n8n, …) | beliebige HTTPS-URL |
--- ---
## Credits ## 4K-UHD
- **Commander**: Projekt-Idee, Anforderungen, Testing 4K braucht **zwei** Dinge, die normale BD/DVD nicht brauchen: ein
- **AI-Box**: Entwicklung, Architektur, Dokumentation **LibreDrive-fähiges Laufwerk** (MakeMKV-Forum: „Ultimate UHD Drives Flashing
Guide") **und** den **Schlüssel dieser Pressung**.
**Der Schlüssel ist die eigentliche Hürde — aus einem überraschenden Grund.**
Am 25.07.2026 auf beiden Maschinen gemessen (Akira UHD, MKB v76, Pressung
Dezember 2020): Laufwerk und MakeMKV waren in Ordnung — „Using LibreDrive mode
(v06.3)", die Disc wurde gelesen — und trotzdem kam „The volume key is unknown
for this disc".
> **`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz.
> Die Windows-Version tut es.**
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach draußen auf
— geprüft mit leerem und gefülltem Schlüsselspeicher, mit und ohne `--noscan`,
mit `dev:` und `disc:`, und mit erzwungener frischer Prüfung. Dieselbe Disc am
selben Laufwerk unter Windows: „Lade aktuelle HK …", Verbindung zum
Schlüssel-Server, Disc geht auf. **Ein MakeMKV-Update ändert daran nichts, und
es ist kein Fehler in Rippy.**
**Der Weg drumherum** (Einstellungen → System):
1. MakeMKV auf einem **Windows-PC** installieren (gleicher Beta-Key).
2. Laufwerk anstecken, Disc **einmal öffnen** — dabei lädt MakeMKV die
Schlüssel nach.
3. In MakeMKV unter *Preferences → General* das „MakeMKV data directory"
nachschlagen und die Datei **`_private_data.tar`** daraus bei Rippy
hochladen.
Rippy zeigt an, **wie viele Disc-Schlüssel** dieser Worker kennt. Steht dort 0,
scheitert jede unbekannte UHD. Für neue Discs den Schritt gelegentlich
wiederholen. Rippy prüft die Datei und lehnt sie ab, wenn kein einziger
Schlüssel drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
**Kennt MakeMKV die Pressung selbst nicht**, bleibt die **`KEYDB.cfg`** als
Notnagel (gleicher Tab). Und: **AACS-Dumps herunterladen** — den Dump legt
MakeMKV bei jeder unbekannten Disc selbst ab, und genau den braucht man, wenn
man im MakeMKV-Forum um den Schlüssel für eine neue Pressung bittet.
> ⚠️ **Rippy liefert keine Disc-Schlüssel mit, lädt keine herunter und
> verteilt keine.** Rippy hält nur den Platz für eine Datei bereit, die du
> selbst mitbringst, und zeigt ehrlich an, was dort liegt. Der
> MakeMKV-**Beta-Key** ist die Lizenz für die *Software* — zwei völlig
> verschiedene Dinge.
⚠️ UHD-Rohdaten sind bis 100 GB groß. Ist die Platte zu klein, lege das
**Arbeitsverzeichnis** (Einstellungen → Verarbeitung) auf eine eingehängte
Freigabe. Rippy bricht sonst **vor** dem Rip mit Klartext ab statt nach 40 GB
mit voller Platte.
---
## Weitere Maschinen als Worker
Die Kompression läuft als eigener Task auf der Queue `transcode` — **jede**
Maschine im Netz kann sie übernehmen. Einstellungen → Worker zeigt für beide
Varianten einen Copy-Paste-Befehl:
- **Windows (nativ, ohne Docker)** — braucht nur Python. Der Installer kommt
als fertige `.exe` von Rippy selbst; Worker-Code und HandBrake holt er zur
Laufzeit.
- **Linux (Docker)** — `deploy/remote-transcode-worker.yml`.
Rippy zeigt für jeden Worker an, **was er wirklich kann**: Encoder, Kernzahl,
Vektorbefehle. Für einen **GPU-Worker** wichtig: das Bild braucht ein
HandBrake mit `nvenc_*` bzw. `qsv_*` — die Anzeige nennt HandBrakes
ungefilterte Auskunft, damit du das nachprüfen kannst. Das mitgelieferte
Linux-Worker-Bild hat **keinen** Hardware-Encoder.
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy bleibt
All-in-one.
---
## Für Fortgeschrittene
<details>
<summary><b>Umgebungsvariablen (.env)</b></summary>
`install.sh` füllt die hostabhängigen Werte selbst. Alles hier ist optional —
UI-Einstellungen überstimmen die Env-Variablen.
| Variable | Zweck |
|---|---|
| `TMDB_API_KEY` | Metadaten (deutsche Texte). Bequemer im Assistenten — beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
| `OMDB_API_KEY` | zweite Metadaten-Quelle (Fallback) |
| `THETVDB_API_KEY` | Serien-Fallback |
| `WORKER_NAME` | Anzeigename des eingebauten Workers (Standard `rippy-hauptworker`) |
| `MAKEMKV_APP_KEY` | MakeMKV-Beta-Key. Bequemer im UI unter Einstellungen → System — gilt ab dem nächsten Rip, ohne Rebuild |
| `MAKEMKV_VERSION` | MakeMKV-Version für den Image-Build |
| `MAKEMKV_URL_BASE` | Download-Quelle für den Build. Normalerweise **nichts eintragen** — der Standard `makemkv.com/download` liefert (am 25.07.2026 mit HTTP 200 geprüft; `/download/old` gibt 525, ein web.archive.org-Schnappschuss 404) |
| `MAKEMKV_URL_FALLBACK` | zweite Quelle, die bei Fehlschlag der ersten **automatisch** versucht wird. Leer = keine. Trage nur ein, was du selbst geprüft hast: eine Adresse, die nicht liefert, lässt die Konfiguration gesund aussehen und den Build später scheitern |
| `MAKEMKV_DATA_HOST` | Host-Verzeichnis für MakeMKVs Daten (Standard `/srv/rippy/makemkv`) — Schlüsselspeicher, `KEYDB.cfg`, AACS-Dumps. Persistent, überlebt jeden Rebuild |
| `OPTICAL_SR` / `OPTICAL_SG` | Geräteknoten des Laufwerks. **Findet `install.sh` selbst** |
| `POSTGRES_PASSWORD` | DB-Passwort (Standard `rippy`) |
</details>
<details>
<summary><b>Installation ohne install.sh (von Hand)</b></summary>
Falls du jeden Schritt selbst machen willst:
```bash
cp .env.example .env
mkdir -p /srv/rippy/media /srv/rippy/makemkv
# Laufwerksknoten ermitteln — die sg-Nummer ist je Host anders!
lsscsi -g # zeigt Modell + zugehoerigen /dev/sgN
# in die .env: OPTICAL_SR=/dev/sr0 und OPTICAL_SG=/dev/sgN
# Nur falls 'docker compose up' meldet:
# "path ... is mounted on / but it is not a shared mount"
mount --bind /srv/rippy/media /srv/rippy/media
mount --make-rshared /srv/rippy/media # neustart-fest als systemd-.mount-Unit
docker compose up -d --build
```
Braucht klassisches Linux-Docker mit `SYS_ADMIN` — nicht Docker Desktop,
rootless oder Podman. Die Mount-Propagation ist nur für das Einhängen von
NAS-Freigaben **aus dem UI** nötig; Freigaben auf dem Host einhängen geht immer.
</details>
<details>
<summary><b>Wie es intern funktioniert</b></summary>
1. **Disc-Wache** — ioctl-Polling, kein udev. Im Container läuft kein udevd,
die udev-Datenbank ist leer; `udevadm info` liefert dort prinzipbedingt
nichts.
2. **Rip** — MakeMKV, verlustfrei. Der einzige Weg durch AACS: HandBrake kann
verschlüsselte Discs nicht lesen, deshalb sind es zwingend zwei Stufen.
3. **Kompression** — HandBrake als eigener Celery-Task auf eigener Queue,
deshalb an andere Maschinen routbar.
4. **Metadaten** — Volume-Label → TMDB → OMDb → MyAnimeList, mit
Ähnlichkeits-Bewertung und Korrektur-Popup an der Disc-Karte.
</details>
<details>
<summary><b>Härtung für exponierte Netze</b></summary>
Rippy ist bewusst **Heimnetz-only**: keine Authentifizierung, und Redis +
PostgreSQL veröffentlichen Ports für Remote-Worker. Im vertrauten LAN ein
akzeptierter Kompromiss — **exponiere Rippy niemals ungeschützt an ein
öffentliches Netz.**
- **Zugriff kapseln:** UI und API (`:8000`) hinter einen Reverse-Proxy mit Auth
oder nur über VPN erreichbar machen.
- **DB/Broker abschotten:** starkes `POSTGRES_PASSWORD`; die Ports
`5432`/`6379` nur an ein internes/VPN-Interface binden statt an `0.0.0.0`;
Redis mit `--requirepass` starten und das Passwort in `REDIS_URL` ergänzen.
- **CORS:** `main.py` erlaubt `*` (nötig, weil UI und API getrennte Ports
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
- **NAS-Zugangsdaten** liegen im Klartext in der DB — ein weiterer Grund, den
DB-Port nie offen ins unsichere Netz zu hängen.
</details>
<details>
<summary><b>Updates der Werkzeuge</b></summary>
„Auf Updates prüfen" (Einstellungen → System) vergleicht mit makemkv.com und
den offiziellen HandBrake-Releases, 12 h gecacht.
**MakeMKV** aktualisieren: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
`sudo ./install.sh`. Der Beta-Key wechselt etwa monatlich (Forum-Thread
t=1053) und wird im UI gepflegt — ohne Rebuild.
**HandBrake** kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
offiziellen Version hinterher); der native Windows-Worker nutzt die aktuelle.
⚠️ Ein MakeMKV-Update hilft **nicht** gegen „The volume key is unknown" —
MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es holt sie zur Laufzeit,
und die Linux-Version holt sie nie. Siehe [4K-UHD](#4k-uhd).
</details>
---
## Entwicklung
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
einen Branch, `main` — die Ampel prüft nur, deployt wird direkt aus `main`.
Rot heißt: nicht deployen.
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
Fahrplan: [ROADMAP.md](ROADMAP.md) · Stand: [SAVEPOINT.md](SAVEPOINT.md)
+336 -1
View File
@@ -249,5 +249,340 @@ Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf.
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere) - [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade - [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume - [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
- [ ] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem Rip) - [x] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem
Rip) — 24.07., medien.py: Ordner „Titel (Jahr)" + movie.nfo + poster.jpg,
gesteuert übers mediaServer-Setting (Jellyfin/Emby/Kodi/Plex/keins)
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts) - [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
---
## Etappe 12: Besser als ARM (aus ARM-Analyse 23.07.2026)
**Quelle:** Vollanalyse von Automatic Ripping Machine v2.24 (Code, arm.yaml,
Wiki, Issues). Was Rippy strukturell schon besser macht: Lossless-first statt
Transcode-first (ARMs BD-Default ist ein 1080p30-Downscale!), Celery+Postgres
statt Prozess-pro-udev-Event+SQLite (ARM-UI friert beim Rippen ein, #1046),
ioctl-Watcher statt fragiler udev-Ketten (ARMs größte Support-Quelle), NFO +
Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs).
**Übernehmen (priorisiert):**
- [ ] **Disc-Fingerprint-DB (selbstlernend)** — ARMs beste Idee, besser gemacht:
Tabelle disc_fingerprints (DVD-CRC64/MusicBrainz-DiscID/BD-Label-Hash →
bestätigte Metadaten). Jede UI-Bestätigung schreibt zurück; zweite Disc
derselben Serie wird sofort erkannt. Lokal statt ARMs Crowd-Server
(Single Point of Failure auf Free-Hosting).
- [ ] **BD-Titel aus BDMV/META/DL/bdmt_eng.xml** als Identifikations-Stufe vor
den APIs (echter Disc-Titel statt Label-Raterei).
- [ ] **Progressive Suchdegradation** bei TMDB/OMDb: mit Jahr → ohne Jahr →
tokenweises Titel-Stripping; jede Stufe senkt die Confidence.
- [ ] **Duplikat-Schutz**: Fingerprint-Treffer → „schon gerippt am X — trotzdem?"
- [ ] **Track-Auswahl (Manual Mode), besser als ARM**: Job-Status
awaiting_selection nach makemkvcon-info; Track-Tabelle im UI mit
Heuristik-Vorauswahl; Timeout → Weiter mit Default (ARM bricht hart ab,
erlaubt nur EINE Auswahl).
- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht
Jellyfin nativ) statt alles flach.
- [x] **Benachrichtigungen bei Job-Ende** — 24.07., eigenes notify-Modul
(Discord/Slack/ntfy/generisches JSON, Dienst-Erkennung an der URL)
statt Apprise-Dependency; Test-Knopf im UI. Telegram/Deep-Links offen.
- [ ] **Multi-Drive-Verwaltung**: drives-Tabelle (Seriennummer, Custom-Name,
UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung.
- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs.
- [ ] **Jellyfin-Refresh** nach Ablage (POST /Library/Refresh).
**ARM schlagen (Alleinstellung):**
- [ ] **Serien-Episoden-Erkennung** — ARMs offene Wunde seit 2019 (#395):
TVDB-Episodenlaufzeiten gegen Track-Laufzeiten matchen (±5 %,
Reihenfolge-Constraint, Disc-Nr aus Label), Vorschlag
„Show/Season 02/Show S02E05.mkv" im Preview bestätigen.
- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt
ARMs „größte Datei gewinnt" (#1297-Fehlklasse).
---
## Etappe 13 (24.07.2026): Universal-Komfort-Runde — Lücken aus dem Alltag schließen
**Quelle:** Commander-Sammelauftrag 24.07. — alles, was beim echten Benutzen
auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben.
**Gebaut:**
- [x] **Ampel entrostet**: bcrypt auf 4.0.1 gepinnt (passlib-1.7.4-Bruch) und
cache_keys-Test an das Fingerprint-Format vom 23.07. angepasst — DIE
beiden Rot-Ursachen, wegen derer `stable` 10 Commits zurückhing.
- [x] **Media-Server-Integration**: mediaServer-Setting (Wizard + Einstellungen),
Zielordner „Titel (Jahr)", movie.nfo/tvshow.nfo + poster.jpg für
Jellyfin/Emby/Kodi (medien.py im Worker, mit Tests).
- [x] **Benachrichtigungen echt gemacht**: notify.py (API+Worker), Meldung bei
fertig/fehlgeschlagen/abgebrochen, Anleitung + Test-Knopf im UI —
das Webhook-Feld war vorher ein Placebo, nichts hat je gesendet.
- [x] **SMB-Freigaben-Scan repariert**: NT_STATUS-Fehler → Klartext (Gast-
Abfrage verweigert ⇒ Benutzer/Passwort nötig), Credential-Felder im UI
VOR den „Freigaben auflisten"-Knopf gezogen.
- [x] **4K-UHD-Platzproblem**: Arbeitsverzeichnis konfigurierbar (workDir,
z. B. NAS-Freigabe), Platz-Check per Disc-Größe (ioctl) VOR dem Rip.
- [x] **MakeMKV-Key über UI** (Einstellungen → System): gilt ab dem nächsten
Rip, ohne Rebuild; Worker melden Werkzeug-Versionen + Key-Quelle.
- [x] **Job-Detail-Popup**: Klick auf Titel in „Neueste Jobs" → Poster, Jahr,
Beschreibung, Ablagepfad, Fehler (Job speichert Metadaten jetzt mit).
- [x] **UI-Feedback überall**: Toast-System (reinploppende Meldungen), drehende
Refresh-Knöpfe, Logs-Filter um Warnung/Fehler-Pills ergänzt.
- [x] **Datei-Browser zeigt Dateien**: Ordner wirkten „leer", weil nur
Unterordner gelistet wurden — Dateien erscheinen jetzt grau mit Größe.
- [x] Favicon (Disc im Logo-Verlauf) + Footer „Created with ❤️ by LucyAI,
Claude and KrBrZ".
- [x] README: Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
„Rippy woanders bereitstellen" (beliebiger Docker-Host).
- [x] **Download-Knopf für fertige Rips** (Wunsch aus der Übernahme-Session):
GET /jobs/{id}/files (+ Datei-Stream, Pfad-Validierung strikt unter
/app/media inkl. realpath-Check), Download-Knopf in der Aktion-Spalte,
Dateiliste mit Größen im Job-Detail-Popup — vorher kam man an fertige
MKVs nur per scp. nginx hatte proxy_buffering off schon (SSE).
**Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten.
---
## Etappe 14 (24.07.2026): Praxis-Feedback-Runde — erste echte Nutzung
**Quelle:** Commander-Feedback nach dem ersten richtigen Arbeiten mit v3.2.
- [x] SMB-Mount-Blocker: CAP_DAC_READ_SEARCH für mount.cifs (auf der VM
reproduziert + bewiesen), Compose-Fix.
- [x] TMDB-Key-Falle: v3-Schlüssel wurden still 401 (Client konnte nur
v4-Bearer) — beide Arten unterstützt, „Verbindung prüfen" im UI.
- [x] 4K UHD als eigener Disc-Typ (≥ 55 GiB) mit eigener Farbe + Klartext-
Fehler bei Nicht-LibreDrive-Laufwerken.
- [x] Vollautomatik-Setting (Disc rein → Rip startet ohne Popup).
- [x] Job-Verwaltung: can_retry (Knopf nur bei vorhandenen Rohdaten),
Einzel-Löschen, „Erledigte aufräumen", „Alle herunterladen".
- [x] Worker: WORKER_NAME-Anzeigename + IP/ID, verwaiste Einträge löschbar.
- [x] Ripping-Tab nach Medium (Video/Audio/Allgemein), Untertitel-Klartext,
CD → Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung eingeklappt.
---
## Etappe 15 (24.07.2026): Restefeger — der Ideen-Katalog wird abgearbeitet
**Commander-Auftrag:** „Arbeite alles ab, abgesehen von Auth — das ist
irrelevant und kann komplett raus."
- [x] **AUTH KOMPLETT ENTFERNT** (Commander-Entscheid): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT,
JWT_SECRET_KEY-Pflicht — alles raus (KONZEPT §10). Der Schnellstart
braucht keine .env-Pflichtwerte mehr. Rate-Limit pro IP bleibt.
- [x] **Serien-Staffel-Flow**: Im Rip-Dialog Serienname + Staffel →
Ablage `<Serie>/Season NN`; tvshow.nfo + poster.jpg landen im
Serien-Ordner (werden bei Staffel 2 nicht überschrieben).
- [x] **Episoden-Erkennung per Laufzeitabgleich** (ARM-Wunde #395):
Datei-Laufzeiten (HandBrake-Scan) gegen TMDB-Episoden-Laufzeiten,
ordnungserhaltend; komplette Staffel auf einer Disc geht auch bei
uniformen Laufzeiten. Umbenannt wird NUR bei eindeutiger Zuordnung
(„Serie S01E02.mkv"), sonst bleiben die Namen + ehrliches Log.
- [x] **Jellyfin/Emby-Bibliotheks-Refresh** nach jedem fertigen Rip
(POST /Library/Refresh, X-Emby-Token) — URL/Key + Test-Knopf in den
Einstellungen. Die Null-Klick-Kette Disc→Bildschirm steht.
- [x] **Duplikat-Warnung**: Disc-Fingerabdruck gegen die Job-Historie —
Hinweis auf der Disc-Karte, Vollautomatik überspringt Duplikate.
- [x] **„Nur Hauptfilm" ECHT**: das Setting war wirkungslos — jetzt
Info-Lauf → längster Titel → nur der wird gerippt; pro Rip im
Dialog übersteuerbar. (Volle Track-Tabelle bleibt Ausbaustufe.)
- [x] **Deutsche Texte für OMDb-Treffer** via TMDB /find (IMDb-ID → de-DE).
- [x] **Speicherplatz in der Dashboard-Leiste** (amber unter 60 GB frei).
- [x] **CSV-Export** der Job-Historie (Semikolon+BOM, Excel-tauglich).
- [x] **Metadaten-Seite entfernt** (+ Placebo-Endpoints /metadata/lookup
und /metadata/confirm — lookup scannte ein Dummy-Device).
- [x] **Doppel-Jahr-Fix** („X (2009) (2009)" in Log/Ordnernamen).
- [x] **Remote-Worker-Blocker**: redis/postgres waren nie veröffentlicht —
kein Remote-Worker konnte sich je verbinden. Ports 6379/5432 jetzt
offen (Heimnetz-Kompromiss, dokumentiert) + API_URL für Worker.
---
## Etappe 16 (24.07.2026): Track-Tabelle + nativer Windows-Worker + Anleitung
- [x] **Volle Titel-Auswahl vor dem Rip**: scan_tracks-Worker-Task,
Polling-Endpoints, Tabelle (Dauer/Größe/Kapitel, Vorauswahl ≥ 5 min)
im Rip-Dialog, Multi-Titel-Rip (ein makemkvcon-Aufruf je Titel,
Fortschritt anteilig). Parser mit Tests.
- [x] **Nativer Windows-Worker ohne Docker**: install.ps1 + Worker-Code
werden von der Rippy-Instanz selbst serviert (/worker-setup/*);
HandBrakeCLI 1.9.2 vom offiziellen GitHub-Release; celery
--pool=solo -Q transcode; optional Autostart. UI bietet Linux- und
Windows-Variante. E2E auf echtem Windows-PC bewiesen.
- [x] **Anleitungs-Tab**: Rippy erklärt sich selbst (Disc-Weg, Serien,
NAS, Media-Server, Benachrichtigungen, Key, Worker, FAQ).
---
## Etappe 17 (25.07.2026): 4K-UHD-Schlüssel (KEYDB.cfg)
**Quelle:** Messung am 25.07. live auf der Rippy-VM im Worker-Container.
Eine 4K-UHD (Akira, MKB v76, Pressung Dezember 2020) scheitert an „The
volume key is unknown for this disc" — obwohl makemkvcon „Using LibreDrive
mode (v06.3)" und „Using direct disc access mode" meldet, die Disc liest
und den AACS-Dump ablegt (Meldung 3332). Das Debug-Log geht ohne einen
einzigen Netz-Versuch von „Loaded content hash table" direkt auf den
Fehler; `_private_data.tar` enthielt nur die Index-Datei und keine einzige
`hkd_*.bin`; auch mit gelöschter `update.conf` (Meldung 5074 belegt den
Web-Kontakt) und `app_UpdateEnable = "1"` kam kein Schlüssel; die
Forum-Schlüssel-Server `hkdata.fairuse.org` und `hkdata.crabdance.com`
lösen weltweit nicht mehr auf. **Fazit: Ein MakeMKV-Update löst das
nicht** (das behauptete v3.3 — dort richtiggestellt). Der einzige heute
funktionierende Weg ist eine `KEYDB.cfg` im MakeMKV-Datenverzeichnis.
> ⚠️ **Nachtrag am selben Tag (siehe Etappe 18): Die letzte Folgerung war
> falsch.** Die beiden toten Hostnamen stammen aus alten Forumsbeiträgen und
> werden von MakeMKV längst nicht mehr benutzt — der Dienst lebt. Richtig
> ist: `makemkvcon` unter **Linux** fragt nie nach Schlüsseln, die
> **Windows**-Version schon. Alles hier Gebaute bleibt richtig und nötig,
> nur die `KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
**Gebaut:**
- [x] **Persistentes Datenverzeichnis**: `${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}`
vom Host gemountet — Worker `/root/.MakeMKV`, API `/app/makemkv-data`,
beide mit `MAKEMKV_DATA_DIR`. `KEYDB.cfg` und AACS-Dumps überleben
jeden Rebuild. `.env.example` erklärt die Variable.
- [x] **entrypoint.sh entschärft**: `settings.conf` wird ergänzt statt
überschrieben (der Beta-Key hatte sonst alles andere gelöscht),
`app_UpdateEnable = "1"` gesetzt.
- [x] **Zwillings-Modul `makemkv_daten.py`** (identisch in `docker/api/`
und `docker/worker/`) als einzige Wahrheit über das Verzeichnis:
Status lesen, Inhalt prüfen, atomar schreiben, löschen, Dumps
auflisten — alles reine Funktionen, damit die Ampel sie ohne
Postgres/Redis testen kann.
- [x] **API**: `GET/POST/DELETE /system/keydb`, `GET /system/aacs-dumps`
und `GET /system/aacs-dumps/{dateiname}`. Der Inhalt kommt bewusst
als JSON-Body — es gibt kein `python-multipart`, ein Endpunkt mit
`UploadFile`/`File()` würde die API beim Import töten.
- [x] **UI (Einstellungen → System)**: Status der `KEYDB.cfg` (Pfad,
Größe, Anzahl Disc-Einträge, Datum), Inhalt einfügen, entfernen,
AACS-Dumps herunterladen — ohne SSH auf die VM.
- [x] **MakeMKV redet endlich**: `parse_msg()` in `ripping.py` + Log-Callback
in `tasks.py` schreiben MakeMKV-Meldungen ins Rippy-Log (gedrosselt:
Code 1003 raus, keine Wiederholungen, max. 40 je Rip). UHD-Fehlertext
ehrlich neu geschrieben; `caps.py` meldet `keydb: ja | nein | unbekannt`.
- [x] **Doku nachgezogen**: README (UHD-Voraussetzungen, System-Panel,
`MAKEMKV_DATA_HOST`, Bereitstellung), SAVEPOINT v3.10 inkl.
Richtigstellung von v3.3, KONZEPT §8 + §10.
**Offen aus dieser Runde:**
- [ ] **Der Nachweis mit einer echten `KEYDB.cfg` steht aus.** Zum
Zeitpunkt der Änderung lag keine Datei vor, die den Akira-Schlüssel
enthält — belegt sind der Befund und die Mechanik, NICHT ein
erfolgreicher UHD-Rip.
- [ ] Damit bleibt auch der volle Transcode-E2E an einer UHD weiter offen
(siehe SAVEPOINT v3.7) — an normalen BD/DVD ist die Kette bewiesen.
**Nachtrag zu „Offen":** Beide Punkte sind mit Etappe 18 erledigt — Akira
ging auf der VM auf, allerdings über den Schlüsselspeicher statt über eine
`KEYDB.cfg`.
**Ausdrücklich nicht gebaut (und wird es auch nicht):** Rippy liefert
keine Disc-Schlüssel mit, lädt keine herunter und verteilt keine. Es
stellt nur den Platz für eine Datei bereit, die der Nutzer selbst
mitbringt, und zeigt ehrlich an, was dort liegt.
---
## Etappe 18 (25.07.2026): 4K-UHD gelöst — Schlüsselspeicher statt KEYDB.cfg
**Quelle:** Einwand des Commanders („wenn ich MakeMKV lokal auf meinem
Windows-PC installiere, würde es SOFORT gehen — wir übersehen etwas
Gewaltiges"). Er hatte recht. Gegenprobe mit demselben Laufwerk und
derselben Disc an einem Windows-PC:
| | Linux (Worker) | Windows |
|---|---|---|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
| Meldung 3338 „Downloading latest HK" | nie | ja |
| `_private_data.tar` | 2048 B, 0 Schlüssel | 6,4 MB, 604 Schlüssel |
| Disc | „volume key is unknown" | **TCOUNT:5, geht auf** |
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`: Linux
fragt nie. Die Meldungsvorlage „Downloading latest %1 to %2 ..." steckt
sehr wohl im Linux-Binary — sie löst nur nicht aus. Gleiches Symptom im
MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
**Damit ist die Diagnose aus Etappe 17 („der Schlüssel-Kanal ist tot")
widerlegt.** Sie stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen,
die MakeMKV längst nicht mehr benutzt.
**Gebaut:**
- [x] **Schlüsselspeicher im Modul**: `zaehle_schluessel`,
`private_data_pruefen`, `schluesselspeicher_status`,
`private_data_schreiben` in `makemkv_daten.py` (beide Zwillinge).
Die Prüfung lehnt einen Speicher OHNE `hkd_*.bin` ab — sonst lädt
jemand den leeren Vorrat einer frischen Installation hoch, es ändert
sich nichts, und niemand versteht warum.
- [x] **API**: `GET /system/keystore` und `POST /system/keystore`. Der
Rohkörper der Anfrage IST die Datei — binär, deshalb kein JSON und
kein Base64; Multipart kann die API ohnehin nicht.
- [x] **UI**: neuer Block „Disc-Schlüssel für 4K-UHD" ÜBER dem
KEYDB-Block, mit Schlüssel-Anzahl, Upload und Schritt-für-Schritt-
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als Notnagel
beschriftet. Worker-Plakette zeigt die Schlüssel-Anzahl.
- [x] **Fehlertext** in `tasks.py` sagt den Windows-Weg an und nennt die
Anzahl bekannter Schlüssel dieses Workers.
- [x] **`caps.py`** meldet `schluessel` je Worker.
- [x] **Alle Falschaussagen korrigiert**: UI (3 Stellen), Anleitung (2),
README (3), KONZEPT §8 + §10, `makemkv_daten.py`-Modulkopf,
Worker-Dockerfile, `makemkv_key.py`.
**Bewiesen:** Nach Übernahme des Windows-Schlüsselspeichers öffnet
`makemkvcon` auf der VM die Akira-UHD — „Operation successfully
completed", `TCOUNT:5`, fünf Titel, identisch zum Windows-Ergebnis.
**Das ist der erste belegte UHD-Disc-Zugriff auf der Rippy-Maschine.**
**Offen aus dieser Runde:**
- [ ] Voller UHD-Rip inklusive Transcode-E2E (nur der Disc-Zugriff ist
belegt, nicht die komplette Kette bis zur fertigen Datei).
- [ ] Ob sich der Abruf unter Linux doch anstoßen lässt, ist ungeklärt —
der Code ist im Binary vorhanden. Bis dahin bleibt der Windows-Umweg.
---
## Etappe 19 (25.07.2026): Preset je Disc-Typ
**Quelle:** Rückfrage des Commanders beim ersten echten UHD-Rip („merkt
Rippy eigentlich, wenn es eine UHD-Disc ist, und wendet direkt das
4K-Preset an?"). Antwort war: nein. Die Kompression fragte den Disc-Typ
gar nicht — ein globales `transcodePreset` galt für alles, live eingestellt
`HQ 1080p30 Surround`. Der laufende 4K-Rip wäre danach auf 1080p
heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
**Gebaut:**
- [x] **`preset_fuer(disc_type, einstellungen)`** in `ripping.py` — pure
Funktion, drei Tests. Reihenfolge: Preset des Disc-Typs → allgemeines
`transcodePreset``DEFAULT_HB_PRESET`. Bestandsinstallationen
ändern ihr Verhalten NICHT, solange die neuen Felder ungespeichert sind.
- [x] **`transcode_files`** holt den Disc-Typ aus dem Job-Datensatz und
schreibt ihn mit ins Log („Disc-Typ 'uhd', Preset '…'").
- [x] **UI**: drei Auswahlfelder (DVD / Blu-ray / 4K-UHD) statt einem, mit
Erklärung, warum 4K auf ein 2160p-Preset gehört. Preset-Namen aus
`HandBrakeCLI --preset-list` im Worker-Image belegt, nicht geraten.
- [x] **Sofortmaßnahme am laufenden Job**: `keepOriginal` auf `True`, damit
der 4K-Rohschnitt die Kompression überlebt.
**Offen aus dieser Runde:**
- [ ] **Deploy steht aus** — er würde den laufenden Akira-Rip abbrechen.
Erst nach Abschluss des Jobs deployen, dann bei Bedarf „Neu
komprimieren" mit dem 4K-Preset.
---
## Ideen-Katalog (Rest) — bewusst offen
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
`theme === 'dark'`-Ternaries → Tailwind-`dark:` + UI-Primitives,
Optik-Modernisierung, ohne Funktions-/Text-Änderungen. Der neue
Anleitungs-Tab gehört mit in den Feinschliff.
2. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
3. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
Jellyfin/Emby.
4. **Windows-Worker als echter Dienst** (statt start-worker.bat/onlogon):
z. B. via NSSM oder PyInstaller-Paket — Komfort-Ausbau der jetzt
funktionierenden nativen Variante.
+2166 -1
View File
File diff suppressed because it is too large Load Diff
+90 -16
View File
@@ -1,26 +1,100 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# NOTFALL-Deploy für Rippy (Normalweg = Grün-Gate: push → Ampel grün → CI # Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
# befördert auf 'stable' → Arcane-GitSync deployt). # Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
# Dieses Skript nur nutzen, wenn der Automat klemmt (Arcane down o. ä.). # befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
# zu deployenden Commit GRUEN ist.
# #
# Nutzt einen EIGENEN Klon (~/notfall-rippy, Branch stable) und fasst Arcanes # Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon # KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
# im Arcane-Verzeichnis kollidiert mit dessen Promote (read-only .git-Objekte). # reicht ohnehin der einfache Weg (siehe README):
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation). # git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
# docker compose up -d --build
#
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
#
# Konfiguration ueber Umgebungsvariablen:
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
set -euo pipefail set -euo pipefail
VM="arcane@192.168.178.162" VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
DIENST="${1:-}"
ssh "$VM" 'bash -s' <<REMOTE ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
set -euo pipefail set -euo pipefail
if [ ! -d ~/notfall-rippy/.git ]; then REPO_URL="$1"
git clone --branch stable https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy ENV_SRC="${2:-$HOME/rippy/.env}"
CLONE="${3:-$HOME/notfall-rippy}"
# ⚠️ ${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 fi
cd ~/notfall-rippy
git fetch origin stable if [ ! -d "$CLONE/.git" ]; then
git reset --hard origin/stable git clone --branch main "$REPO_URL" "$CLONE"
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis" fi
cd "$CLONE"
git fetch origin main
git reset --hard origin/main
# ⚠️ 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 up -d --build $DIENST
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}' docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
REMOTE REMOTE
+64
View File
@@ -0,0 +1,64 @@
# 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. 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 → Worker auf. Er bedient NUR die transcode-Queue;
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
services:
transcode-worker:
build:
context: ..
dockerfile: docker/worker/Dockerfile
command: ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "transcode", "-n", "gpu-worker@%h"]
environment:
- REDIS_URL=redis://${RIPPY_HOST:?RIPPY_HOST setzen}:6379/0
- DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy
- RIP_OUTPUT_DIR=/app/media
- RAW_DIR=/app/temp/raw
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
- 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:
# 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
restart: unless-stopped
Binary file not shown.
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+91 -17
View File
@@ -11,12 +11,17 @@ services:
context: . context: .
dockerfile: docker/api/Dockerfile dockerfile: docker/api/Dockerfile
environment: environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy - DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- TMDB_API_KEY=${TMDB_API_KEY} - TMDB_API_KEY=${TMDB_API_KEY:-}
- THETVDB_API_KEY=${THETVDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY:-}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - OMDB_API_KEY=${OMDB_API_KEY:-}
# Dasselbe Host-Verzeichnis wie beim Worker, nur an neutraler Stelle:
# die API liest den KEYDB-Status und die AACS-Dumps und nimmt eine
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
- MAKEMKV_DATA_DIR=/app/makemkv-data
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"] test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
interval: 30s interval: 30s
@@ -25,11 +30,32 @@ services:
start_period: 10s start_period: 10s
ports: ports:
- "8000:8000" - "8000:8000"
# SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein
# (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker
# jeden Mount sofort.
# DAC_READ_SEARCH: mount.cifs hebt diese Capability an (toggle_dac_
# capability) — sie fehlt in Dockers Default-Set, ohne sie stirbt JEDER
# SMB-Mount mit "Unable to apply new capability set" (Befund 24.07.,
# auf der VM reproduziert und mit dieser Capability bewiesen behoben).
cap_add:
- SYS_ADMIN
- DAC_READ_SEARCH
security_opt:
- apparmor:unconfined
volumes: volumes:
- media:/app/media # /srv/rippy/media auf der VM statt anonymem Volume: rshared lässt
# Mounts aus DIESEM Container zum Host und in andere Container
# propagieren (Commander-Anforderung 23.07.: Ziele frei wählbar).
- type: bind
source: /srv/rippy/media
target: /app/media
bind:
propagation: rshared
- temp:/app/temp - temp:/app/temp
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
devices: devices:
- /dev/sr0:/dev/sr0 - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
@@ -44,23 +70,63 @@ services:
build: build:
context: . context: .
dockerfile: docker/worker/Dockerfile dockerfile: docker/worker/Dockerfile
args:
# Übersteuerbar via .env. /download = aktuelle Version,
# /download/old = ältere. Am 25.07.2026 nachgemessen: /download
# antwortet mit 200, /download/old mit 525 (Cloudflare), und ein
# web.archive.org-Schnappschuss mit 404 — deshalb steht hier der
# Standard, der wirklich liefert.
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird
# (leer = keine). Bewusst ohne Vorbelegung: eine Adresse, die nicht
# liefert, lässt die Konfiguration gesund aussehen und den Build später
# scheitern. Der zuverlässige Weg bleibt docker/worker/vendor/.
MAKEMKV_URL_FALLBACK: ${MAKEMKV_URL_FALLBACK:-}
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
# dann docker compose build worker && docker compose up -d worker.
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
environment: environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy - DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- RIP_OUTPUT_DIR=/app/media - RIP_OUTPUT_DIR=/app/media
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY} - MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
# MakeMKVs Datenverzeichnis im Container — fest verdrahtet in MakeMKV
# selbst (HOME/.MakeMKV, HOME ist /root). Steht hier, damit Worker-Code
# und Mount dieselbe Wahrheit benutzen.
- MAKEMKV_DATA_DIR=/root/.MakeMKV
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
# Für Serien-Episoden-Matching: der Worker fragt die API nach den
# Episoden-Laufzeiten der Staffel (TMDB).
- API_URL=http://api:8000
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
volumes: volumes:
- media:/app/media - type: bind
source: /srv/rippy/media
target: /app/media
bind:
propagation: rslave
- temp:/app/temp - temp:/app/temp
# MakeMKV-Datenverzeichnis PERSISTENT (Befund 25.07.2026, live gemessen):
# Hier liegen die KEYDB.cfg — heute die EINZIGE funktionierende
# Schlüsselquelle für 4K-UHD, weil MakeMKVs Online-Kanal nichts mehr
# liefert —, die AACS-Dumps fehlgeschlagener Discs und settings.conf.
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
devices: devices:
- /dev/sr0:/dev/sr0 # Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an ohne den # MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.). # passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach # des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container # Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
# neu starten. # Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
- /dev/sg1:/dev/sg1 - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
@@ -93,8 +159,14 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
- POSTGRES_USER=rippy - POSTGRES_USER=rippy
- POSTGRES_PASSWORD=rippy - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
- POSTGRES_DB=rippy - POSTGRES_DB=rippy
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
# ports: konnte sich NIE ein Remote-Worker anbinden. Heimnetz-Kompromiss,
# wie die Klartext-Credentials (README).
ports:
- "5432:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
networks: networks:
@@ -108,6 +180,9 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# Siehe postgres: Pflicht für Remote-Worker (Celery-Broker).
ports:
- "6379:6379"
volumes: volumes:
- redis-data:/data - redis-data:/data
networks: networks:
@@ -125,7 +200,6 @@ networks:
driver: bridge driver: bridge
volumes: volumes:
media:
temp: temp:
postgres-data: postgres-data:
redis-data: redis-data:
+23 -1
View File
@@ -3,11 +3,33 @@ FROM python:3.12-slim-bookworm
WORKDIR /app WORKDIR /app
# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) — # udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) —
# die Geräte-Erkennung läuft jetzt über /sys + ioctls, ganz ohne Systempakete. # die Geräte-Erkennung läuft jetzt über /sys + ioctls.
# nfs-common/cifs-utils: Netzwerk-Speicherziele werden aus dem UI heraus
# eingehängt (mounts.py, braucht CAP_SYS_ADMIN aus dem Compose).
RUN apt-get update && apt-get install -y --no-install-recommends \
nfs-common \
cifs-utils \
smbclient \
&& rm -rf /var/lib/apt/lists/*
COPY docker/api/requirements.txt . COPY docker/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ . COPY docker/api/ .
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
# 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/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.
COPY deploy/worker-windows/RippyWorkerSetup.exe worker_dist/
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
-105
View File
@@ -1,105 +0,0 @@
"""JWT-Auth-Module für Rippy API."""
import time
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
import jwt
from passlib.context import CryptContext
from config import settings
# Passwort-Hashing-Kontext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
# Review-Fund 22.07.: der frühere Zufalls-Fallback erzeugte PRO PROZESS einen
# neuen Schlüssel → jeder Neustart/zweite Worker invalidierte alle Tokens.
# Lieber laut scheitern als still kaputt sein.
if not settings.jwt_secret_key:
raise RuntimeError(
"JWT_SECRET_KEY ist nicht gesetzt (.env). Ohne festen Schlüssel wären "
"alle Tokens nach jedem Neustart ungültig — Start verweigert."
)
SECRET_KEY = settings.jwt_secret_key
ALGORITHM = "HS256"
# Token-Lifetimes
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifiziere Passwort."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash Passwort."""
return pwd_context.hash(password)
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
"""Erstelle Access Token (Default 15 min)."""
to_encode = data.copy()
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(timezone.utc) + delta
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: Dict) -> str:
"""Erstelle Refresh Token (7 Tage)."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[Dict]:
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def is_access_token(token: str) -> bool:
"""Prüfe ob Token ein gültiges Access Token ist."""
payload = decode_token(token)
return bool(payload and payload.get("type") == "access")
def is_refresh_token(token: str) -> bool:
"""Prüfe ob Token ein gültiges Refresh Token ist."""
payload = decode_token(token)
return bool(payload and payload.get("type") == "refresh")
# Token-Blacklist für Logout: Token → Ablauf-Zeitstempel (exp).
# Bewusste MVP-Grenze: in-memory = pro Prozess (siehe SAVEPOINT.md).
# Review-Fund 22.07.: das frühere cleanup löschte die GESAMTE Blacklist —
# Logout war ein Placebo. Jetzt fliegen nur abgelaufene Tokens raus
# (die sind eh ungültig, decode_token lehnt sie ab).
token_blacklist: Dict[str, float] = {}
def add_to_blacklist(token: str) -> None:
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
payload = decode_token(token)
if payload:
token_blacklist[token] = float(payload.get("exp", time.time()))
def is_blacklisted(token: str) -> bool:
"""Prüfe ob Token auf der Blacklist steht."""
return token in token_blacklist
def cleanup_blacklist() -> None:
"""Entferne NUR abgelaufene Tokens von der Blacklist."""
now = time.time()
for token in [t for t, exp in token_blacklist.items() if exp <= now]:
del token_blacklist[token]
+8 -3
View File
@@ -30,10 +30,15 @@ FALLBACK_YEAR: Final[int] = 2000
FALLBACK_TITLE: Final[str] = "Unbekannter Titel" FALLBACK_TITLE: Final[str] = "Unbekannter Titel"
def generate_prescan_key(device_path: str, is_audio: bool = False) -> str: def generate_prescan_key(device_path: str, is_audio: bool = False, fingerprint: str = "") -> str:
"""Generiere Cache-Key für Pre-Scan.""" """Generiere Cache-Key für Pre-Scan.
fingerprint (Label+Größe der DISC) ist Pflichtbestandteil seit 23.07.:
Ein Key nur aus dem Gerätepfad servierte der NÄCHSTEN Disc im selben
Laufwerk die Metadaten der vorherigen.
"""
prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX
return f"{prefix}{device_path}" return f"{prefix}{device_path}:{fingerprint}"
def generate_confirmed_key(title: str, year: Optional[int] = None) -> str: def generate_confirmed_key(title: str, year: Optional[int] = None) -> str:
+23 -2
View File
@@ -7,14 +7,35 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
import os import os
from celery import Celery from celery import Celery
from celery.utils import worker_direct
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
def start_rip(device_path: str, job_id: str): def transcode_queue(node: str = None):
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
wenn er gerade online ist, sonst die geteilte transcode-Queue.
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
landet bei irgendeinem freien Worker.
"""
if not node:
return "transcode"
try:
antworten = celery_client.control.ping(timeout=1.0) or []
online = {k for antwort in antworten for k in antwort.keys()}
if node in online:
return worker_direct(node)
except Exception:
pass
return "transcode"
def start_rip(device_path: str, job_id: str, target_dir: str = None):
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
return celery_client.send_task( return celery_client.send_task(
"worker.tasks.rip_disc", args=[device_path, job_id] "worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
) )
+129
View File
@@ -0,0 +1,129 @@
"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key.
Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die
präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und
Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant).
Doku: https://docs.api.jikan.moe/
"""
from difflib import SequenceMatcher
from typing import Dict, Optional
import requests
from cache import get, set as cache_set
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
def titel_aehnlichkeit(a: str, b: str) -> float:
"""Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar)."""
def norm(t: str) -> str:
return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip()
a_n, b_n = norm(a), norm(b)
if not a_n or not b_n:
return 0.0
return SequenceMatcher(None, a_n, b_n).ratio()
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()
def suche(self, title: str) -> list:
"""Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate)."""
try:
response = self.session.get(
f"{JIKAN_BASE_URL}/anime",
params={"q": title, "limit": 5, "sfw": "true"},
timeout=10,
)
response.raise_for_status()
eintraege = response.json().get("data") or []
except Exception:
return []
ergebnisse = []
for e in eintraege:
bilder = (e.get("images") or {}).get("jpg") or {}
ergebnisse.append({
"title": e.get("title_english") or e.get("title") or "",
"year": e.get("year"),
"poster": bilder.get("large_image_url") or bilder.get("image_url") or "",
"overview": (e.get("synopsis") or "")[:200],
"type": "movie" if e.get("type") == "Movie" else "tv",
"source": "jikan",
"id": f"mal-{e.get('mal_id', '')}",
})
return ergebnisse
def lookup(self, title: str) -> Optional[Dict]:
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
cache_key = f"jikan:{title.lower()}"
cached = get(cache_key)
if cached:
return cached
# Jikan wirft sporadisch 504 (Gratis-Dienst) — ein Retry rettet die
# meisten Fälle, mehr wäre Belästigung.
eintraege = None
for versuch in range(2):
try:
response = self.session.get(
f"{JIKAN_BASE_URL}/anime",
params={"q": title, "limit": 5, "sfw": "true"},
timeout=10,
)
response.raise_for_status()
eintraege = response.json().get("data") or []
break
except Exception as e:
print(f"Jikan API Error (Versuch {versuch + 1}): {e}")
if versuch == 0:
import time
time.sleep(2)
if eintraege is None:
return None
bester, bester_score = None, 0.0
for eintrag in eintraege:
varianten = [eintrag.get("title") or ""]
if eintrag.get("title_english"):
varianten.append(eintrag["title_english"])
score = max(titel_aehnlichkeit(title, v) for v in varianten)
if score > bester_score:
bester, bester_score = eintrag, score
if not bester or bester_score < self.MINDEST_AEHNLICHKEIT:
return None
bilder = (bester.get("images") or {}).get("jpg") or {}
ergebnis = {
"type": "movie" if bester.get("type") == "Movie" else "tv",
"id": f"mal-{bester.get('mal_id', '')}",
"title": bester.get("title_english") or bester.get("title") or title,
"year": bester.get("year"),
"overview": (bester.get("synopsis") or "")[:600],
"poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "",
"backdrop_path": "",
"runtime": 0,
"genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")],
"source": "jikan",
"match_score": round(bester_score, 2),
}
cache_set(cache_key, ergebnis)
return ergebnis
+146
View File
@@ -0,0 +1,146 @@
"""OMDb API Client — Fallback-Quelle für die Metadaten-Erkennung.
Kommt zum Zug, wenn TMDB nichts (Sicheres) findet. Kostenloser Key von
https://www.omdbapi.com/apikey.aspx (1000 Anfragen/Tag reichen fürs Heimlab).
"""
from typing import Dict, Optional
import requests
from cache import get, set as cache_set
from config import settings
OMDB_BASE_URL = "https://www.omdbapi.com/"
def parse_runtime_minutes(runtime: str) -> int:
"""OMDb liefert '142 min' als Text → Minuten als Zahl (pure Funktion, testbar)."""
if not runtime:
return 0
teil = runtime.strip().split(" ")[0]
return int(teil) if teil.isdigit() else 0
def parse_year(year: str) -> Optional[int]:
"""OMDb-Jahr kann '1999' oder '20052012' (Serie) sein → erstes Jahr."""
if not year:
return None
erste = year.strip()[:4]
return int(erste) if erste.isdigit() else None
class OMDbClient:
def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
self.session = requests.Session()
def suche(self, title: str) -> list:
"""Roh-Kandidaten für die manuelle Korrektur (unscharfe s=-Suche)."""
if not self.api_key:
return []
try:
antwort = self.session.get(
OMDB_BASE_URL,
params={"apikey": self.api_key, "s": title, "r": "json"},
timeout=10,
).json()
except Exception:
return []
if antwort.get("Response") != "True":
return []
ergebnisse = []
for e in (antwort.get("Search") or [])[:5]:
poster = e.get("Poster", "")
ergebnisse.append({
"title": e.get("Title", ""),
"year": parse_year(e.get("Year", "")),
"poster": poster if poster != "N/A" else "",
"overview": "",
"type": "tv" if e.get("Type") == "series" else "movie",
"source": "omdb",
"id": e.get("imdbID", ""),
})
return ergebnisse
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
if not self.api_key:
return None
cache_key = f"omdb:{title.lower()}:{year or ''}"
cached = get(cache_key)
if cached:
return cached
params = {"apikey": self.api_key, "t": title, "r": "json"}
if year:
params["y"] = str(year)
try:
response = self.session.get(OMDB_BASE_URL, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# t= ist exakt — wenn leer, unscharfe Suche (s=) und den besten
# Treffer per imdbID nachladen
if data.get("Response") != "True":
suche = self.session.get(
OMDB_BASE_URL,
params={"apikey": self.api_key, "s": title, "r": "json"},
timeout=10,
).json()
treffer = (suche.get("Search") or []) if suche.get("Response") == "True" else []
if treffer:
# Bester Kandidat statt blind Treffer #1: echte Filme vor
# Episoden/Reviews, mit Poster vor ohne, kurzer Titel vor
# langem (Zusatz-Geschwafel wie "Episode 110 - …" verliert).
def rang(t: Dict):
return (
0 if t.get("Type") == "movie" else (1 if t.get("Type") == "series" else 2),
0 if t.get("Poster") not in (None, "", "N/A") else 1,
len(t.get("Title") or ""),
)
bester = sorted(treffer, key=rang)[0]
data = self.session.get(
OMDB_BASE_URL,
params={"apikey": self.api_key, "i": bester["imdbID"], "r": "json"},
timeout=10,
).json()
except Exception as e:
print(f"OMDb API Error: {e}")
return None
if data.get("Response") != "True":
return None
plot = data.get("Plot", "")
if plot == "N/A":
plot = ""
poster = data.get("Poster", "")
if poster == "N/A":
poster = ""
# Qualitäts-Gate: Einträge ohne Poster UND ohne Beschreibung sind
# fast immer Datenmüll (Review-Episoden etc.) — als Nicht-Treffer
# werten, damit die Kandidaten-Kette weiterprobiert.
if not poster and not plot:
return None
ergebnis = {
"type": "tv" if data.get("Type") == "series" else "movie",
"id": data.get("imdbID", ""),
"title": data.get("Title", title),
"year": parse_year(data.get("Year", "")),
"overview": plot,
"poster_path": poster,
"backdrop_path": "",
"runtime": parse_runtime_minutes(data.get("Runtime", "")),
"genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip() and g.strip() != "N/A"],
"source": "omdb",
}
cache_set(cache_key, ergebnis)
return ergebnis
+3 -1
View File
@@ -12,7 +12,9 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
class TheTVDBClient: class TheTVDBClient:
def __init__(self): def __init__(self):
self.api_key = settings.thetvdb_api_key # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
self.base_url = THETVDB_BASE_URL self.base_url = THETVDB_BASE_URL
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({
+59 -6
View File
@@ -11,18 +11,35 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
def ist_v4_token(key: str) -> bool:
"""Pure Funktion (testbar): TMDB hat ZWEI Key-Arten — der v4 Read Access
Token ist ein langes JWT ('eyJ…', Bearer-Header), der klassische v3-Key
32 Hex-Zeichen (api_key-Query-Parameter). Befund 24.07.: der Client konnte
NUR v4 — mit dem üblichen v3-Key aus dem TMDB-Konto war jede Anfrage 401
und die Suche lieferte still nur OMDb-Treffer."""
return bool(key) and key.startswith("eyJ")
class TMDBClient: class TMDBClient:
def __init__(self): def __init__(self):
self.api_key = settings.tmdb_api_key # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
from db import get_settings
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({"Content-Type": "application/json"})
"Authorization": f"Bearer {self.api_key}", # Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
"Content-Type": "application/json" # api_key-Parameter, v4-Token als Bearer-Header)
}) self._key_params = {}
if ist_v4_token(self.api_key):
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
elif self.api_key:
self._key_params = {"api_key": self.api_key}
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]: def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
"""Mache API-Request mit Caching.""" """Mache API-Request mit Caching."""
from cache.keys import generate_tmdb_key from cache.keys import generate_tmdb_key
# Cache-Key OHNE den api_key — der gehört nicht in den Cache
cache_key = generate_tmdb_key(endpoint, params) cache_key = generate_tmdb_key(endpoint, params)
cached = get(cache_key) cached = get(cache_key)
@@ -32,7 +49,7 @@ class TMDBClient:
try: try:
response = self.session.get( response = self.session.get(
f"{TMDB_BASE_URL}/{endpoint}", f"{TMDB_BASE_URL}/{endpoint}",
params=params, params={**(params or {}), **self._key_params},
timeout=10 timeout=10
) )
response.raise_for_status() response.raise_for_status()
@@ -79,6 +96,42 @@ class TMDBClient:
params = {"language": "de-DE"} params = {"language": "de-DE"}
return self._request(f"tv/{tv_id}", params) return self._request(f"tv/{tv_id}", params)
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
developer.themoviedb.org/reference/find-by-id).
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
"""
result = self._request(
f"find/{imdb_id}",
{"external_source": "imdb_id", "language": "de-DE"},
)
if not result:
return None
for schluessel, typ, titel_feld, datum_feld in (
("movie_results", "movie", "title", "release_date"),
("tv_results", "tv", "name", "first_air_date"),
):
eintraege = result.get(schluessel) or []
if eintraege:
e = eintraege[0]
datum = e.get(datum_feld) or ""
return {
"type": typ,
"tmdb_id": e.get("id"),
"title": e.get(titel_feld) or "",
"overview": e.get("overview") or "",
"poster_path": e.get("poster_path") or "",
"year": int(datum[:4]) if datum[:4].isdigit() else None,
}
return None
def get_movie_images(self, movie_id: int) -> Dict[str, str]: def get_movie_images(self, movie_id: int) -> Dict[str, str]:
"""Hole Poster/Fanart URLs.""" """Hole Poster/Fanart URLs."""
params = {"language": "de-DE", "include_image_language": "de,en,null"} params = {"language": "de-DE", "include_image_language": "de,en,null"}
+3 -6
View File
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
# API Keys # API Keys
tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)") tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)")
thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)") thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)")
omdb_api_key: Optional[str] = Field(default=None, description="OMDb API Key (Fallback-Quelle, optional)")
musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)") musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
# Cache # Cache
@@ -23,12 +24,8 @@ class Settings(BaseSettings):
# Logging # Logging
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level") log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
# JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr) # JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)") # Heimnetz-only, es gab nie einen Login-Flow im UI — siehe KONZEPT §10.
# Admin-Login (MVP — vorher hartkodiert admin/rippy123 in main.py)
admin_username: str = Field(default="admin", description="Admin-Benutzername")
admin_password: str = Field(default="rippy123", description="Admin-Passwort (per .env ÄNDERN!)")
class Config: class Config:
env_file = ".env" env_file = ".env"
+6 -10
View File
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
try: try:
settings = Settings() settings = Settings()
# TMDB API Key ist Pflicht (gemäß KONZEPT.md) # TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
# sich jederzeit im UI (Einstellungen → System) nachtragen.
if not settings.tmdb_api_key: if not settings.tmdb_api_key:
raise ConfigValidationError( raise ConfigValidationError(
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n" "TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n" "Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key" "Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
) )
return settings return settings
@@ -48,10 +50,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet." "MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
) )
# Optional: JWT Secret (wird generiert, wenn nicht gesetzt)
if not settings.jwt_secret_key:
warnings.append(
"JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet."
)
return settings, warnings return settings, warnings
+1 -4
View File
@@ -1,9 +1,6 @@
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret.""" """Pytest-Setup für API-Tests: flache Modul-Imports."""
import os import os
import sys import sys
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
+146 -2
View File
@@ -41,7 +41,9 @@ jobs = Table(
Column("status", String(16), nullable=False, server_default="pending"), Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"), Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text), Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text), Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
Column("created_at", DateTime(timezone=True)), Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)), Column("finished_at", DateTime(timezone=True)),
) )
@@ -63,17 +65,93 @@ settings_table = Table(
Column("value", Text), Column("value", Text),
) )
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
storage_mounts = Table(
"storage_mounts",
metadata,
Column("name", String(64), primary_key=True),
Column("typ", String(8)), # nfs | cifs
Column("quelle", String(255)), # host:/export bzw. //host/share
Column("optionen", String(255)),
Column("username", String(128)),
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
)
def list_workers() -> list:
import json
with engine.connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = []
for z in zeilen:
eintrag = dict(z)
try:
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
except ValueError:
eintrag["encoders"] = []
try:
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
except ValueError:
eintrag["info"] = {}
if eintrag.get("last_seen"):
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
ergebnis.append(eintrag)
return ergebnis
def list_mounts() -> list:
with engine.connect() as conn:
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
with engine.begin() as conn:
conn.execute(
storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle,
optionen=optionen, username=username, passwort=passwort,
)
)
def delete_mount(name: str) -> None:
with engine.begin() as conn:
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
def utcnow() -> datetime: def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def init_db() -> None: def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent).""" """Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
metadata.create_all(engine) metadata.create_all(engine)
# create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen:
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None: def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None,
) -> None:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute( conn.execute(
jobs.insert().values( jobs.insert().values(
@@ -81,6 +159,8 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
device=device, device=device,
disc_type=disc_type, disc_type=disc_type,
title=title, title=title,
target_dir=target_dir,
meta=meta,
status="pending", status="pending",
progress=0, progress=0,
created_at=utcnow(), created_at=utcnow(),
@@ -88,6 +168,19 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
) )
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def get_job(job_id: str) -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def list_jobs(limit: int = 100) -> list: def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn: with engine.connect() as conn:
zeilen = conn.execute( zeilen = conn.execute(
@@ -96,6 +189,57 @@ def list_jobs(limit: int = 100) -> list:
return [dict(z) for z in zeilen] return [dict(z) for z in zeilen]
def delete_job(job_id: str) -> None:
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
with engine.begin() as conn:
conn.execute(jobs.delete().where(jobs.c.id == job_id))
def delete_finished_jobs() -> int:
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
with engine.begin() as conn:
ergebnis = conn.execute(
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
)
return ergebnis.rowcount or 0
def delete_worker(name: str) -> None:
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
with engine.begin() as conn:
conn.execute(workers.delete().where(workers.c.name == name))
def hat_arbeit() -> bool:
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
`umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
anlaufen.
"""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.status.in_(
("pending", "running", "ripping", "transcoding", "canceling")))
.limit(1)
).first()
return zeile is not None
def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.device == device)
.where(jobs.c.status.in_(("pending", "running")))
.limit(1)
).first()
return zeile is not None
def add_log(level: str, source: str, message: str) -> None: def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute( conn.execute(
+7 -1
View File
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer). # Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray. # Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3 BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def _open_nonblock(device_path: str) -> int: def _open_nonblock(device_path: str) -> int:
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
def classify(disc_status_code: int, size_bytes: int) -> str: def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown.""" """Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED): if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd" return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2): if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd" return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown" return "unknown"
+47
View File
@@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall.
import glob import glob
import os import os
from fcntl import ioctl
from detection import ( from detection import (
CDS_DISC_OK, CDS_DISC_OK,
@@ -17,6 +18,52 @@ from detection import (
drive_status, drive_status,
) )
# include/uapi/linux/cdrom.h
CDROMEJECT = 0x5309
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
CDROM_DRIVE_STATUS = 0x5326
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
AUSWURF_WARTEN_SEKUNDEN = 5
def eject(device_path: str) -> None:
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
und tut nichts. MakeMKV verriegelt die Tür während des Rips
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
Deshalb macht das Werkzeug `eject` immer beides.
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
zwischen den Containern; dort steht die ausführliche Herleitung.
"""
import time
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
try:
ioctl(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
ioctl(fd, CDROMEJECT, 0)
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
# Slot-Laufwerk hat keine Schublade.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
return
time.sleep(1)
raise OSError(
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
)
finally:
os.close(fd)
def list_optical_devices() -> list: def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose).""" """Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
+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)}
-110
View File
@@ -1,110 +0,0 @@
"""Jellyfin Image Downloader."""
import requests
from pathlib import Path
from typing import Dict, Optional
from clients.tmdb import TMDBClient
from clients.thetvdb import TheTVDBClient
class ImageDownloader:
def __init__(self):
self.tmdb = TMDBClient()
self.thetvdb = TheTVDBClient()
self.base_url = "https://image.tmdb.org/t/p"
def download_image(self, url: str, output_path: Path, width: int = 500) -> bool:
"""Lade Image herunter."""
try:
# TMDB URL anpassen
if url.startswith("https://image.tmdb.org"):
# Konvertiere zu gewünschter Größe
path = url.replace(f"{self.base_url}/", "")
url = f"{self.base_url}/w{width}/{path}"
response = requests.get(url, timeout=30)
response.raise_for_status()
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(response.content)
return True
except Exception as e:
print(f"Image download error: {e}")
return False
def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]:
"""Lade Poster herunter."""
# TMDB Search
movies = self.tmdb.search_movie(title)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("poster"):
output_path = output_dir / "poster.jpg"
if self.download_image(images["poster"], output_path, width):
return output_path
return None
def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]:
"""Lade Fanart herunter."""
# TMDB Search
movies = self.tmdb.search_movie(title)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("fanart"):
output_path = output_dir / "fanart.jpg"
if self.download_image(images["fanart"], output_path, width):
return output_path
return None
def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]:
"""Lade Serien-Poster und Fanart herunter."""
result = {
"poster": None,
"fanart": None
}
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
tv = tv_shows[0]
images = self.tmdb.get_tv_images(tv["id"])
if images.get("poster"):
output_path = output_dir / "poster.jpg"
if self.download_image(images["poster"], output_path, 500):
result["poster"] = output_path
if images.get("fanart"):
output_path = output_dir / "fanart.jpg"
if self.download_image(images["fanart"], output_path, 1920):
result["fanart"] = output_path
return result
def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]:
"""Lade Musik-Album-Cover herunter (via TMDB als Fallback)."""
result = {
"album": None
}
# TMDB Search für Soundtracks
query = f"{album} soundtrack"
movies = self.tmdb.search_movie(query)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("poster"):
output_path = output_dir / "album.jpg"
if self.download_image(images["poster"], output_path, 500):
result["album"] = output_path
return result
+2020 -249
View File
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
MakeMKV liest die Disc — sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden — sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+87
View File
@@ -0,0 +1,87 @@
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
t=1053) wechselt etwa monatlich und läuft zum Monatsende ab. Ohne gültigen Key
fällt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
Rebuild/Neustart, ab dem nächsten Rip.
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
Richtigstellung 25.07.2026: Hier stand früher, MakeMKV ziehe den AACS-Schluessel
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
NICHT für 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
Messungen stehen im Modul-Kopf von makemkv_daten.py.
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
- Key-Format: "T-" + langer String aus [A-Za-z0-9_] (typ. ~66 Zeichen; die exakte
Laenge variiert je Key, darum kein fixes Limit, sondern mind. 50, greedy).
"""
import asyncio
import re
FORUM_URL = "https://forum.makemkv.com/forum/viewtopic.php?t=1053"
KEY_RE = re.compile(r"T-[A-Za-z0-9_]{50,}")
_HEADERS = {"User-Agent": "rippy-makemkv-key-refresh/1.0"}
def extract_key(html: str) -> str | None:
"""Zieht den Beta-Key ("T-...") aus dem Forum-HTML. Pure Funktion -> testbar."""
treffer = KEY_RE.search(html or "")
return treffer.group(0) if treffer else None
def fetch_current_key(timeout: int = 15) -> str | None:
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
import requests # lazy: hält extract_key + den Test dependency-frei
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
antwort.raise_for_status()
return extract_key(antwort.text)
def _apply_key(key: str) -> bool:
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
import db
einstellungen = db.get_settings()
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
return False
einstellungen["makemkvAppKey"] = key
db.save_settings(einstellungen)
return True
async def refresh_once() -> bool:
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
import db
key = await asyncio.to_thread(fetch_current_key)
if not key:
db.add_log("warning", "makemkv-key",
"Aktueller Beta-Key nicht im Forum gefunden (Format geaendert?).")
return False
geaendert = await asyncio.to_thread(_apply_key, key)
if geaendert:
db.add_log("success", "makemkv-key",
f"MakeMKV-Beta-Key automatisch erneuert (…{key[-6:]}).")
return geaendert
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
import db
await asyncio.sleep(start_verzoegerung_s)
while True:
try:
await refresh_once()
except Exception as exc:
db.add_log("warning", "makemkv-key", f"Key-Refresh fehlgeschlagen: {exc!r}")
await asyncio.sleep(max(3600, intervall_stunden * 3600))
+474
View File
@@ -0,0 +1,474 @@
"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
alle anderen Container (Worker sieht das Ziel sofort).
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
sondern über eine temporäre credentials-Datei).
"""
import os
import posixpath
import re
import subprocess
import tempfile
import db
MEDIA_ROOT = "/app/media"
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
def _mountpoint(name: str) -> str:
"""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:
return bool(NAME_MUSTER.match(name))
def ist_gemountet(name: str) -> bool:
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
try:
return os.path.ismount(_mountpoint(name))
except OSError:
return True
def ist_erreichbar(name: str) -> bool:
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
"""
ziel = _mountpoint(name)
try:
ergebnis = subprocess.run(
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
)
return ergebnis.returncode == 0
except (OSError, subprocess.TimeoutExpired):
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?
Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob
die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf.
"""
testdatei = os.path.join(pfad, ".rippy-schreibtest")
try:
with open(testdatei, "w") as f:
f.write("ok")
os.unlink(testdatei)
return True
except OSError:
return False
def _stale_mounts_loesen(ziel: str) -> int:
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
Rueckgabe: Zahl der geloesten Schichten.
"""
geloest = 0
for _ in range(20): # harte Obergrenze gegen Endlosschleife
try:
ergebnis = subprocess.run(
["umount", "-l", ziel], capture_output=True, timeout=10
)
except (OSError, subprocess.TimeoutExpired):
break
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
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
rät niemand, dass oben Benutzer/Passwort fehlen.
"""
if "NT_STATUS_ACCESS_DENIED" in fehler:
if not mit_credentials:
return (
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
"Zugriff auf die Freigabe), dann erneut auflisten."
)
return (
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
"Konto darf die Freigaben nicht auflisten."
)
if "NT_STATUS_LOGON_FAILURE" in fehler:
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
if (
"NT_STATUS_HOST_UNREACHABLE" in fehler
or "NT_STATUS_IO_TIMEOUT" in fehler
or "NT_STATUS_UNSUCCESSFUL" in fehler
or "NT_STATUS_CONNECTION_REFUSED" in fehler
or "Connection to" in fehler
):
return (
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
)
return fehler[:200]
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
cmd = ["smbclient", "-L", f"//{host}", "-g"]
if username:
cmd += ["-U", f"{username}%{passwort or ''}"]
else:
cmd += ["-N"]
try:
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
)
freigaben = []
for zeile in (ergebnis.stdout or "").splitlines():
# -g-Format: Disk|Freigabename|Kommentar
teile = zeile.split("|")
if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"):
freigaben.append(teile[1])
if not freigaben and ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
raise RuntimeError(
"Freigaben-Abfrage fehlgeschlagen: "
+ uebersetze_smb_fehler(fehler, bool(username))
)
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.
Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei
Mount-Fehlern.
"""
ziel = _mountpoint(name)
# 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 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 # 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).
_stale_mounts_loesen(ziel)
creds_datei = None
try:
if typ == "nfs":
opts = optionen or "vers=4,soft,timeo=100"
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
elif typ == "cifs":
teile = [optionen] if optionen else []
if username:
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
creds = tempfile.NamedTemporaryFile(
"w", delete=False, prefix="cifs-", suffix=".cred"
)
creds.write(f"username={username}\npassword={passwort or ''}\n")
creds.close()
os.chmod(creds.name, 0o600)
creds_datei = creds.name
teile.append(f"credentials={creds.name}")
else:
teile.append("guest")
teile.append("iocharset=utf8")
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
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()
hinweis = ""
if "error(13)" in fehler or "Permission denied" in fehler:
hinweis = (
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
"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:
os.unlink(creds_datei)
except OSError:
pass
def _lazy_umount(ziel: str) -> None:
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
gar nicht mehr entfernen (Befund 24.07.)."""
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
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)
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:
pass # nicht leer oder weg — egal
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
username: str = "", passwort: str = "") -> bool:
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
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)
_lazy_umount(ziel)
return mounten(name, typ, quelle, optionen, username, passwort)
def alle_remounten() -> list:
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
meldungen = []
for eintrag in db.list_mounts():
try:
mounten(
eintrag["name"], eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
meldungen.append(f"{eintrag['name']}: eingehängt")
except Exception as e:
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
return meldungen
-169
View File
@@ -1,169 +0,0 @@
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
from pathlib import Path
from typing import List
from xml.dom.minidom import getDOMImplementation
class NFOGenerator:
def __init__(self):
self.dom_impl = getDOMImplementation()
def _create_element(self, doc, name: str, text: str = None) -> None:
"""Hilfsfunktion für Element-Erstellung."""
element = doc.createElement(name)
if text:
element.appendChild(doc.createTextNode(str(text)))
return element
def generate_movie_nfo(self, title: str, year: int,
overview: str = "", rating: float = 0.0,
runtime: int = 0, genres: List[str] = None,
director: str = "", writer: str = "",
actors: List[str] = None, studio: str = "",
premiered: str = "", mpaa: str = "") -> str:
"""Generiere movie.nfo für Filme."""
doc = self.dom_impl.createDocument(None, "movie", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
root.appendChild(self._create_element(doc, "runtime", runtime))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
if director:
root.appendChild(self._create_element(doc, "director", director))
if writer:
root.appendChild(self._create_element(doc, "writer", writer))
if actors:
for actor in actors:
actor_node = doc.createElement("actor")
actor_node.appendChild(self._create_element(doc, "name", actor))
root.appendChild(actor_node)
if studio:
root.appendChild(self._create_element(doc, "studio", studio))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
if mpaa:
root.appendChild(self._create_element(doc, "mpaa", mpaa))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
return doc.toprettyxml(indent=" ")
def generate_series_nfo(self, title: str, year: int,
overview: str = "", rating: float = 0.0,
genres: List[str] = None, studio: str = "",
premiered: str = "") -> str:
"""Generiere series.nfo für Serien."""
doc = self.dom_impl.createDocument(None, "tvshow", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
if studio:
root.appendChild(self._create_element(doc, "studio", studio))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
return doc.toprettyxml(indent=" ")
def generate_episode_nfo(self, title: str, season: int, episode: int,
overview: str = "", rating: float = 0.0,
director: str = "", premiered: str = "",
writers: List[str] = None) -> str:
"""Generiere episode.nfo für Episoden."""
doc = self.dom_impl.createDocument(None, "episodedetails", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "season", season))
root.appendChild(self._create_element(doc, "episode", episode))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
if director:
root.appendChild(self._create_element(doc, "director", director))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
if writers:
for writer in writers:
root.appendChild(self._create_element(doc, "credits", writer))
return doc.toprettyxml(indent=" ")
def generate_album_nfo(self, title: str, artist: str, year: int,
genres: List[str] = None, rating: float = 0.0,
review: str = "") -> str:
"""Generiere album.nfo für Musikalben."""
doc = self.dom_impl.createDocument(None, "musicalbum", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "artist", artist))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "rating", rating))
root.appendChild(self._create_element(doc, "review", review))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
return doc.toprettyxml(indent=" ")
def save_nfo(self, content: str, output_path: Path) -> bool:
"""Speichere NFO-Datei."""
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
return True
except Exception as e:
print(f"NFO save error: {e}")
return False
# Beispieldaten
if __name__ == "__main__":
nfo_gen = NFOGenerator()
# movie.nfo
movie_nfo = nfo_gen.generate_movie_nfo(
title="Inception",
year=2010,
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
rating=8.8,
runtime=148,
genres=["Action", "Sci-Fi", "Thriller"],
director="Christopher Nolan",
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
)
print(movie_nfo)
+66
View File
@@ -0,0 +1,66 @@
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
docs.ntfy.sh/publish
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
(Home Assistant, n8n, eigene Skripte).
"""
import requests
TIMEOUT_SEKUNDEN = 10
def erkenne_webhook_typ(url: str) -> str:
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
u = url.lower()
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
return "discord"
if "hooks.slack.com" in u:
return "slack"
if "ntfy" in u:
return "ntfy"
return "generisch"
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
typ = erkenne_webhook_typ(url)
if typ == "discord":
return typ, {"content": f"**{titel}**\n{text}"}
if typ == "slack":
return typ, {"text": f"*{titel}*\n{text}"}
if typ == "ntfy":
return typ, None
return typ, {"title": titel, "message": text, "level": level}
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
typ, payload = baue_payload(url, titel, text, level)
try:
if typ == "ntfy":
antwort = requests.post(
url, data=text.encode("utf-8"),
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
)
else:
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
except requests.RequestException as e:
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
if antwort.status_code >= 300:
raise RuntimeError(
f"Webhook antwortete mit HTTP {antwort.status_code}: "
f"{(antwort.text or '')[:200]}"
)
+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
+352 -45
View File
@@ -5,15 +5,194 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt. lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
""" """
import os
import shutil
import subprocess import subprocess
from typing import Dict, List from typing import Dict, List, Optional
import detection
from clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
from clients.omdb import OMDbClient
from clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set from cache import get, set as cache_set
from cache.keys import generate_prescan_key from cache.keys import generate_prescan_key
def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion).
Volume-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN — so findet keine
Metadaten-API etwas. Unterstriche zu Leerzeichen, Titel-Schreibung.
"""
if not label:
return ""
bereinigt = " ".join(label.replace("_", " ").replace(".", " ").split())
if bereinigt.isupper():
bereinigt = bereinigt.title()
return bereinigt
def read_iso_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor.
Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für
DVDs — Blu-rays sind oft reines UDF ohne ISO-Bridge (dann greift
read_udf_volume_label).
"""
try:
with open(device_path, "rb") as f:
f.seek(32768)
pvd = f.read(2048)
if len(pvd) < 72 or pvd[1:6] != b"CD001":
return None
label = pvd[40:72].decode("ascii", errors="replace").strip()
return label or None
except OSError:
return None
def parse_udf_dstring(data: bytes) -> str:
"""Dekodiert einen UDF d-string (ECMA-167 1/7.2.12; pure Funktion, testbar).
Letztes Byte = genutzte Länge, erstes Byte = Kompressions-ID
(8 = Latin-1, 16 = UTF-16BE), dazwischen der Text.
"""
if not data:
return ""
laenge = data[-1]
if laenge < 2 or laenge > len(data) - 1:
return ""
comp_id, text = data[0], data[1:laenge]
try:
if comp_id == 8:
return text.decode("latin-1").strip()
if comp_id == 16:
return text.decode("utf-16-be").strip()
except UnicodeDecodeError:
pass
return ""
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
Das Volume-Label ist oft kryptisch (BD_EVG_D2) — der echte Titel
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
"""
import re as _re
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
gemountet = False
try:
ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
return None
gemountet = True
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
kandidaten = sorted(os.listdir(meta_dir))
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
)
if not bdmt:
return None
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
if treffer:
return treffer.group(1).strip()
return None
except Exception:
return None
finally:
if gemountet:
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
def titel_kandidaten(titel: str) -> List[str]:
"""Suchvarianten für die Metadaten-APIs (progressive Degradation, ARM-Lehre).
'Evangelion: 2.22 You Can (Not) Advance.' → auch 'Evangelion: 2.22 …' ohne
Zusatz, ohne Doppelpunkt-Teil usw. — Disc-Titel sind selten API-freundlich.
"""
kandidaten = []
def merke(t: str):
t = " ".join(t.split()).strip(" .")
if t and t not in kandidaten:
kandidaten.append(t)
merke(titel)
if ":" in titel:
merke(titel.split(":", 1)[0]) # Haupttitel vor dem Doppelpunkt
merke(titel.replace(":", ""))
import re as _re
merke(_re.sub(r"\([^)]*\)", "", titel)) # Klammer-Zusätze raus
# Iterativ hintere Worte strippen (ARM-Lehre): "Evangelion 2.22" muss
# auch als "Evangelion" gesucht werden — Versions-/Zusatz-Tokens stehen
# fast immer hinten. Begrenzt, damit die API-Anfragen überschaubar bleiben.
worte = titel.split()
while len(worte) > 1 and len(kandidaten) < 6:
worte = worte[:-1]
merke(" ".join(worte))
return kandidaten
def disc_fingerprint(device_path: str) -> str:
"""Billiger, stabiler Disc-Fingerabdruck (Volume-Label + Größe).
Für Cache-Keys und das Merken manueller Korrekturen — bewusst OHNE den
teuren Mount-Titel, damit ihn jeder Endpunkt schnell berechnen kann.
"""
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) or "unbekannt"
try:
groesse = detection.disc_size_bytes(device_path)
except OSError:
groesse = 0
return f"{label}|{groesse}"
def read_udf_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
Weg laut ECMA-167: Anchor Volume Descriptor Pointer bei Sektor 256 →
zeigt auf die Main Volume Descriptor Sequence → darin der PVD (Tag-ID 1)
mit dem Volume Identifier (d-string, 32 Bytes ab Offset 24).
"""
sektor = 2048
try:
with open(device_path, "rb") as f:
f.seek(256 * sektor)
avdp = f.read(sektor)
if len(avdp) < 24 or int.from_bytes(avdp[0:2], "little") != 2:
return None
vds_ort = int.from_bytes(avdp[20:24], "little")
f.seek(vds_ort * sektor)
# VDS-Deskriptoren durchgehen, bis der PVD (Tag-ID 1) kommt
for _ in range(32):
block = f.read(sektor)
if len(block) < 56:
return None
tag_id = int.from_bytes(block[0:2], "little")
if tag_id == 1:
return parse_udf_dstring(block[24:56]) or None
if tag_id == 8: # Terminating Descriptor — Sequenz zu Ende
return None
except OSError:
return None
return None
class PreScanResult: class PreScanResult:
def __init__( def __init__(
@@ -23,7 +202,8 @@ class PreScanResult:
year: int = None, year: int = None,
confidence: float = 0.0, confidence: float = 0.0,
metadata: Dict = None, metadata: Dict = None,
tracks: List[Dict] = None tracks: List[Dict] = None,
fingerprint: str = ""
): ):
self.disc_type = disc_type self.disc_type = disc_type
self.title = title self.title = title
@@ -31,6 +211,9 @@ class PreScanResult:
self.confidence = confidence self.confidence = confidence
self.metadata = metadata or {} self.metadata = metadata or {}
self.tracks = tracks or [] self.tracks = tracks or []
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
# Duplikat-Warnung („diese Disc wurde schon gerippt")
self.fingerprint = fingerprint
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
return { return {
@@ -39,7 +222,8 @@ class PreScanResult:
"year": self.year, "year": self.year,
"confidence": self.confidence, "confidence": self.confidence,
"metadata": self.metadata, "metadata": self.metadata,
"tracks": self.tracks "tracks": self.tracks,
"fingerprint": self.fingerprint
} }
@@ -48,36 +232,32 @@ class PreScan:
self.tmdb = TMDBClient() self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient() self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient() self.thetvdb = TheTVDBClient()
self.omdb = OMDbClient()
self.jikan = JikanClient()
def scan(self, device_path: str) -> PreScanResult: def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch.""" """Führe Pre-Scan durch."""
disc_type = self._detect_disc_type(device_path) disc_type = self._detect_disc_type(device_path)
toc = self._read_toc(device_path, disc_type) toc = self._read_toc(device_path, disc_type)
# Typ + Disc-Fingerabdruck (Label|Größe) an die Zweige durchreichen —
# der Typ ging vorher verloren (BDs hießen immer "DVD"), und der
# Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc
# die Metadaten der vorherigen).
toc["disc_type"] = disc_type
toc["fingerprint"] = disc_fingerprint(device_path)
if disc_type == "CD": if disc_type == "CD":
return self._scan_audio(device_path, toc) return self._scan_audio(device_path, toc)
return self._scan_video(device_path, toc) return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str: def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe.""" """Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
try:
result = subprocess.run( Vorher lief hier `isosize` — das Werkzeug war im Container nicht
["isosize", "-x", device_path], installiert, die Erkennung fiel still immer auf "DVD" zurück.
capture_output=True, """
text=True, typ = detection.detect_disc_type(device_path)
timeout=5 return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray", "uhd": "4K UHD"}.get(typ, "DVD")
)
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024:
return "CD"
elif size < 15 * 1024 * 1024 * 1024:
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
return "DVD"
def _read_toc(self, device_path: str, disc_type: str) -> Dict: def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents).""" """Lese TOC (Table of Contents)."""
@@ -105,19 +285,28 @@ class PreScan:
"duration": 0 "duration": 0
}) })
else: else:
# makemkvcon liest nur den TOC — KEIN Ripping # Titel-Quelle 1 (beste): Klartext-Titel aus den BD-Metadaten
# (BDMV/META — Labels wie BD_EVG_D2 taugen nicht für APIs)
klartext = read_disc_title_via_mount(device_path)
if klartext:
toc["title"] = klartext
else:
# Titel-Quelle 2: Volume-Label direkt vom Medium.
# ISO-9660 für DVDs, UDF für Blu-rays (keine ISO-Bridge).
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path)
if label:
toc["title"] = normalize_disc_label(label)
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
if shutil.which("makemkvcon"):
result = subprocess.run( result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path], ["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30 timeout=120
) )
for line in result.stdout.split('\n'): for line in result.stdout.split('\n'):
if line.startswith('DRV:'): if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
parts = line.split(',') parts = line.split(',')
if len(parts) >= 5: if len(parts) >= 5:
toc["tracks"].append({ toc["tracks"].append({
@@ -130,7 +319,7 @@ class PreScan:
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult: def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD.""" """Pre-Scan für Audio-CD."""
cache_key = generate_prescan_key(device_path, is_audio=True) cache_key = generate_prescan_key(device_path, is_audio=True, fingerprint=toc.get("fingerprint", ""))
cached = get(cache_key) cached = get(cache_key)
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
@@ -176,27 +365,37 @@ class PreScan:
disc_type="CD", disc_type="CD",
title=album, title=album,
tracks=toc["tracks"], tracks=toc["tracks"],
confidence=confidence confidence=confidence,
fingerprint=toc.get("fingerprint", "")
) )
cache_set(cache_key, result.to_dict()) cache_set(cache_key, result.to_dict())
return result return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult: def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray.""" """Pre-Scan für DVD/Blu-ray."""
cache_key = generate_prescan_key(device_path, is_audio=False) cache_key = generate_prescan_key(device_path, is_audio=False, fingerprint=toc.get("fingerprint", ""))
cached = get(cache_key) cached = get(cache_key)
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
title = toc["title"] or "Unknown Title" # Titel NICHT nochmal normalisieren: aus bdmt_*.xml kommt er sauber
# („2.22" würde die Label-Normalisierung zu „2 22" zerlegen)
title = (toc.get("title") or "").strip() or "Unknown Title"
confidence = 0.0 confidence = 0.0
metadata = {} metadata = {}
matched = False matched = False
movies = self.tmdb.search_movie(title) # Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten
if movies: # probieren — Disc-Titel sind selten API-freundlich formatiert.
for movie in movies: kandidaten = titel_kandidaten(title) or [title]
if movie.get("title", "").lower() == title.lower(): movies = []
for kandidat in kandidaten:
treffer = self.tmdb.search_movie(kandidat)
if treffer and not movies:
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
for movie in treffer:
if movie.get("title", "").lower() == kandidat.lower():
confidence = 0.95 confidence = 0.95
movie_details = self.tmdb.get_movie_details(movie["id"]) movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details: if movie_details:
@@ -209,16 +408,19 @@ class PreScan:
"poster_path": movie_details.get("poster_path", ""), "poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""), "backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0), "runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])] "genres": [g["name"] for g in movie_details.get("genres", [])],
"source": "tmdb",
} }
matched = True matched = True
break break
if matched:
break
if not matched: if not matched:
tv_shows = self.tmdb.search_tv(title) for kandidat in kandidaten:
if tv_shows: tv_shows = self.tmdb.search_tv(kandidat)
for show in tv_shows: for show in tv_shows:
if show.get("name", "").lower() == title.lower(): if show.get("name", "").lower() == kandidat.lower():
confidence = 0.9 confidence = 0.9
tv_details = self.tmdb.get_tv_details(show["id"]) tv_details = self.tmdb.get_tv_details(show["id"])
if tv_details: if tv_details:
@@ -230,10 +432,93 @@ class PreScan:
"overview": tv_details.get("overview", ""), "overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""), "poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""), "backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])] "genres": [g["name"] for g in tv_details.get("genres", [])],
"source": "tmdb",
} }
matched = True matched = True
break break
if matched:
break
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
# 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 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
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
if not matched:
for kandidat in kandidaten:
omdb_treffer = self.omdb.lookup(kandidat)
if omdb_treffer:
confidence = 0.8
metadata = omdb_treffer
title = omdb_treffer.get("title", title)
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:
vorschlag = self.tmdb.get_movie_details(movies[0]["id"])
if vorschlag:
confidence = 0.6
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": vorschlag.get("title", title),
"year": int(vorschlag.get("release_date", "0")[:4]) if vorschlag.get("release_date") else None,
"overview": vorschlag.get("overview", ""),
"poster_path": vorschlag.get("poster_path", ""),
"backdrop_path": vorschlag.get("backdrop_path", ""),
"runtime": vorschlag.get("runtime", 0),
"genres": [g["name"] for g in vorschlag.get("genres", [])],
"source": "tmdb",
}
matched = True
if not matched: if not matched:
confidence = 0.3 confidence = 0.3
@@ -243,13 +528,35 @@ class PreScan:
"year": None "year": None
} }
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
deutsch = self.tmdb.find_by_imdb(metadata["id"])
if deutsch:
for feld in ("title", "overview", "poster_path", "year", "type"):
if deutsch.get(feld):
metadata[feld] = deutsch[feld]
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
if matched and metadata.get("title"):
title = metadata["title"]
result = PreScanResult( result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"), disc_type=toc.get("disc_type", "DVD"),
title=title, title=title,
year=metadata.get("year"), year=metadata.get("year"),
confidence=confidence, confidence=confidence,
metadata=metadata, metadata=metadata,
tracks=toc.get("tracks", []) tracks=toc.get("tracks", []),
fingerprint=toc.get("fingerprint", "")
) )
cache_set(cache_key, result.to_dict()) # Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
# Mittelgute Treffer verfallen nach einem Tag — falls eine bessere
# Quelle (Jikan-504 vorbei, TMDB-Key neu) später mehr weiß.
if confidence >= 0.9:
cache_set(cache_key, result.to_dict(), expire=7 * 86400)
elif confidence >= 0.6:
cache_set(cache_key, result.to_dict(), expire=86400)
return result return result
+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
},
}
+93 -43
View File
@@ -1,17 +1,105 @@
"""Rate-Limiting-Modul für Rippy API.""" """Rate-Limiting-Modul für Rippy API (pro Client-IP).
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 import time
import secrets
from collections import defaultdict from collections import defaultdict
from typing import Dict, Optional from typing import Dict
# Default Rate Limit # Wie viele Anfragen pro Minute und Client durchgehen.
MAX_REQUESTS_PER_MINUTE = 100 #
# ⚠️ 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) # In-Memory Rate Limit Store (in Produktion mit Redis)
rate_limit_store: Dict[str, list] = defaultdict(list) 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: 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.""" """Prüfe ob Client rate-limited ist."""
current_time = time.time() current_time = time.time()
@@ -48,41 +136,3 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
def reset_rate_limit(client_id: str) -> None: def reset_rate_limit(client_id: str) -> None:
"""Setze Rate Limit für Client zurück.""" """Setze Rate Limit für Client zurück."""
rate_limit_store[client_id] = [] rate_limit_store[client_id] = []
# API-Key Store (in Produktion mit Datenbank)
api_keys: Dict[str, Dict] = {
"example_key": {
"key": "example_key",
"name": "Beispiel API Key",
"created_at": time.time(),
"rate_limit": 100
}
}
def validate_api_key(api_key: str) -> Optional[Dict]:
"""Validiere API Key."""
if api_key in api_keys:
return api_keys[api_key]
return None
def create_api_key(name: str) -> Dict:
"""Erstelle neuer API Key."""
key = secrets.token_urlsafe(32)
api_keys[key] = {
"key": key,
"name": name,
"created_at": time.time(),
"rate_limit": MAX_REQUESTS_PER_MINUTE
}
return api_keys[key]
def delete_api_key(key: str) -> bool:
"""Lösche API Key."""
if key in api_keys:
del api_keys[key]
return True
return False
+3 -3
View File
@@ -7,7 +7,7 @@ redis==5.0.4
requests==2.32.3 requests==2.32.3
pydantic==2.9.0 pydantic==2.9.0
pydantic-settings==2.5.2 pydantic-settings==2.5.2
passlib==1.7.4 # passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
PyJWT==2.9.0 # Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
python-multipart==0.0.9 # die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
aiofiles==24.1.0 aiofiles==24.1.0
+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
+217 -1
View File
@@ -18,10 +18,31 @@ def test_main_importierbar_und_routen_verdrahtet():
from main import app from main import app
routen = {route.path for route in app.routes} routen = {route.path for route in app.routes}
for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"): for pfad in (
"/health", "/jobs", "/devices", "/logs", "/settings",
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore",
):
assert pfad in routen, f"Route {pfad} fehlt" assert pfad in routen, f"Route {pfad} fehlt"
def test_dateiname_validierung_blockt_pfad_tricks():
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
from main import _sicherer_dateiname
assert _sicherer_dateiname("film.mkv") is True
assert _sicherer_dateiname("../../etc/passwd") is False
assert _sicherer_dateiname("a/b.mkv") is False
assert _sicherer_dateiname("a\\b.mkv") is False
assert _sicherer_dateiname(".versteckt") is False
assert _sicherer_dateiname("") is False
def test_worker_task_name_passt_zum_celery_client(): def test_worker_task_name_passt_zum_celery_client():
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker.""" """API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
import inspect import inspect
@@ -30,3 +51,198 @@ def test_worker_task_name_passt_zum_celery_client():
quelle = inspect.getsource(celery_client.start_rip) quelle = inspect.getsource(celery_client.start_rip)
assert '"worker.tasks.rip_disc"' in quelle assert '"worker.tasks.rip_disc"' in quelle
def test_remount_blockiert_den_api_start_nicht():
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
import inspect
import main
quelle = inspect.getsource(main.startup_event)
assert "create_task(asyncio.to_thread(remount))" in quelle, \
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
assert "await asyncio.to_thread(remount)" not in quelle, \
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: In main.py prüften neun Stellen mit nacktem
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
aber außerhalb. Zwilling von tasks.unter_wurzel im Worker."""
from main import unter_wurzel
assert unter_wurzel("/app/media", "/app/media") is True
assert unter_wurzel("/app/media/movies", "/app/media") is True
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
assert unter_wurzel("/app/mediaX", "/app/media") is False
assert unter_wurzel("/etc/passwd", "/app/media") is False
assert unter_wurzel("", "/app/media") is False
assert unter_wurzel("/app/media", "") is False
assert unter_wurzel("/app/media/movies", "/app/media/") is True
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
"""Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der
Celery-Ping sammelt Antworten bis zum Timeout und kann nicht früher
aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite
eine Sekunde, während alle anderen Endpunkte unter 25 ms lagen.
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
"""
import time as _t
import main
pings = []
monkeypatch.setattr(main, "_ping_jetzt", lambda: pings.append(1) or ["celery@neu"])
# Frischer Vorrat -> ablesen, kein Ping
monkeypatch.setitem(main._PING, "knoten", ["celery@alt"])
monkeypatch.setitem(main._PING, "stand", _t.monotonic())
assert main._ping_knoten() == ["celery@alt"]
assert pings == [], "bei frischem Vorrat darf NICHT gepingt werden"
# Zu alter Vorrat -> einmal synchron pingen
monkeypatch.setitem(main._PING, "stand", _t.monotonic() - main.PING_ALTER_MAX_SEKUNDEN - 1)
assert main._ping_knoten() == ["celery@neu"]
assert len(pings) == 1
# Kalter Start (nie gepingt) -> ebenfalls pingen, nicht "alles offline" melden
monkeypatch.setitem(main._PING, "stand", -1e9)
main._ping_knoten()
assert len(pings) == 2
def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
"""Sonst läuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der
Endpunkt pingt doch wieder synchron."""
import main
assert main.PING_INTERVALL_SEKUNDEN < main.PING_ALTER_MAX_SEKUNDEN
def test_tote_routen_sind_und_bleiben_weg():
"""Entfernt am 25.07.2026, jede ein Überrest eines ersetzten Entwurfs und
ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe
des UI gegen alle Routen).
Der Test hält sie draussen. /stream/jobs ist der Grund für diese
Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt
entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden
Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein
EventSource im UI).
"""
from main import app
routen = {route.path for route in app.routes}
for pfad in ("/prescan", "/jellyfin/format", "/stream/jobs",
"/worker-setup/windows-gui"):
assert pfad not in routen, (
f"{pfad} ist wieder da — entweder mit Verbraucher (dann diesen Test "
"anpassen) oder versehentlich (dann wieder raus)"
)
def test_worker_setup_routen_die_gebraucht_werden_sind_da():
"""Die Installer holen sich Code und .exe hierueber — /worker-setup/paket
ruft install.ps1 UND install-gui.ps1 auf, /windows-exe der UI-Knopf."""
from main import app
routen = {route.path for route in app.routes}
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)
-69
View File
@@ -1,69 +0,0 @@
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
import time
from auth import (
add_to_blacklist,
cleanup_blacklist,
create_access_token,
create_refresh_token,
decode_token,
get_password_hash,
is_access_token,
is_blacklisted,
is_refresh_token,
token_blacklist,
verify_password,
)
def test_passwort_hash_roundtrip():
hashed = get_password_hash("geheim123")
assert hashed != "geheim123"
assert verify_password("geheim123", hashed) is True
assert verify_password("falsch", hashed) is False
def test_access_token_roundtrip():
token = create_access_token({"sub": "commander"})
payload = decode_token(token)
assert payload is not None
assert payload["sub"] == "commander"
assert payload["type"] == "access"
assert is_access_token(token) is True
assert is_refresh_token(token) is False
def test_refresh_token_roundtrip():
token = create_refresh_token({"sub": "commander"})
payload = decode_token(token)
assert payload is not None
assert payload["type"] == "refresh"
assert is_refresh_token(token) is True
assert is_access_token(token) is False
def test_muell_token_gibt_none_und_false():
assert decode_token("kein.echter.token") is None
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
assert is_access_token("kein.echter.token") is False
assert is_refresh_token("kein.echter.token") is False
def test_blacklist_logout_wirkt():
token = create_access_token({"sub": "commander"})
assert is_blacklisted(token) is False
add_to_blacklist(token)
assert is_blacklisted(token) is True
def test_cleanup_entfernt_nur_abgelaufene():
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
frisch = create_access_token({"sub": "commander"})
add_to_blacklist(frisch)
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
cleanup_blacklist()
assert "laengst-abgelaufener-token" not in token_blacklist
assert is_blacklisted(frisch) is True
+11 -1
View File
@@ -15,7 +15,17 @@ def test_prescan_key_audio_vs_video():
assert audio.startswith(PRESCAN_AUDIO_PREFIX) assert audio.startswith(PRESCAN_AUDIO_PREFIX)
assert video.startswith(PRESCAN_VIDEO_PREFIX) assert video.startswith(PRESCAN_VIDEO_PREFIX)
assert audio != video assert audio != video
assert audio.endswith("/dev/sr0") assert "/dev/sr0" in audio
def test_prescan_key_unterscheidet_discs_im_selben_laufwerk():
"""Seit 23.07. ist der Disc-Fingerabdruck Teil des Keys — sonst bekam die
NÄCHSTE Disc im selben Laufwerk die Metadaten der vorherigen serviert.
(Der alte Test kannte den Fingerprint nicht und hielt die Ampel rot.)"""
a = generate_prescan_key("/dev/sr0", False, "EVANGELION_2_22:43GB")
b = generate_prescan_key("/dev/sr0", False, "MATRIX:31GB")
assert a != b
assert a.endswith("EVANGELION_2_22:43GB")
def test_confirmed_key_mit_und_ohne_jahr(): def test_confirmed_key_mit_und_ohne_jahr():
+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
+118
View File
@@ -0,0 +1,118 @@
"""Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
das Zusammenfuehren der settings.conf.
"""
from makemkv_daten import (
ist_aacs_dump,
keydb_pruefen,
settings_conf_zusammenfuehren,
zaehle_disc_eintraege,
)
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
_GUELTIG = """; KEYDB.cfg — Beispiel
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
assert zaehle_disc_eintraege(nur_kommentare) == 0
assert zaehle_disc_eintraege("") == 0
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
def test_keydb_pruefen_meldet_leere_datei():
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html():
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen():
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False
assert ist_aacs_dump("unter\\ordner.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu")
assert 'app_Key = "T-neu"' in neu
assert "T-alt" not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DestinationDir = "/tmp"' in neu
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
# Datei mit abschliessendem Zeilenumbruch.
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
assert settings_conf_zusammenfuehren("", "") == ""
+28
View File
@@ -0,0 +1,28 @@
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
_KEY = "T-BSaJ6gwgMx4eIggWkVYXiVP_6zehm7WAO9dEydvzOHFHoZ6YQ82BL5cGpYDxvyRWnS"
def test_extract_key_findet_key_im_html():
html = f'<div class="content"><pre><code>{_KEY}</code></pre></div>'
assert extract_key(html) == _KEY
def test_extract_key_ohne_key_gibt_none():
assert extract_key("<html>kein Schluessel hier</html>") is None
assert extract_key("") is None
def test_extract_key_zu_kurz_kein_treffer():
# Format ist streng T- + genau 64 Zeichen — zu kurz fällt raus.
assert extract_key("T-zukurz123") is None
def test_extract_key_liefert_ganzen_key():
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind länger
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY
assert treffer.startswith("T-")
+421
View File
@@ -0,0 +1,421 @@
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
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():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
)
assert "Gast" in meldung
assert "Benutzername + Passwort" in meldung
def test_access_denied_mit_credentials_verweist_auf_konto():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
)
assert "stimmen nicht" in meldung
def test_logon_failure_wird_uebersetzt():
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
assert "Passwort falsch" in meldung
def test_unerreichbar_wird_uebersetzt():
meldung = uebersetze_smb_fehler(
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
mit_credentials=False,
)
assert "nicht erreichbar" in meldung
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
assert meldung == "X" * 200
def test_validiere_name_bleibt_streng():
assert validiere_name("nas-filme")
assert not validiere_name("NAS Filme")
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
import types
import mounts
aufrufe = []
def fake_run(cmd, **kwargs):
aufrufe.append(cmd)
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
assert mounts._stale_mounts_loesen("/app/media/x") == 3
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
def test_mounten_loest_stale_vor_dem_mount():
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
import inspect
import mounts
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
+146
View File
@@ -0,0 +1,146 @@
"""Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing)."""
from clients.omdb import parse_runtime_minutes, parse_year
from prescan.prescan import normalize_disc_label, parse_udf_dstring
def test_udf_dstring_latin1():
# d-string: [comp_id=8]["MEIN_FILM"][padding][len=10]
daten = bytes([8]) + b"MEIN_FILM" + bytes(21) + bytes([10])
assert parse_udf_dstring(daten) == "MEIN_FILM"
def test_udf_dstring_utf16():
text = "FILM".encode("utf-16-be")
daten = bytes([16]) + text + bytes(32 - 2 - len(text)) + bytes([1 + len(text)])
assert parse_udf_dstring(daten) == "FILM"
def test_udf_dstring_kaputt_gibt_leer():
assert parse_udf_dstring(b"") == ""
assert parse_udf_dstring(bytes(32)) == "" # Länge 0
daten = bytes([8]) + b"X" + bytes(29) + bytes([99]) # Länge > Puffer
assert parse_udf_dstring(daten) == ""
def test_label_normalisierung():
# Disc-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN
assert normalize_disc_label("PULP_FICTION") == "Pulp Fiction"
assert normalize_disc_label("DIE_HARD_4.0_DE") == "Die Hard 4 0 De"
assert normalize_disc_label(" doppel raum ") == "doppel raum"
assert normalize_disc_label("") == ""
def test_label_mit_gemischter_schreibung_bleibt():
# Wer schon sauber benannt hat, wird nicht plattgewalzt
assert normalize_disc_label("Inception") == "Inception"
def test_omdb_runtime_parsing():
assert parse_runtime_minutes("142 min") == 142
assert parse_runtime_minutes("N/A") == 0
assert parse_runtime_minutes("") == 0
def test_omdb_jahr_parsing():
assert parse_year("1999") == 1999
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
+20
View File
@@ -0,0 +1,20 @@
"""Tests für die TMDB-Key-Erkennung (v3-Schlüssel vs. v4-Token).
Befund 24.07.: Der Client konnte nur v4-Bearer — mit dem üblichen 32-Hex-
v3-Key war jede Anfrage 401 und TMDB fiel still aus (nur OMDb-Treffer).
"""
from clients.tmdb import ist_v4_token
def test_v4_token_wird_erkannt():
assert ist_v4_token("eyJhbGciOiJIUzI1NiJ9.irgendwas.signatur") is True
def test_v3_hex_key_ist_kein_v4_token():
assert ist_v4_token("842abc0123456789842abc0123456789") is False
def test_leer_ist_kein_token():
assert ist_v4_token("") is False
assert ist_v4_token(None) is False
-6
View File
@@ -1,6 +0,0 @@
FROM postgres:16
COPY init.sql /docker-entrypoint-initdb.d/
EXPOSE 5432
-3
View File
@@ -1,3 +0,0 @@
CREATE DATABASE rippy;
CREATE USER rippy WITH PASSWORD 'rippy123';
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
-5
View File
@@ -1,5 +0,0 @@
FROM redis:7-alpine
COPY redis.conf /usr/local/etc/redis/redis.conf
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
-6
View File
@@ -1,6 +0,0 @@
appendonly yes
appendfsync everysec
timeout 0
tcp-keepalive 300
loglevel notice
databases 16
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy — Automatisches Ripping-System</title> <title>Rippy — Automatisches Ripping-System</title>
</head> </head>
+15
View File
@@ -14,9 +14,24 @@ server {
proxy_pass http://api:8000/; proxy_pass http://api:8000/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; 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: # SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
# Der nginx-Default ist 1 MB. Eine echte KEYDB.cfg ist deutlich groesser,
# der Upload ueber POST /api/system/keydb wuerde also schon hier mit
# 413 abgewiesen — die API bekaeme die Anfrage nie zu sehen und das UI
# haette keinen detail-Text, den es anzeigen koennte. 64m entspricht dem
# Limit MAX_KEYDB_BYTES in makemkv_daten.py.
client_max_body_size 64m;
} }
location / { location / {
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Rippy-Favicon: Disc im Indigo-Violett-Verlauf des UI-Logos -->
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#9333ea"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#ffffff" stroke-width="4"/>
<circle cx="32" cy="32" r="6" fill="#ffffff"/>
<path d="M32 13 A19 19 0 0 1 51 32" fill="none" stroke="#c7d2fe" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 634 B

+63 -61
View File
@@ -1,86 +1,88 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon, Terminal } from 'lucide-react' import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs' import LogsPage from './pages/Logs'
import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api'
type Page = 'dashboard' | 'metadata' | 'settings' | 'logs' type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
function App() { export default function App() {
const [page, setPage] = useState<Page>('dashboard') const [currentPage, setCurrentPage] = useState<Page>('dashboard')
const { theme, toggleTheme } = useDarkMode() // First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
const [setupDone, setSetupDone] = useState<boolean | null>(null)
const navItems = [ useEffect(() => {
api.get('/setup')
.then((r) => setSetupDone(Boolean(r.data?.done)))
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
}, [])
const navItems: { id: Page; label: string; icon: any }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'metadata', label: 'Metadaten', icon: Disc }, { id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'logs', label: 'Logs', icon: Terminal }, { id: 'logs', label: 'Logs', icon: FileText },
{ id: 'settings', label: 'Einstellungen', icon: Settings }, { id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
] ]
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
return ( return (
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300"> <div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
<div className="flex h-screen overflow-hidden"> {/* Top Header Navigation Bar */}
{/* Sidebar */} <header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col"> <div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
<div className="p-6">
<div className="flex items-center gap-3"> {/* Logo */}
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl"> <div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
<Disc size={28} className="text-white" /> <span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
</div> RIPPY
<div> </span>
<h1 className="text-xl font-bold tracking-tight">Rippy</h1> <span className="px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30">
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p> CINEMA OS
</div> </span>
</div>
</div> </div>
<button {/* Central Nav Tabs */}
onClick={toggleTheme} <nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200" {navItems.map((item) => {
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'} const Icon = item.icon
> const isActive = currentPage === item.id
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} return (
</button>
<nav className="flex-1 px-3 py-2 space-y-1">
{navItems.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => setPage(item.id as Page)} onClick={() => setCurrentPage(item.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${ className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
page === item.id isActive
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30' ? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
: 'text-slate-400 hover:bg-slate-800 hover:text-white' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`} }`}
> >
<item.icon size={20} /> <Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
<span className="font-medium">{item.label}</span> <span>{item.label}</span>
</button> </button>
))} )
})}
</nav> </nav>
<div className="p-4 border-t border-slate-800 dark:border-slate-700"> <div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<button className="w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-slate-400 hover:text-white hover:bg-slate-800 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-700"> <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<LogOut size={20} /> <span>ONLINE</span>
<span className="text-sm">Abmelden</span>
</button>
</div> </div>
</aside> </div>
</header>
{/* Main Content */} {/* Main Content Viewport */}
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950"> <main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-7xl mx-auto p-6"> {currentPage === 'dashboard' && <Dashboard />}
{page === 'dashboard' && <Dashboard />} {currentPage === 'anleitung' && <AnleitungPage />}
{page === 'metadata' && <MetadataPreview />} {currentPage === 'logs' && <LogsPage />}
{page === 'logs' && <LogsPage />} {currentPage === 'settings' && <SettingsPage />}
{page === 'settings' && <SettingsPage />}
</div>
</main> </main>
</div> </div>
</div>
) )
} }
export default App
+34 -38
View File
@@ -1,5 +1,7 @@
import { useState } from 'react' import { ReactNode } from 'react'
import { useDarkMode } from '../context/ThemeContext' import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
@@ -7,6 +9,9 @@ interface ConfirmDialogProps {
onConfirm: () => void onConfirm: () => void
title: string title: string
message: 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 confirmText?: string
cancelText?: string cancelText?: string
type?: 'warning' | 'info' | 'success' type?: 'warning' | 'info' | 'success'
@@ -18,66 +23,57 @@ export default function ConfirmDialog({
onConfirm, onConfirm,
title, title,
message, message,
extra,
confirmText = 'Bestätigen', confirmText = 'Bestätigen',
cancelText = 'Abbrechen', cancelText = 'Abbrechen',
type = 'info', type = 'info',
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { theme } = useDarkMode()
const getIcon = () => { const getIcon = () => {
switch (type) { switch (type) {
case 'warning': return <span className="text-4xl"></span> case 'warning':
case 'success': return <span className="text-4xl"></span> return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
default: return <span className="text-4xl">🤔</span> case 'success':
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
default:
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
} }
} }
const getConfirmColor = () => { const getConfirmVariant = () => {
switch (type) { switch (type) {
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30' case 'warning':
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30' return 'amber'
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30' case 'success':
return 'primary'
default:
return 'primary'
} }
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all"> <Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> <div className="flex items-start gap-4">
{/* Header */}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className="flex items-center gap-3">
{getIcon()} {getIcon()}
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{title} {title}
</h2> </h2>
</div> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</div>
{/* Body */}
<div className="px-6 py-6">
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{message} {message}
</p> </p>
{extra}
</div>
</div> </div>
{/* Footer */} <div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}> <Button variant="secondary" onClick={onClose}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
{cancelText} {cancelText}
</button> </Button>
<button <Button variant={getConfirmVariant()} onClick={onConfirm}>
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
>
{confirmText} {confirmText}
</button> </Button>
</div>
</div>
</div> </div>
</Modal>
) )
} }
+268 -74
View File
@@ -1,31 +1,126 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { TypeBadge } from './ui/Badge'
import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
interface DiscInfo {
title: string
year?: number
disc_type: string
confidence: number
metadata?: {
poster_path?: string
overview?: string
type?: string
source?: string
}
bereits_gerippt?: {
job_id: string
title?: string
finished_at?: string
}
}
interface Device { interface Device {
id: string id: string
name: string name: string
type: 'cd' | 'dvd' | 'bluray' | 'unknown' type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
path: string path: string
status: 'empty' | 'ready' | 'ripping' status: 'empty' | 'ready' | 'ripping'
serial?: string serial?: string
model?: string model?: string
disc?: DiscInfo
}
function posterUrl(disc?: DiscInfo): string | null {
const p = disc?.metadata?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
function typLabel(type: string): string {
switch (type) {
case 'cd': return 'Audio CD'
case 'dvd': return 'DVD'
case 'bluray': return 'Blu-ray'
case 'uhd': return '4K UHD'
default: return 'Unbekannt'
}
} }
export default function DeviceDiscovery() { export default function DeviceDiscovery() {
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { theme } = useDarkMode() const [refreshing, setRefreshing] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
const [actionBusy, setActionBusy] = useState(false)
const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { toast } = useToast()
const refreshDevices = async () => { const refreshDevices = async (manuell = false) => {
if (manuell) setRefreshing(true)
try { try {
const response = await api.get('/devices') const response = await api.get('/devices')
setDevices(response.data) setDevices(response.data)
if (manuell) toast('success', 'Laufwerke aktualisiert')
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Geräte:', error) console.error('Fehler beim Laden der Geräte:', error)
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
} finally { } finally {
setLoading(false) setLoading(false)
if (manuell) setTimeout(() => setRefreshing(false), 400)
}
}
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
setActionBusy(true)
setActionFeedback(null)
try {
const response = await api.post('/jobs', {
device_path: device.path,
...(targetDir ? { target_dir: targetDir } : {}),
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
...(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"')
} catch (error: any) {
const detail = error?.response?.data?.detail || 'Job konnte nicht angelegt werden'
setActionFeedback(`${detail}`)
toast('error', detail)
} finally {
setActionBusy(false)
}
}
const ejectDisc = async (device: Device) => {
setActionBusy(true)
setActionFeedback(null)
try {
await api.post(`/devices/${device.id}/eject`)
setActionFeedback('✓ Disc ausgeworfen')
toast('success', 'Disc ausgeworfen')
refreshDevices()
} catch (error: any) {
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
setActionFeedback(`${detail}`)
toast('error', detail)
} finally {
setActionBusy(false)
} }
} }
@@ -35,126 +130,225 @@ export default function DeviceDiscovery() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const getTypeColor = (type: string) => { const QUELLEN_LABEL: Record<string, string> = {
switch (type) { tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
}
} }
const getStatusColor = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' case 'empty':
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Keine Disc</span>
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' case 'ready':
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"><CheckCircle size={13} /> Bereit</span>
} case 'ripping':
} return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-sky-500/30 animate-pulse"><RefreshCw size={13} className="animate-spin" /> Rippt</span>
default:
const getStatusIcon = (status: string) => { return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Unbekannt</span>
switch (status) {
case 'empty': return <AlertCircle size={16} />
case 'ready': return <CheckCircle size={16} />
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
default: return <AlertCircle size={16} />
} }
} }
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<RefreshCw className="animate-spin text-indigo-600" size={24} /> <RefreshCw className="animate-spin text-amber-500" size={24} />
</div> </div>
) )
} }
return ( return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <CardHeader>
<div> <div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2> <CardTitle>Laufwerke</CardTitle>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p> <p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
</div>
<button
onClick={refreshDevices}
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
title="Geräte neu laden"
>
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
</button>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
<RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<div className="p-6 transition-colors duration-300"> <CardContent>
{devices.length === 0 ? ( {/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
<div className="text-center py-12 transition-colors duration-300"> {devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <div
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} /> key={`disc-${device.id}`}
className="mb-6 rounded-2xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 flex shadow-xl shadow-amber-500/5 backdrop-blur-md"
>
{posterUrl(device.disc) && (
<img
src={posterUrl(device.disc)!}
alt={device.disc!.title}
className="w-32 object-cover flex-shrink-0 border-r border-slate-800"
/>
)}
<div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
Im Laufwerk erkannt
</p>
<h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
{device.disc!.title}
{device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
</h3>
<div className="flex items-center gap-2 mt-2">
<TypeBadge type={device.type} />
<span className="text-xs text-slate-500 dark:text-slate-400">
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
</span>
</div> </div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p> {device.disc!.metadata?.overview && (
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
{device.disc!.metadata.overview}
</p>
)}
{device.disc!.bereits_gerippt && (
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
<AlertCircle size={13} className="flex-shrink-0" />
Diese Disc wurde bereits gerippt
{device.disc!.bereits_gerippt.finished_at
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
: ''} nochmal rippen ist möglich.
</p>
)}
</div>
<div className="flex items-center gap-3 mt-4">
<Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
Rippen starten
</Button>
<Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
Nicht korrekt?
</Button>
</div>
</div>
</div>
))}
{devices.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-4 bg-slate-100 dark:bg-slate-800 text-slate-400">
<HardDrive size={32} />
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
<p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
Stelle sicher, dass ein Laufwerk angeschlossen ist Stelle sicher, dass ein Laufwerk angeschlossen ist
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-3">
{devices.map(device => ( {devices.map(device => (
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}> <div key={device.id} className="rounded-xl p-4 border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}> <div className="p-3 rounded-xl bg-slate-200/60 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
<Disc size={24} /> <Disc size={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="font-semibold text-slate-900 dark:text-slate-100">
{device.name || 'Unbekanntes Laufwerk'} {device.name || 'Unbekanntes Laufwerk'}
</h3> </h3>
{device.status === 'ripping' && ( <TypeBadge type={device.type} />
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
<RefreshCw size={12} className="animate-spin" />
Rippt
</span>
)}
</div> </div>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs font-mono px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.path} {device.path}
</span> </span>
{device.serial && ( {device.serial && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.serial} {device.serial}
</span> </span>
)} )}
{device.model && ( {device.model && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.model} {device.model}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-3"> <div className="mt-3">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}> {getStatusBadge(device.status)}
{getStatusIcon(device.status)} </div>
{device.status === 'empty' ? 'Keine Disc' : </div>
device.status === 'ready' ? 'Bereit' : </div>
'Rippt'} <Button
</span> variant="ghost"
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> size="sm"
Typ: {device.type.toUpperCase()} onClick={() => {
setExpandedId(expandedId === device.id ? null : device.id)
setActionFeedback(null)
}}
>
{expandedId === device.id ? 'Schließen' : 'Details'}
</Button>
</div>
{expandedId === device.id && (
<div className="mt-4 pt-4 border-t border-slate-200/80 dark:border-slate-800 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<span className="text-slate-500 dark:text-slate-400">Gerätepfad</span>
<span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
<span className="text-slate-500 dark:text-slate-400">Modell</span>
<span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
<span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
<span className="text-slate-500 dark:text-slate-400">Disc</span>
<span className="text-slate-700 dark:text-slate-300">
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 pt-1">
<Button
variant="amber"
size="sm"
onClick={() => setModalDevice(device)}
disabled={actionBusy || device.status !== 'ready'}
>
Rippen starten
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => ejectDisc(device)}
disabled={actionBusy || device.status !== 'ready'}
>
Auswerfen
</Button>
</div> </div>
{actionFeedback && (
<p className={`text-sm font-medium ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{actionFeedback}
</p>
)}
</div> </div>
<button className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}> )}
Details
</button>
</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</div>
<RipTargetModal
isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
discTitle={modalDevice?.disc?.title}
deviceId={modalDevice?.id}
onClose={() => setModalDevice(null)}
onSave={(target, optionen) => {
if (modalDevice) {
startRip(modalDevice, target.path, optionen)
}
}}
/>
<MetadataKorrektur
devicePath={korrekturDevice?.path || ''}
vorschlag={korrekturDevice?.disc?.title}
isOpen={korrekturDevice !== null}
onClose={() => setKorrekturDevice(null)}
onUebernommen={refreshDevices}
/>
</Card>
) )
} }
+478
View File
@@ -0,0 +1,478 @@
import { useState, useEffect } from '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'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
interface WorkerInfo {
name: string
encoders: string[]
online?: boolean
info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string }
}
interface GeraetInfo {
id: string
name: string
status: string
}
interface PlatzInfo {
name: string
frei_gb: number
gesamt_gb: number
}
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264 (x264)',
'cpu-x265': 'CPU · H.265 (x265)',
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
'nvenc': 'Hardware · NVENC (NVIDIA)',
}
// Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
const PLATZ_WARNUNG_GB = 60
// 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 {
...(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)
const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
// läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft
// „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
useEffect(() => {
let laeuft = true
const laden = async () => {
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()
const takt = setInterval(laden, 5000)
return () => { laeuft = false; clearInterval(takt) }
}, [])
const schwach = schwacheEncoderCpu(workers)
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
const abschliessen = async () => {
setSaving(true)
setError(null)
setKeyWarnung(null)
try {
const bestehende = (await api.get('/settings')).data || {}
await api.post('/settings', {
...bestehende,
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
mediaServer,
transcodeEnabled,
...presets,
})
// Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
// sofort auf, statt erst beim ersten Rip als „Unknown Disc".
if (tmdbKey.trim() || omdbKey.trim()) {
try {
const s = (await api.get('/metadata/status')).data || {}
const kaputt = Object.entries(s)
.filter(([, v]) => v === 'fehler')
.map(([k]) => k.toUpperCase())
if (kaputt.length > 0) {
setKeyWarnung(
`${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein `
+ 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann '
+ 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.'
)
setSaving(false)
return // nicht wegklicken, damit die Warnung gelesen wird
}
} catch {
// Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
}
}
await api.post('/setup/complete')
onDone()
} catch {
setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)')
setSaving(false)
}
}
const trotzdemWeiter = async () => {
setSaving(true)
try {
await api.post('/setup/complete')
onDone()
} catch {
setError('Speichern fehlgeschlagen — läuft die API?')
setSaving(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
<div className="flex items-center gap-3 mb-2">
<Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div>
<p className="opacity-90">
Einmalige Einrichtung dauert keine zwei Minuten. Du kannst nichts kaputt
machen: alles ist später unter Einstellungen änderbar.
</p>
</div>
<div className="p-8 space-y-8">
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
</h2>
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
<Zeile
titel="Laufwerk"
geladen={geladen}
gut={geraete.length > 0}
gutText={geraete.map(g => g.name).join(', ')}
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
/>
<Zeile
titel="Encoding-Worker"
geladen={geladen}
gut={encoderWorker.length > 0}
gutText={encoderWorker.map(w => {
const kerne = w.info?.cpu_kerne
const simd = w.info?.cpu_simd
const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : '']
.filter(Boolean).join(', ')
return zusatz ? `${w.name} (${zusatz})` : w.name
}).join(' · ')}
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht."
/>
<Zeile
titel="Freier Platz"
geladen={geladen}
gut={platz === null || platz >= PLATZ_WARNUNG_GB}
gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`}
schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`}
/>
</div>
</section>
{/* ---- Metadaten ---------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2>
<div className="space-y-3">
{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys,
keine Passwoerter — und einen Tippfehler sieht man in Punkten
nicht. Geprueft wird direkt nach dem Speichern. */}
<Input
label="TMDB API-Key (empfohlen)"
value={tmdbKey}
onChange={e => setTmdbKey(e.target.value)}
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
/>
<Input
label="OMDb API-Key (optional, zweite Quelle)"
value={omdbKey}
onChange={e => setOmdbKey(e.target.value)}
placeholder="Kostenlos auf omdbapi.com"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
Beide Keys werden direkt nach dem Speichern geprüft.
</p>
</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">
<Tv size={18} className="text-amber-500" /> Dein Media-Server
</h2>
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
zusätzlich movie.nfo + poster.jpg dazu.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => (
<button
key={o.wert}
type="button"
onClick={() => setMediaServer(o.wert)}
title={o.hinweis}
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
mediaServer === o.wert
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{o.label}
</button>
))}
</div>
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
</p>
</section>
{/* ---- Verarbeitung ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2>
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={transcodeEnabled}
onChange={e => setTranscodeEnabled(e.target.checked)}
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
/>
<span>
Nach dem Rip auf Arbeitsgröße komprimieren
<span className="block text-xs text-slate-500 dark:text-slate-400">
Empfohlen — ohne Kompression braucht jede Blu-ray rund 40 GB.
</span>
</span>
</label>
{transcodeEnabled && (
<div className={`rounded-xl p-4 border ${
schwach
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
}`}>
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN
Rechenleistung. Vorher stand hier H.265 als Standard - auf
einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und
genau das ist am 25.07.2026 passiert. */}
{schwach ? (
<>
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
</p>
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
Keiner deiner Worker kann AVX2 — die Vektorbefehle, von denen H.265
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
{' '}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:
</p>
)}
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
<li>
<strong>4K-UHD:</strong>{' '}
{presets.transcodePresetUhd === PRESET_KEINE
? 'nicht komprimieren, verlustfrei behalten (20100 GB je Film)'
: 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>
</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>
</div>
)}
{encoderWorker.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
)}
</section>
{keyWarnung && (
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
<div className="flex gap-2 mt-3">
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
Key korrigieren
</Button>
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
Trotzdem fertigstellen
</Button>
</div>
</div>
)}
{error && (
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
{error}
</p>
)}
<Button
variant="amber"
size="lg"
onClick={abschliessen}
disabled={saving}
className="w-full"
>
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
</Button>
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
</p>
</div>
</div>
</div>
)
}
/** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst
* bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */
function Zeile({ titel, geladen, gut, gutText, schlechtText }: {
titel: string
geladen: boolean
gut: boolean
gutText: string
schlechtText: string
}) {
if (!geladen) {
return (
<div className="flex items-center gap-3 p-3">
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} wird geprüft</span>
</div>
)
}
return (
<div className="flex items-start gap-3 p-3">
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
<div className="min-w-0">
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-medium">{titel}:</span>{' '}
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
</p>
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
</div>
</div>
)
}
@@ -0,0 +1,218 @@
import { Disc, Sparkles, CheckCircle, Shield, Zap, Tv, HardDrive } from 'lucide-react'
import { RadialProgress } from './ui/RadialProgress'
import { TypeBadge, StatusBadge } from './ui/Badge'
import { Button } from './ui/Button'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
progress: number
title?: string
can_retry?: boolean
meta?: JobMeta | 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/w500${p}`
}
interface HeroCinemaBannerProps {
aktiverJob?: Job | null
completedJobs?: Job[]
onDetailClick?: (jobId: string) => void
onCancelClick?: (jobId: string) => void
}
export function HeroCinemaBanner({
aktiverJob,
completedJobs = [],
onDetailClick,
onCancelClick,
}: HeroCinemaBannerProps) {
// Fall 1: Aktiver Job (Rippt oder Komprimiert live)
if (aktiverJob) {
const isTranscoding = aktiverJob.status === 'transcoding'
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
const poster = posterUrl(aktiverJob.meta)
return (
<div className="relative rounded-3xl overflow-hidden border-2 border-amber-500/50 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
<div className="flex items-center gap-6">
<div className="relative flex-shrink-0">
{poster ? (
<div className="relative">
<img src={poster} alt="" className="w-28 h-40 rounded-2xl object-cover shadow-2xl border-2 border-amber-500/40" />
<div className="absolute -bottom-3 -right-3 w-14 h-14 rounded-full disc-texture shadow-xl flex items-center justify-center animate-disc-spin border border-amber-400">
<Disc size={20} className="text-amber-400" />
</div>
</div>
) : (
<div className="w-28 h-28 rounded-full disc-texture shadow-2xl flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-500/60">
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
</div>
</div>
)}
</div>
<div className="space-y-2 text-center sm:text-left">
<div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap">
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md">
NOW RIPPING
</span>
<TypeBadge type={aktiverJob.type} />
<StatusBadge status={aktiverJob.status} />
</div>
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
{aktiverJob.title || 'Aktiver Ripping-Prozess'}
</h2>
<p className="text-xs text-slate-400 font-mono">
Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong> · {statusText}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" />
<div className="flex flex-col gap-2 w-full sm:w-auto">
<Button variant="amber" size="md" onClick={() => onDetailClick && onDetailClick(aktiverJob.id)}>
<Sparkles size={16} /> Details
</Button>
<Button variant="danger" size="md" onClick={() => onCancelClick && onCancelClick(aktiverJob.id)}>
Abbrechen
</Button>
</div>
</div>
</div>
</div>
)
}
// Fall 2: Kein aktiver Job -> 5-Panel Kachel-Galerie VOLLSTÄNDIG ausfüllen (Zero "Löcher"!)
// Wir füllen freie Slots mit informativen Cinema-Engine-Showcase-Kacheln auf!
const realMovieTiles = completedJobs.slice(0, 4)
return (
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-[#0e1322] shadow-[0_0_40px_-5px_rgba(245,158,11,0.18)] mb-8 p-3">
{/* Top Subtle Amber Line */}
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-amber-500/40 to-transparent pointer-events-none" />
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
{/* Echte Medien-Kacheln aus der API */}
{realMovieTiles.map((job) => {
const poster = posterUrl(job.meta)
return (
<div
key={job.id}
onClick={() => onDetailClick && onDetailClick(job.id)}
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
>
{poster ? (
<img
src={poster}
alt={job.title || ''}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-slate-950 flex flex-col items-center justify-center p-3 text-center">
<Disc size={32} className="text-amber-400 mb-2" />
<span className="text-[10px] font-bold text-slate-400 uppercase">{job.type}</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
<div className="relative z-10 space-y-1">
<TypeBadge type={job.type} />
<h4 className="text-xs font-bold text-slate-100 truncate group-hover:text-amber-400 transition-colors">
{job.title || 'Unbekannt'}
</h4>
</div>
</div>
)
})}
{/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
{realMovieTiles.length < 5 && (
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-[#131929] to-slate-950 border-2 border-amber-500/40 flex items-center justify-center p-4 shadow-inner group">
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-400/60">
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_16px_rgba(245,158,11,0.9)]" />
</div>
</div>
<div className="absolute bottom-2.5 px-3 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-widest bg-amber-500 text-slate-950 shadow-md">
DISC ENGINE
</div>
</div>
)}
{/* Dynamic Engine Tile: Drive Status */}
{realMovieTiles.length < 4 && (
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-indigo-950/80 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold">
<CheckCircle size={14} /> DRIVE READY
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">Automatische Erkennung</h4>
<p className="text-[11px] text-slate-400 mt-0.5">Lege eine Disc ein Rippy rippt sofort los.</p>
</div>
<div className="text-[10px] font-mono text-amber-400 font-bold">
4K UHD · BLU-RAY · DVD · CD
</div>
</div>
)}
{/* Dynamic Engine Tile: Media-Server Sync */}
{realMovieTiles.length < 3 && (
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold">
<Tv size={14} /> MEDIA SERVER
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">Auto-NFO &amp; Poster</h4>
<p className="text-[11px] text-slate-400 mt-0.5">Jellyfin, Plex &amp; Kodi kompatibel.</p>
</div>
<div className="text-[10px] font-mono text-slate-400">
Auto-Scan nach Rip
</div>
</div>
)}
{/* Dynamic Engine Tile: Lossless Assurance */}
{realMovieTiles.length < 2 && (
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
<div className="flex items-center gap-2 text-indigo-400 text-xs font-bold">
<Shield size={14} /> MakeMKV
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">Lossless 1:1</h4>
<p className="text-[11px] text-slate-400 mt-0.5">Alle Tonspuren &amp; Subtitles erhalten.</p>
</div>
<div className="text-[10px] font-mono text-slate-400">
x265 HandBrake Kompression
</div>
</div>
)}
</div>
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react'
import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { StatusBadge } from './ui/Badge'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface JobDetail {
id: string
type: string
status: string
device: string
startTime: string
endTime?: string
progress: number
title?: string
error?: string
target_dir?: string
output_path?: string
meta?: JobMeta | 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}`
}
interface JobDatei {
name: string
size_mb: number | null
}
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
const [detail, setDetail] = useState<JobDetail | null>(null)
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
const [laedt, setLaedt] = useState(false)
useEffect(() => {
if (!jobId) {
setDetail(null)
setDateien(null)
return
}
setLaedt(true)
api.get(`/jobs/${jobId}/detail`)
.then(r => {
setDetail(r.data)
if (r.data?.status === 'completed') {
api.get(`/jobs/${jobId}/files`)
.then(f => setDateien(f.data.files))
.catch(() => setDateien(null))
}
})
.catch(() => setDetail(null))
.finally(() => setLaedt(false))
}, [jobId])
if (!jobId) return null
const meta = detail?.meta
const poster = posterUrl(meta)
return (
<Modal
isOpen={!!jobId}
onClose={onClose}
title={detail?.title || 'Job-Details'}
maxWidth="2xl"
>
{laedt ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-amber-500" size={28} />
</div>
) : !detail ? (
<p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
Job nicht gefunden.
</p>
) : (
<div className="space-y-6">
{/* Film/Serien-Info aus der Disc-Erkennung */}
<div className="flex gap-5">
{poster ? (
<img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
) : (
<div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
<Film size={36} />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{detail.title || 'Unbekannt'}
{meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
</h3>
<StatusBadge status={detail.status} />
</div>
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
{meta?.type === 'tv' ? 'Serie' : 'Film'}
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
</p>
{meta?.genres && meta.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{meta.genres.map(g => (
<span key={g} className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
{g}
</span>
))}
</div>
)}
{meta?.overview ? (
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
{meta.overview}
</p>
) : (
<p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p>
)}
</div>
</div>
{/* Job-Fakten Grid */}
<div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
<span className="text-slate-900 dark:text-slate-200">
{detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
<span className="text-slate-900 dark:text-slate-200">
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
<span className="text-slate-900 dark:text-slate-200">
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
</span>
{(detail.output_path || detail.target_dir) && (
<>
<span className="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
<span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
{detail.output_path || detail.target_dir}
</span>
</>
)}
</div>
{/* Dateiliste & Download */}
{detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
<Download size={15} className="text-amber-500" /> Dateien herunterladen
</p>
{dateien.length > 1 && (
<Button
variant="amber"
size="sm"
onClick={() => {
dateien.forEach((f, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
a.download = f.name
document.body.appendChild(a)
a.click()
a.remove()
}, i * 400)
})
}}
>
Alle herunterladen ({dateien.length})
</Button>
)}
</div>
<div className="space-y-1.5">
{dateien.map(f => (
<a
key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</a>
))}
</div>
</div>
)}
{detail.error && (
<div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
<span className="break-words min-w-0">{detail.error}</span>
</div>
)}
</div>
)}
</Modal>
)
}
+30 -35
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Card, CardHeader, CardTitle } from './ui/Card'
interface LiveLogEntry { interface LiveLogEntry {
id: string id: string
@@ -14,14 +14,13 @@ interface LiveLogEntry {
export default function LiveLogSection() { export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([]) const [logs, setLogs] = useState<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(null) const [currentJob, setCurrentJob] = useState<any>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
const fetchJobs = async () => { const fetchJobs = async () => {
try { try {
const response = await api.get('/jobs') const response = await api.get('/jobs')
const jobs = response.data const jobs = response.data
const current = jobs.find((j: any) => j.status === 'processing') const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Jobs:', error) console.error('Fehler beim Laden der Jobs:', error)
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
const getStatusColor = (level: string) => { const getStatusColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return 'text-rose-500' case 'error': return 'text-rose-500 dark:text-rose-400'
case 'warning': return 'text-amber-500' case 'warning': return 'text-amber-500 dark:text-amber-400'
case 'success': return 'text-emerald-500' case 'success': return 'text-emerald-500 dark:text-emerald-400'
default: return 'text-blue-500' default: return 'text-sky-500 dark:text-sky-400'
} }
} }
const getStatusIcon = (level: string) => { const getStatusIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
return ( return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <CardHeader>
<div> <div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2> <CardTitle>Live-Log</CardTitle>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
{currentJob {currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)` ? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
: 'Kein aktiver Job'} : 'Kein aktiver Job'}
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
</div> </div>
{currentJob && ( {currentJob && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${ <div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
currentJob.progress === 100 currentJob.progress === 100
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' : 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
}`}> }`}>
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />} {currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
{currentJob.progress}% {currentJob.progress}%
</div> </div>
</div> </div>
)} )}
</div> </CardHeader>
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${ <div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
}`}>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-8 text-slate-500"> <div className="text-center py-8 text-slate-500">
<Terminal size={32} className="mx-auto mb-2 opacity-50" /> <Terminal size={32} className="mx-auto mb-2 opacity-50" />
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
</div> </div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <div key={log.id} className="flex items-start gap-2 text-slate-300">
<span className="text-slate-500 whitespace-nowrap"> <span className="text-slate-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })} {new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
</span> </span>
<span className={`font-bold ${getStatusColor(log.level)}`}> <span className={`font-bold ${getStatusColor(log.level)}`}>
[{log.source}] [{log.source}]
</span> </span>
<span className="flex-1">{log.message}</span> <span className="flex-1 text-slate-200">{log.message}</span>
<span className={getStatusColor(log.level)}> <span className={getStatusColor(log.level)}>
{getStatusIcon(log.level)} {getStatusIcon(log.level)}
</span> </span>
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
</div> </div>
{/* Quick Stats */} {/* Quick Stats */}
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
{currentJob && ( {currentJob && (
<> <div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p> <p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-lg font-bold text-slate-900 dark:text-slate-100">
{currentJob.progress}% {currentJob.progress}%
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p> <p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.device || 'Unbekannt'} {currentJob.device || 'Unbekannt'}
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p> <p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.type?.toUpperCase() || 'Unbekannt'} {currentJob.type?.toUpperCase() || 'Unbekannt'}
</p> </p>
</div> </div>
</> </div>
)} )}
</div> </Card>
</div>
) )
} }
@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { Search, Loader2, Film } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
interface SuchKandidat {
title: string
year?: number
poster?: string
overview?: string
type: string
source: string
id?: string
}
interface Props {
devicePath: string
vorschlag?: string
isOpen: boolean
onClose: () => void
onUebernommen: () => void
}
export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClose, onUebernommen }: Props) {
const [suchbegriff, setSuchbegriff] = useState('')
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
setSuchbegriff(vorschlag || '')
setKandidaten([])
setFehler(null)
}
}, [isOpen, vorschlag])
const suchen = async () => {
if (!suchbegriff) return
setLaeuft(true)
setFehler(null)
try {
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
setKandidaten(r.data)
if (r.data.length === 0) setFehler('Nichts gefunden — anderen Suchbegriff probieren.')
} catch {
setFehler('Suche fehlgeschlagen.')
} finally {
setLaeuft(false)
}
}
const waehlen = async (k: SuchKandidat) => {
try {
await api.post('/metadata/override', {
device_path: devicePath,
title: k.title,
year: k.year,
poster: k.poster,
overview: k.overview,
type: k.type,
source: k.source,
id: k.id,
})
onUebernommen()
onClose()
} catch {
setFehler('Übernehmen fehlgeschlagen.')
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Richtigen Titel wählen"
maxWidth="2xl"
>
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400">
Deine Wahl gilt für diese Disc auch beim nächsten Einlegen.
</p>
<div className="flex gap-3 items-end">
<div className="flex-1">
<Input
value={suchbegriff}
onChange={e => setSuchbegriff(e.target.value)}
onKeyDown={e => e.key === 'Enter' && suchen()}
autoFocus
placeholder="Titel eingeben…"
/>
</div>
<Button
variant="amber"
onClick={suchen}
disabled={!suchbegriff || laeuft}
>
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen
</Button>
</div>
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
{kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
{kandidaten.map((k, i) => (
<button
key={i}
onClick={() => waehlen(k)}
className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
>
{k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
<Film size={32} />
</div>
)}
<div className="p-2.5">
<p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
{k.title}
</p>
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p>
</div>
</button>
))}
</div>
)}
</div>
</Modal>
)
}
+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>
)
}
+614 -94
View File
@@ -1,5 +1,10 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useDarkMode } from '../context/ThemeContext' 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 { interface TargetConfig {
id: string id: string
@@ -9,130 +14,645 @@ interface TargetConfig {
isActive: boolean isActive: boolean
} }
interface RipTargetModalProps { export interface RipOptionen {
isOpen: boolean series?: string
onClose: () => void season?: number
onSave: (target: TargetConfig) => void mainFeatureOnly?: boolean
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[]
} }
export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) { // 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.
interface StorageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
interface WorkerWahl {
name: string
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> = {
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1',
'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
}
interface TitelInfo {
nr: number
dauer_s: number
groesse_bytes: number
kapitel: number
}
interface RipTargetModalProps {
isOpen: boolean
initialType?: TargetConfig['type']
discTitle?: string
deviceId?: string
onClose: () => void
onSave: (target: TargetConfig, optionen: RipOptionen) => void
}
function dauerText(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`
}
interface BrowseDir {
name: string
path: string
}
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies') const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [serienName, setSerienName] = useState('')
const [staffel, setStaffel] = useState(1)
const [nurHauptfilm, setNurHauptfilm] = useState(false)
const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
const [scanFehler, setScanFehler] = useState('')
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
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
setScanStatus('running')
setScanFehler('')
try {
await api.post(`/devices/${deviceId}/scan-tracks`)
} catch (e: any) {
setScanStatus('error')
setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden')
}
}
useEffect(() => {
if (scanStatus !== 'running' || !deviceId) return
const interval = setInterval(async () => {
try {
const r = await api.get(`/devices/${deviceId}/tracks`)
if (r.data.status === 'done') {
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')
setScanStatus('error')
}
} catch { /* API kurz weg — weiter pollen */ }
}, 3000)
return () => clearInterval(interval)
}, [scanStatus, deviceId])
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('') const [customPath, setCustomPath] = useState('')
const { theme } = useDarkMode() // 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.
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
const [arbeitsDir, setArbeitsDir] = useState('')
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
const defaultTargets: TargetConfig[] = [ useEffect(() => {
{ id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true }, if (!isOpen) return
{ id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true }, setSelectedType(initialType || 'movies')
{ id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true }, setCustomPath('')
] setSerienName(discTitle || '')
setStaffel(1)
setScanStatus('idle')
setScanFehler('')
setTitelListe([])
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([]))
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
api.get('/capabilities').then(r => {
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
}).catch(() => setWorkers([]))
api.get('/settings').then(r => {
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 },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
])
}).catch(() => {})
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
// Normalfall gar nicht.
}, [isOpen])
const [targets, setTargets] = useState(defaultTargets) const laden = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
} catch {
setBrowseDirs([])
}
}
// 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 handleSave = () => {
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
if (selectedType === 'series' && serienName.trim()) {
optionen.series = serienName.trim()
optionen.season = Math.max(1, staffel || 1)
}
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
}
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) const target = targets.find(t => t.type === selectedType)
if (target) { if (customPath) {
onSave(target) onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
} else if (target) {
onSave(target, optionen)
} }
onClose() onClose()
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all"> <Modal
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> isOpen={isOpen}
{/* Header */} onClose={onClose}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> title="Rip-Ziel auswählen"
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2> maxWidth="lg"
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> >
Wohin sollen die gerippten Dateien gespeichert werden? <div className="space-y-5">
<p className="text-xs text-slate-500 dark:text-slate-400">
Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar.
</p> </p>
</div>
{/* Body */} {/* Schnellwahl */}
<div className="p-6 space-y-6">
{/* Type Selection */}
<div>
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Dateityp
</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{['movies', 'series', 'music'].map((type) => ( {targets.map((t) => {
const isSelected = selectedType === t.type && !customPath
return (
<button <button
key={type} key={t.type}
onClick={() => setSelectedType(type as TargetConfig['type'])} onClick={() => { setSelectedType(t.type); setCustomPath('') }}
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${ className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
selectedType === type isSelected
? theme === 'dark' ? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400' : 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark'
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`} }`}
> >
<span className="text-2xl"> {t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
{type === 'movies' && '🎥'} {t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
{type === 'series' && '📺'} {t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
{type === 'music' && '🎵'} <span className="text-sm">{t.name}</span>
</span>
<span className="text-sm font-medium capitalize">{type}</span>
</button> </button>
))} )
</div> })}
</div> </div>
{/* Path Display */} {/* Serien-Flow */}
<div> {selectedType === 'series' && (
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
Zielverzeichnis <div className="grid grid-cols-[1fr_90px] gap-3">
</label> <Input
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}> label="Serienname"
<span className="text-xl">📁</span> value={serienName}
<input onChange={e => setSerienName(e.target.value)}
type="text" placeholder="z. B. Neon Genesis Evangelion"
value={customPath || targets.find(t => t.type === selectedType)?.path || ''} />
onChange={(e) => setCustomPath(e.target.value)} <Input
className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`} label="Staffel"
placeholder="/path/to/target" type="number"
min={1}
value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
/> />
<button
onClick={() => {
const target = targets.find(t => t.type === selectedType)
if (target) {
setCustomPath(target.path)
}
}}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Standard
</button>
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
{customPath Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
}
</p> </p>
</div> </div>
)}
{/* Hauptfilm-Wahl */}
{selectedType === 'movies' && scanStatus !== 'done' && (
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-amber-500 rounded"
/>
Nur Hauptfilm rippen (längster Titel Extras/Trailer bleiben weg)
</label>
)}
{/*
Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026).
Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß
und lag bisher immer auf der Container-Platte — am 25.07. lief sie
damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur
Disc, nicht in eine globale Einstellung. Leer = der Wert aus
Einstellungen → Verarbeitung; genau der greift auch bei
Vollautomatik-Rips, weil dort niemand gefragt wird.
Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
*/}
{selectedType !== 'music' && (
<div>
<Select
label="Arbeitsverzeichnis für die Rohdaten"
value={arbeitsDir}
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard aus den Einstellungen
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
Bei 4K-UHD bis zu 100 GB nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
</p>
</div>
)}
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
{selectedType !== 'music' && workers.length >= 2 && (
<div>
<Select
label="Encoder / Worker für die Kompression"
value={encoderNode}
onChange={e => setEncoderNode(e.target.value)}
>
<option value="">Automatisch (erster freier Worker)</option>
{workers.map(w => (
<option key={w.node!} value={w.node!}>
{w.name} {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) sonst nimmt der erste freie Worker.
</p>
</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">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Disc scannen und einzelne Titel an-/abwählen.
</p>
)}
</div>
{scanStatus === 'running' ? (
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
Disc wird gelesen (20120 s)
</span>
) : (
<Button variant="secondary" size="sm" onClick={scanStarten}>
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</Button>
)}
</div> </div>
{/* Footer */} {scanStatus === 'error' && (
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}> <p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
<button )}
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`} {scanStatus === 'done' && titelListe.length > 0 && (
<>
<div className="max-h-44 overflow-y-auto">
{titelListe.map(t => (
<label
key={t.nr}
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
> >
Abbrechen <input
</button> type="checkbox"
<button checked={gewaehlt.has(t.nr)}
onClick={handleSave} onChange={e => {
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30" const neu = new Set(gewaehlt)
> if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
Speichern setGewaehlt(neu)
</button> }}
className="w-4 h-4 accent-amber-500 rounded"
/>
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && (
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
)}
<span className="ml-auto text-xs font-mono text-slate-400">
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span>
</label>
))}
</div> </div>
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
{gewaehlt.size} von {titelListe.length} Titeln gewählt
</p>
</>
)}
</div>
)}
{/* 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>
</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)}
disabled={!browseParent}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{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">
{zielPfad}
</span>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button variant="amber" onClick={handleSave}>
Rippen starten
</Button>
</div>
</div>
</Modal>
)
} }
+444
View File
@@ -0,0 +1,444 @@
import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
interface Mount {
name: string
type: string
source: string
mounted: boolean
reachable?: boolean
has_credentials: boolean
}
interface Target {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
export default function StorageMounts() {
const [mounts, setMounts] = useState<Mount[]>([])
const [targets, setTargets] = useState<Target[]>([])
const [name, setName] = useState('')
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
const [source, setSource] = useState('')
const [host, setHost] = useState('')
const [shares, setShares] = useState<string[]>([])
const [selectedShare, setSelectedShare] = useState('')
const [sharesBusy, setSharesBusy] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [browsePath, setBrowsePath] = useState('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false)
const { toast } = useToast()
const browsen = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
const ordnerAnlegen = async () => {
if (!neuerOrdner) return
try {
await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner })
setNeuerOrdner('')
browsen(browsePath)
laden()
} catch (e: any) {
setFeedback(`${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`)
}
}
const laden = async (manuell = false) => {
try {
const [m, t] = await Promise.allSettled([
api.get('/storage-mounts'),
api.get('/storage-targets'),
])
if (m.status === 'fulfilled') setMounts(m.value.data || [])
if (t.status === 'fulfilled') setTargets(t.value.data || [])
if (manuell) toast('success', 'Speicherziele aktualisiert')
} catch {
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
}
}
useEffect(() => { laden(); browsen('/app/media') }, [])
const freigabenAuflisten = async () => {
if (!host) return
setSharesBusy(true)
setFeedback(null)
try {
const r = await api.get('/storage-mounts/shares', {
params: { host, ...(username ? { username, password } : {}) },
})
setShares(r.data.shares)
if (r.data.shares.length === 0) {
setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?')
} else {
setSelectedShare(r.data.shares[0])
}
} catch (e: any) {
setFeedback(`${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`)
setShares([])
} finally {
setSharesBusy(false)
}
}
const hinzufuegen = async () => {
setBusy(true)
setFeedback(null)
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
const zielName = name
try {
const r = await api.post('/storage-mounts', {
name, type: typ, source: quelle,
...(username ? { username, password } : {}),
})
if (r.data.writable) {
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
toast('success', `${zielName}" eingehängt — Schreibtest OK`)
} else {
setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`)
toast('error', `${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`)
}
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
laden()
} catch (e: any) {
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
setFeedback(`${detail}`)
toast('error', `${zielName}": ${detail}`)
} finally {
setBusy(false)
}
}
const entfernen = async (mountName: string) => {
setBusy(true)
try {
await api.delete(`/storage-mounts/${mountName}`)
toast('success', `${mountName}" ausgehängt und entfernt`)
laden()
} catch (e: any) {
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
setFeedback(`${detail}`)
toast('error', detail)
} finally {
setBusy(false)
}
}
const reparieren = async (mountName: string) => {
setBusy(true)
try {
const r = await api.post(`/storage-mounts/${mountName}/repair`)
toast(r.data.writable ? 'success' : 'error',
r.data.writable ? `${mountName}" neu verbunden` : `${mountName}" verbunden, aber nur lesbar`)
laden()
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6">
{/* Vorhandene Ziele */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={20} className="text-amber-500" />
<span>Verfügbare Ziele</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
<RefreshCw size={16} />
</Button>
</CardHeader>
<CardContent>
{targets.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p>
) : (
<div className="space-y-2.5">
{targets.map(t => (
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span>
</div>
</div>
{mounts.some(m => m.name === t.name) && (
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
{mounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={20} className="text-amber-500" />
<span>Netzwerk-Mounts</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{mounts.map(m => {
const aktiv = m.mounted && m.reachable !== false
const tot = m.mounted && m.reachable === false
return (
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0">
{aktiv
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
: tot
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
<div className="min-w-0">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!aktiv && (
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
<Wrench size={14} /> Reparieren
</Button>
)}
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
<Trash2 size={16} />
</button>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Ordner-Verwaltung */}
<Card>
<button
onClick={() => setBrowserOffen(!browserOffen)}
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
>
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ordner-Verwaltung (optional)
</h3>
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder „doku".
</p>
</div>
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<input
value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner"
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
<button
onClick={ordnerAnlegen}
disabled={!neuerOrdner}
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Ordner anlegen"
>
<FolderPlus size={16} />
</button>
</div>
<div className="max-h-44 overflow-y-auto p-2">
{browseDirs.length === 0 && browseFiles.length === 0 ? (
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
) : (
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
{d.name}
</button>
))}
{browseFiles.map(f => (
<div
key={f.name}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</div>
))}
</>
)}
</div>
</div>
)}
</Card>
{/* Neues Netzwerk-Ziel */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus size={20} className="text-amber-500" />
<span>Netzwerk-Ziel hinzufügen</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Name (klein, ohne Leerzeichen)"
value={name}
onChange={e => setName(e.target.value.toLowerCase())}
placeholder="nas-filme"
/>
<Select
label="Typ"
value={typ}
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
>
<option value="nfs">NFS (NAS/Linux)</option>
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
</Select>
{typ === 'nfs' ? (
<div className="md:col-span-2">
<Input
label="Quelle (host:/export/pfad)"
value={source}
onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</div>
) : (
<>
<div className="md:col-span-2">
<Input
label="Rechner (dein PC oder NAS — Name oder IP)"
value={host}
onChange={e => setHost(e.target.value)}
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
/>
</div>
<Input
label="Benutzer"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
placeholder="Freigabe-Benutzer"
/>
<Input
label="Passwort"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben versucht Rippy einen Gast-Zugriff.
</p>
<div className="md:col-span-2">
<Button
variant="secondary"
size="sm"
onClick={freigabenAuflisten}
disabled={!host || sharesBusy}
>
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</Button>
</div>
{shares.length > 0 && (
<div className="md:col-span-2">
<Select
label="Freigabe wählen"
value={selectedShare}
onChange={e => setSelectedShare(e.target.value)}
>
{shares.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
</div>
)}
</>
)}
</div>
<Button
variant="amber"
onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
>
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</Button>
{feedback && (
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback}
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
import { api } from '../lib/api'
import { simdWarnung } from '../lib/encoder'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
interface WorkerInfo {
name: string
encoders: string[]
last_seen?: string
online?: boolean
info?: {
hostname?: string
ip?: string
makemkv?: string
handbrake?: string
cpu_modell?: string
cpu_kerne?: string
cpu_simd?: string
handbrake_encoder?: string
rippy_version?: string
}
}
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264',
'cpu-x265': 'CPU · H.265',
'cpu-av1': 'CPU · AV1 (SVT)',
'vaapi': 'GPU · VAAPI (AMD/Intel)',
'nvenc': 'GPU · NVENC (NVIDIA)',
}
function relativeZeit(iso?: string): string {
if (!iso) return 'nie'
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (sekunden < 90) return 'gerade eben'
if (sekunden < 3600) return `vor ${Math.floor(sekunden / 60)} min`
return `vor ${Math.floor(sekunden / 3600)} h`
}
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
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
// eintragen lassen als eine falsche Adresse vorgaukeln.
const [rippyHost, setRippyHost] = useState('')
const { toast } = useToast()
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
const siehtOeffentlichAus =
rippyHost !== '' &&
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') &&
rippyHost !== 'localhost' &&
rippyHost.includes('.')
const laden = (manuell = false) => {
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') })
}
useEffect(() => {
laden()
const interval = setInterval(laden, 15000)
return () => clearInterval(interval)
}, [])
const installBefehl = variante === 'linux'
? [
`git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
: [
// Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur
// ein Administrator schreiben, deshalb der Hinweis direkt im Befehl.
// Die .exe fragt selbst per UAC; hier muss die Konsole erhöht sein.
`# PowerShell als Administrator starten (Standard-Ziel: Programme)`,
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
`# Ohne Adminrechte: -InstallDir "$env:LOCALAPPDATA\\Rippy Worker" anhaengen`,
].join('\n')
const kopieren = () => {
navigator.clipboard.writeText(installBefehl).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2000)
})
}
// Fertige Installer-.exe (Rippy-Icon, kein Konsolenfenster) direkt laden.
// Generisch — die GUI fragt die Rippy-Adresse selbst ab.
const exeHerunterladen = () => {
const a = document.createElement('a')
a.href = '/api/worker-setup/windows-exe'
a.download = 'RippyWorkerSetup.exe'
document.body.appendChild(a); a.click(); a.remove()
toast('success', 'RippyWorkerSetup.exe wird heruntergeladen — doppelklicken zum Installieren')
}
return (
<div className="space-y-6">
{/* Verbundene Worker */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server size={20} className="text-amber-500" />
<span>Verbundene Worker</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
<RefreshCw size={16} />
</Button>
</CardHeader>
<CardContent>
{workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : (
<div className="space-y-2.5">
{workers.map(w => (
<div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0">
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
/>
<Server size={18} className="text-slate-400 dark:text-slate-500" />
<div className="min-w-0">
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{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` : ''}
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
</p>
)}
{simdWarnung(w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400 max-w-xl">
{simdWarnung(w.info?.cpu_simd)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu')
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
{!w.online && (
<button
onClick={() => {
api.delete(`/workers/${encodeURIComponent(w.name)}`)
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}}
title="Verwaisten Eintrag entfernen"
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Neue Maschine anbinden */}
<Card>
<CardHeader>
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-w-md">
<Input
label="LAN-IP der Rippy-Maschine (Docker-Host)"
value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())}
placeholder="z. B. 192.168.1.10"
/>
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft meist deine
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
an (Ports 6379/5432) das klappt nur mit der echten LAN-IP im Heimnetz.
</p>
{siehtOeffentlichAus && (
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die
<strong> LAN-IP</strong> der Rippy-Maschine eintragen die beginnt im Heimnetz
fast immer mit 192.168., 10. oder 172.16.172.31.
</p>
)}
</div>
<div className="flex gap-2">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<Button
key={wert}
variant={variante === wert ? 'amber' : 'secondary'}
size="sm"
onClick={() => setVariante(wert)}
>
{label}
</Button>
))}
</div>
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
{variante === 'windows' && (
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
<p className="text-sm text-slate-700 dark:text-slate-300">
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
im Fenster Adresse + Name eintragen, Installieren". Kein Konsolenfenster.
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
</p>
<Button variant="amber" onClick={exeHerunterladen}>
<Download size={16} /> Installer herunterladen (.exe)
</Button>
<p className="text-xs text-slate-700 dark:text-slate-300">
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
{variante === 'linux'
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
: 'Alternativ (für Profis) per PowerShell-Befehl:'}
</p>
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
{installBefehl}
<button
onClick={kopieren}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
title="Kopieren"
>
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, wo das Laufwerk hängt.
</p>
</CardContent>
</Card>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Disc } from 'lucide-react'
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
interface StatusBadgeProps {
status: string
className?: string
}
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: status,
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
{config.label}
</span>
)
}
interface TypeBadgeProps {
type: string
className?: string
}
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: type.toUpperCase(),
}
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
<Disc size={13} />
{config.label}
</span>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
className?: string
}
export function Button({
variant = 'primary',
size = 'md',
children,
className = '',
disabled,
...props
}: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
const variants = {
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
}
const sizes = {
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-5 py-2.5 text-base gap-2.5',
}
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
glow?: boolean
onClick?: () => void
}
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
return (
<div
onClick={onClick}
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
glow
? 'glass-panel-amber'
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
} text-slate-900 dark:text-slate-100 ${
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
} ${className}`}
>
{/* Top subtle highlight line for 3D glass depth */}
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
{children}
</div>
)
}
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
{children}
</div>
)
}
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
{children}
</h2>
)
}
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`p-6 ${className}`}>
{children}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
className?: string
}
export function Input({ label, error, className = '', ...props }: InputProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<input
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
error ? 'border-rose-500 ring-rose-500/20' : ''
} ${className}`}
{...props}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
</div>
)
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string
children: ReactNode
className?: string
}
export function Select({ label, children, className = '', ...props }: SelectProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<select
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
{...props}
>
{children}
</select>
</div>
)
}
interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
description?: string
disabled?: boolean
}
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
return (
<div className="flex items-center justify-between py-2">
{(label || description) && (
<div className="space-y-0.5">
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
</div>
)}
<button
type="button"
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
}
return () => {
document.body.style.overflow = 'unset'
window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
if (!isOpen) return null
const maxWidening = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'4xl': 'max-w-4xl',
}
// WICHTIG (Befund 25.07.2026): Der Dialog wird per Portal direkt an
// document.body gehängt statt dort zu bleiben, wo er im Baum steht.
// Grund: `.glass-panel` in index.css setzt `backdrop-filter: blur(16px)`,
// und ein Element mit backdrop-filter wird zum Bezugsrahmen für
// `position: fixed` seiner Nachfahren. Ein Modal INNERHALB einer Card war
// damit nicht mehr am Fenster ausgerichtet, sondern an der Card — es klebte
// im Panel und wurde am Rand abgeschnitten (gemeldet für „Rippen starten"
// im Laufwerke-Tab). Das Portal löst das für ALLE Dialoge auf einmal.
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
{/* Glass Backdrop */}
<div
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X size={18} />
</button>
</div>
)}
<div className="p-6">
{children}
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,19 @@
import { ReactNode } from 'react'
interface PageHeaderProps {
title: string
subtitle?: string
action?: ReactNode
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
</div>
{action && <div>{action}</div>}
</div>
)
}
@@ -0,0 +1,56 @@
interface RadialProgressProps {
progress: number
size?: number
strokeWidth?: number
label?: string
status?: string
}
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const strokeDashoffset = circumference - (progress / 100) * circumference
const isComplete = progress === 100
return (
<div className="relative inline-flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
{/* Background track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-slate-800 dark:text-slate-800/80 fill-none"
/>
{/* Progress track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="fill-none transition-all duration-500 ease-out"
/>
<defs>
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
</linearGradient>
</defs>
</svg>
<div className="absolute flex flex-col items-center justify-center text-center">
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
{progress}%
</span>
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
</div>
</div>
)
}
+5 -23
View File
@@ -1,43 +1,25 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react' import { createContext, useContext, useEffect, ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType { interface ThemeContextType {
theme: Theme theme: 'dark'
toggleTheme: () => void toggleTheme: () => void
} }
const ThemeContext = createContext<ThemeContextType | undefined>(undefined) const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
setTheme('dark')
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} else { localStorage.setItem('theme', 'dark')
setTheme('light')
document.documentElement.classList.remove('dark')
}
}, []) }, [])
const toggleTheme = () => { const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark' // 100% Dark Mode locked per Commander directive
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
if (newTheme === 'dark') {
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
} }
return ( return (
<ThemeContext.Provider value={{ theme, toggleTheme }}> <ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
) )
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useCallback, useContext, useRef, useState, ReactNode } from 'react'
import { CheckCircle, AlertCircle, Info } from 'lucide-react'
// Globales Feedback-System (Commander-Wunsch 24.07.): JEDE Aktualisierung im
// UI quittiert sichtbar — „Erfolgreich aktualisiert" ploppt oben rechts rein,
// statt dass man raten muss, ob der Klick etwas getan hat.
type ToastTyp = 'success' | 'error' | 'info'
interface Toast {
id: number
typ: ToastTyp
text: string
}
const ToastContext = createContext<{ toast: (typ: ToastTyp, text: string) => void }>({
toast: () => {},
})
export function useToast() {
return useContext(ToastContext)
}
const STIL: Record<ToastTyp, string> = {
success: 'bg-emerald-600 text-white',
error: 'bg-rose-600 text-white',
info: 'bg-slate-800 text-slate-100 dark:bg-slate-700',
}
const ICON: Record<ToastTyp, ReactNode> = {
success: <CheckCircle size={18} className="flex-shrink-0" />,
error: <AlertCircle size={18} className="flex-shrink-0" />,
info: <Info size={18} className="flex-shrink-0" />,
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const naechsteId = useRef(1)
const toast = useCallback((typ: ToastTyp, text: string) => {
const id = naechsteId.current++
setToasts(t => [...t.slice(-3), { id, typ, text }])
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000)
}, [])
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-80 pointer-events-none">
{toasts.map(t => (
<div
key={t.id}
className={`toast-rein flex items-center gap-2.5 px-4 py-3 rounded-lg shadow-lg text-sm font-medium ${STIL[t.typ]}`}
>
{ICON[t.typ]}
<span className="min-w-0">{t.text}</span>
</div>
))}
</div>
</ToastContext.Provider>
)
}
+75 -45
View File
@@ -2,52 +2,82 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root { @layer base {
--bg-color: #f8fafc;
--bg-secondary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #64748b;
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--border-color: #e2e8f0;
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #e2e8f0;
--success-color: #22c55e;
--error-color: #ef4444;
}
[data-theme="dark"] {
--bg-color: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--border-color: #334155;
--card-bg: #1e293b;
--input-bg: #0f172a;
--input-border: #334155;
}
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
body { body {
margin: 0; @apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', background-image:
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
sans-serif; radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
-webkit-font-smoothing: antialiased; background-attachment: fixed;
-moz-osx-font-smoothing: grayscale; }
background-color: var(--bg-color);
color: var(--text-primary);
} }
code { /* Toast Einflug */
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', @keyframes toast-rein {
monospace; from {
background-color: var(--bg-secondary); opacity: 0;
color: var(--text-primary); transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast-rein {
animation: toast-rein 0.25s ease-out;
}
/* Subtile Disc-Rotation für Cinematic Spotlight */
@keyframes disc-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-disc-spin {
animation: disc-spin 12s linear infinite;
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.animate-pulse-slow {
animation: pulse-slow 3s ease-in-out infinite;
}
/* Custom Glass & Shimmer Effects */
.glass-panel {
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-panel-amber {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
backdrop-filter: blur(16px);
border: 1px solid rgba(245, 158, 11, 0.25);
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
}
/* Metallic Disc Texture Gradient */
.disc-texture {
background: conic-gradient(
from 0deg,
#1e293b 0deg,
#475569 45deg,
#1e293b 90deg,
#64748b 135deg,
#1e293b 180deg,
#475569 225deg,
#1e293b 270deg,
#64748b 315deg,
#1e293b 360deg
);
} }
+6 -1
View File
@@ -4,4 +4,9 @@ import axios from 'axios'
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000 // den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach // hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
// Laufwerken und Jobs — deshalb blieb das UI immer leer. // 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 })
+96
View File
@@ -0,0 +1,96 @@
// Centralized Design System & Style Tokens for Rippy (Cinematic Cinema OS)
export const STATUS_STYLES = {
pending: {
badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
label: 'Wartend',
},
processing: {
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30 animate-pulse',
label: 'Rippen',
},
transcoding: {
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
label: 'Komprimieren',
},
canceling: {
badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/30',
label: 'Wird abgebrochen…',
},
completed: {
badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
label: 'Fertig',
},
failed: {
badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30',
label: 'Fehler',
},
} as const
export const DISC_TYPE_STYLES = {
cd: {
badge: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-indigo-500/30',
aura: 'from-indigo-500/20 via-indigo-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(99,102,241,0.2)]',
label: 'CD',
},
dvd: {
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
aura: 'from-purple-500/20 via-purple-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(168,85,247,0.2)]',
label: 'DVD',
},
bluray: {
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
aura: 'from-sky-500/20 via-sky-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(14,165,233,0.2)]',
label: 'BLU-RAY',
},
uhd: {
badge: 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40 font-semibold shadow-sm shadow-amber-500/20',
aura: 'from-amber-500/25 via-amber-900/15 to-transparent',
glow: 'shadow-[0_0_25px_-5px_rgba(245,158,11,0.25)]',
label: '4K UHD',
},
} as const
export const LOG_LEVEL_STYLES = {
INFO: {
color: 'text-sky-600 dark:text-sky-400',
bg: 'bg-sky-500/10',
border: 'border-sky-500/20',
},
WARNING: {
color: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-500/10',
border: 'border-amber-500/20',
},
ERROR: {
color: 'text-rose-600 dark:text-rose-400',
bg: 'bg-rose-500/10',
border: 'border-rose-500/20',
},
DEBUG: {
color: 'text-slate-500 dark:text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20',
},
} as const
// Encoder-Backends, wie worker/caps.py sie meldet. Hardware wird seit
// 25.07.2026 nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) — vorher landete
// eine AMD-Karte unter dem Intel-Namen „vaapi", und AV1 in Hardware war gar
// nicht sichtbar, obwohl es die beste Kombination aus Tempo und Größe ist.
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
'nvenc': { label: 'NVENC ⚡ (NVIDIA)', highlight: true },
'nvenc-av1': { label: 'AV1 · NVENC ⚡', highlight: true },
'qsv': { label: 'QuickSync ⚡ (Intel)', highlight: true },
'qsv-av1': { label: 'AV1 · QuickSync ⚡', highlight: true },
'vce': { label: 'VCE ⚡ (AMD)', highlight: true },
'vce-av1': { label: 'AV1 · VCE ⚡', highlight: true },
'vaapi': { label: 'VAAPI ⚡', highlight: true },
'vaapi-av1': { label: 'AV1 · VAAPI ⚡', highlight: true },
}
+219
View File
@@ -0,0 +1,219 @@
// Encoder-Fähigkeiten eines Workers in Klartext übersetzen.
//
// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell
// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2.
// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene
// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die
// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet.
// Stufen, mit denen Software-Encoding brauchbar schnell ist.
export const SIMD_SCHNELL = ['avx512f', 'avx2']
/**
* Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist.
* Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist —
* lieber nichts sagen als etwas Falsches behaupten.
*/
export function simdWarnung(simd?: string): string | null {
if (!simd || simd === 'unbekannt') return null
if (SIMD_SCHNELL.includes(simd)) return null
return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier `
+ 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den '
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
+ 'Hardware-Encoder wählen.'
}
// Reservierter Wert im Preset-Feld: „diesen Disc-Typ NICHT komprimieren".
// Muss mit ripping.PRESET_KEINE im Worker übereinstimmen — es gibt kein
// geteiltes Paket zwischen UI und Worker, deshalb steht der Wert hier nochmal.
export const PRESET_KEINE = 'keine'
interface WorkerFuerBewertung {
encoders?: string[]
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
* eine bekannte SIMD-Stufe, und keiner erreicht AVX2 — und keiner hat einen
* Hardware-Encoder, der die Frage sowieso erledigt.
*
* Bei unbekannter Stufe (z. B. Windows-Worker, dort gibt es kein
* /proc/cpuinfo) wird NICHT gewarnt. Lieber schweigen als falsch warnen.
*/
export function schwacheEncoderCpu(workers?: WorkerFuerBewertung[]): boolean {
const liste = workers || []
if (liste.length === 0) return false
const hardware = liste.some(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
if (hardware) return false
const bekannte = liste
.map(w => w.info?.cpu_simd)
.filter((s): s is string => !!s && s !== 'unbekannt')
if (bekannte.length === 0) return false
return !bekannte.some(s => SIMD_SCHNELL.includes(s))
}
+16
View File
@@ -0,0 +1,16 @@
// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die
// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der
// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung).
export interface MediaServerOption {
wert: string
label: string
hinweis: string
}
export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [
{ wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
{ wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
{ wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
{ wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' },
{ wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' },
]
+3
View File
@@ -2,12 +2,15 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { ThemeProvider } from './context/ThemeContext' import { ThemeProvider } from './context/ThemeContext'
import { ToastProvider } from './context/ToastContext'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<ToastProvider>
<App /> <App />
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
) )
+205
View File
@@ -0,0 +1,205 @@
import { ReactNode } from 'react'
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<Icon size={20} className="text-amber-500" />
<span>{titel}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</CardContent>
</Card>
)
}
export default function AnleitungPage() {
const fett = "font-semibold text-slate-100"
return (
<div className="space-y-6 max-w-6xl mx-auto">
<PageHeader
title="Anleitung & Hilfe"
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
/>
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
<p>
<span className={fett}>1. Disc einlegen.</span> Rippy erkennt sie automatisch (Titel, Poster,
Beschreibung) und zeigt sie oben im Dashboard. Stimmt der Titel nicht Nicht korrekt?"
klicken und den richtigen wählen — Rippy merkt sich das für diese Disc.
</p>
<p>
<span className={fett}>2. „Rippen starten"</span> und das Ziel wählen: 🎥 Filme, 📺 Serien,
🎵 Musik oder ein eigener Ordner (auch NAS-Freigaben). Optional: Nur Hauptfilm" (längster
Titel) oder per <span className={fett}>„Disc scannen"</span> einzelne Titel an-/abwählen.
</p>
<p>
<span className={fett}>3. Warten.</span> Rippy sichert verlustfrei (MakeMKV) und komprimiert
danach auf Arbeitsgröße (HandBrake) alle Tonspuren und Untertitel bleiben erhalten.
Fertige Jobs haben einen Download-Knopf; Klick auf den Titel zeigt alle Details.
</p>
<p>
<span className={fett}>Vollautomatik:</span> Unter Einstellungen Ripping einschaltbar
dann startet der Rip ohne Nachfrage, sobald eine Disc erkannt wurde. Bereits gerippte
Discs (Fingerabdruck) werden übersprungen.
</p>
</Abschnitt>
<Abschnitt icon={Tv} titel="Serien rippen">
<p>
Im Rippen starten"-Dialog <span className={fett}>„Serien"</span> wählen, Serienname und
Staffel angeben. Die Ablage wird automatisch <code>Serie/Season 02/</code>, und nach dem
Rip ordnet Rippy die Episoden über einen Laufzeit-Abgleich mit TMDB zu und benennt sie
(Serie S02E03.mkv"). Das passiert nur bei <span className={fett}>eindeutiger</span> Zuordnung —
im Zweifel bleiben die Original-Namen und das Log sagt warum.
</p>
<p>
Eine komplette Staffel auf einer Disc klappt auch, wenn alle Episoden gleich lang sind
(Anime-Boxen). Bei Teil-Discs mit lauter gleich langen Episoden ist eine automatische
Zuordnung prinzipiell nicht möglich — dann von Hand umbenennen.
</p>
</Abschnitt>
<Abschnitt icon={HardDrive} titel="NAS &amp; Speicherziele">
<p>
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
auflisten", Freigabe wählen, Einhängen &amp; speichern". Danach taucht das Ziel im
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
</p>
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
Einstellungen Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
jedem Rip und bricht sonst mit Klartext ab.
</p>
</Abschnitt>
<Abschnitt icon={Tv} titel="Jellyfin, Emby, Kodi, Plex">
<p>
Einstellungen Ripping Media-Server: Rippy benennt fertige Rips als Titel (Jahr)" und
legt für Jellyfin/Emby/Kodi zusätzlich <code>movie.nfo</code> + <code>poster.jpg</code> dazu —
dein Server erkennt alles ohne Nacharbeit (Plex nutzt nur die Benennung).
</p>
<p>
<span className={fett}>Null-Klick-Kette:</span> Bei Jellyfin/Emby zusätzlich Server-URL und
API-Key eintragen (Administration → API-Schlüssel) — dann stößt Rippy nach jedem fertigen
Rip sofort einen Bibliotheks-Scan an. Disc rein → Film erscheint im Server.
</p>
</Abschnitt>
<Abschnitt icon={Bell} titel="Benachrichtigungen">
<p>
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen (Discord-Webhook, ntfy-Thema
für Handy-Push, Slack oder eigenes JSON), „Test senden", speichern. Rippy meldet sich, wenn
ein Job fertig ist, fehlschlägt oder abgebrochen wird. Die Schritt-für-Schritt-Anleitung je
Dienst steht direkt auf der Einstellungs-Seite.
</p>
</Abschnitt>
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker.
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
</p>
<p>
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
(Status, Start/Stopp, „Rippy öffnen", Log) gestartet über <code>start-tray.bat</code>.
Deinstallieren: <code>uninstall.ps1</code> im Worker-Ordner stoppt alles, entfernt den
Autostart, meldet den Worker in Rippy ab und löscht den Ordner rückstandsfrei.
</p>
<p>
<span className={fett}>Wichtig:</span> Worker verbinden sich direkt im Heimnetz in den
Befehlen immer die <span className={fett}>LAN-Adresse</span> der Rippy-Maschine verwenden,
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
</p>
</Abschnitt>
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System">
<p>
Blu-ray-Ripping braucht den MakeMKV-Beta-Key, der etwa <span className={fett}>monatlich
wechselt</span> (MakeMKV-Forum, Thread MakeMKV is free while in beta"). Neuen Key unter
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
</p>
{/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
<p>
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
Update-Befehl an neue Versionen bringen bessere Laufwerks-Unterstützung und
Fehlerbehebungen. <span className={fett}>Disc-Schlüssel für 4K-UHD kommen dagegen nicht
aus einem Update</span> die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
nie. Wie du sie trotzdem bekommst, steht unter Häufige Fragen".
</p>
</Abschnitt>
<Abschnitt icon={Download} titel="Fertige Dateien herunterladen">
<p>
Bei fertigen Jobs: „Download" in der Aktion-Spalte (oder Klick auf den Titel) Dateiliste
mit Alle herunterladen". Die Job-Liste lässt sich per Papierkorb bzw. „Erledigte aufräumen"
leeren <span className={fett}>die gerippten Dateien bleiben dabei immer liegen</span>.
CSV" exportiert die komplette Historie für Excel.
</p>
</Abschnitt>
<Abschnitt icon={HelpCircle} titel="Häufige Fragen">
<p>
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
Fingerabdruck. Nochmal rippen geht trotzdem der Hinweis verhindert nur Versehen.
</p>
{/*
25.07.2026 auf BEIDEN Maschinen nachgemessen und erneut korrigiert. Es
stand hier nacheinander zweierlei Falsches: erst "ein MakeMKV-Update macht
die Disc rippbar", dann "MakeMKVs Schluessel-Kanal ist tot". Richtig ist:
makemkvcon unter Linux ruft die Schluessel nie ab, die Windows-Version
schon (Meldung 3338, Verbindung nach 185.84.108.20:443).
*/}
<p>
<span className={fett}>4K-UHD schlägt fehl mit volume key is unknown"?</span> Das Laufwerk
liest die Disc einwandfrei (LibreDrive) — MakeMKV fehlt nur der Schlüssel dieser Pressung.
Der Grund liegt nicht bei dir und nicht bei Rippy: <span className={fett}>die Linux-Version
von MakeMKV holt Disc-Schlüssel nie selbst aus dem Netz</span>. Die Windows-Version tut es.
Ein MakeMKV-Update ändert daran nichts.
</p>
<p>
<span className={fett}>Der Weg drumherum:</span> MakeMKV einmalig auf einem Windows-PC
installieren (gleicher Beta-Key), das Laufwerk dort anstecken, die Disc öffnen — MakeMKV
lädt die Schlüssel dabei nach. Dann in MakeMKV unter <em>Preferences → General</em> das
„MakeMKV data directory" nachschlagen und die Datei <code>_private_data.tar</code> daraus
bei Rippy unter Einstellungen System hochladen. Wirkt ab dem nächsten Rip. Für neue
Discs gelegentlich wiederholen der Block dort zeigt dir, wie viele Schlüssel Rippy kennt.
</p>
<p>
Geht eine Pressung auch damit nicht auf, kennt MakeMKV sie selbst nicht. Dann bleiben zwei
Dinge: eine <code>KEYDB.cfg</code> (ebenfalls dort hochladbar, der Notnagel), oder den
<span className={fett}> AACS-Dump</span> im MakeMKV-Forum im Bereich Ultra HD Blu-ray"
einreichen — der bleibt jetzt erhalten und steht unter Einstellungen → System zum
Herunterladen. <span className={fett}>Rippy liefert keine Schlüssel mit und lädt keine
herunter</span> — es verwaltet nur, was du selbst mitbringst.
</p>
<p>
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
erscheint nur, wenn die Rohdaten noch daliegen bei einem Job, der nie gerippt hat, gäbe
es nichts zu komprimieren.
</p>
<p>
<span className={fett}>Kein Laufwerk sichtbar?</span> In einer VM muss das Laufwerk per
USB-Passthrough durchgereicht sein (nicht als emuliertes CD-ROM) Details in der README.
</p>
</Abschnitt>
</div>
)
}
File diff suppressed because it is too large Load Diff
+126 -95
View File
@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
interface LogEntry { interface LogEntry {
id: string id: string
@@ -16,18 +19,21 @@ export default function LogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
const [filter, setFilter] = useState<string>('all') const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { theme } = useDarkMode() const [refreshing, setRefreshing] = useState(false)
const { toast } = useToast()
const refreshLogs = async () => { const refreshLogs = async (manuell = false) => {
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix if (manuell) setRefreshing(true)
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
try { try {
const response = await api.get('/logs', { params: { limit: 200 } }) const response = await api.get('/logs', { params: { limit: 200 } })
setLogs(response.data) setLogs(response.data)
if (manuell) toast('success', 'Logs aktualisiert')
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Logs:', error) console.error('Fehler beim Laden der Logs:', error)
if (manuell) toast('error', 'Logs konnten nicht geladen werden')
} finally { } finally {
setLoading(false) setLoading(false)
if (manuell) setRefreshing(false)
} }
} }
@@ -39,19 +45,19 @@ export default function LogsPage() {
const getLevelColor = (level: string) => { const getLevelColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20' case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20' case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20' case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20' default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
} }
} }
const getLevelIcon = (level: string) => { const getLevelIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
@@ -68,6 +74,24 @@ export default function LogsPage() {
? logs ? logs
: logs.filter(log => log.level === filter || log.source === filter) : 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 = { const stats = {
info: logs.filter(l => l.level === 'info').length, info: logs.filter(l => l.level === 'info').length,
warning: logs.filter(l => l.level === 'warning').length, warning: logs.filter(l => l.level === 'warning').length,
@@ -76,133 +100,140 @@ export default function LogsPage() {
} }
return ( return (
<div className="space-y-6 transition-colors duration-300"> <div className="space-y-6 max-w-5xl mx-auto">
{/* Header */} <PageHeader
<div className="flex flex-col gap-2 transition-colors duration-300"> title="System-Logs"
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1> subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p> action={
</div> <Button variant="secondary" onClick={() => refreshLogs(true)}>
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
Neu laden
</Button>
}
/>
{/* Stats */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card>
<div className="flex items-center gap-3 mb-2"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500"> <div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
<Terminal size={18} /> <Terminal size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
<CheckCircle size={18} /> <CheckCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
</div> </div>
</CardContent>
</Card>
</div> </div>
{/* Filters */} {/* Filters & Log List */}
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className="flex flex-wrap items-center gap-3"> <div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span> <span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
{['all', 'info', 'success', 'warning', 'error'].map(f => (
<button <button
onClick={() => setFilter('all')} key={f}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ onClick={() => setFilter(f)}
filter === 'all' className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
? 'bg-indigo-600 text-white' filter === f
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' ? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-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'
}`} }`}
> >
Alle {f === 'all' ? 'Alle' : getLevelLabel(f)}
</button> </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 <button
onClick={() => setFilter('info')} key={q}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ onClick={() => setFilter(q)}
filter === 'info' className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
? 'bg-blue-600 text-white' filter === q
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' ? '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'
}`} }`}
> >
Info {quelleLabel(q)}
</button> </button>
<button ))}
onClick={() => setFilter('success')} </>
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ )}
filter === 'success'
? 'bg-emerald-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Erfolg
</button>
<div className="flex-1" />
<button
onClick={refreshLogs}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
Neu laden
</button>
</div>
</div> </div>
{/* Logs List */}
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="max-h-[600px] overflow-y-auto"> <div className="max-h-[600px] overflow-y-auto">
{filteredLogs.length === 0 ? ( {filteredLogs.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300"> <div className="text-center py-16">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700">
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} /> <Terminal size={32} />
</div> </div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p> <p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-slate-200 dark:divide-slate-700"> <div className="divide-y divide-slate-200/80 dark:divide-slate-800">
{filteredLogs.map(log => ( {filteredLogs.map(log => (
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}> <div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
{getLevelIcon(log.level)} {getLevelIcon(log.level)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
{log.source} {log.source}
</span> </span>
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <span className="text-xs text-slate-400 font-mono">
{log.timestamp} {log.timestamp}
</span> </span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}> <span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
{getLevelLabel(log.level)} {getLevelLabel(log.level)}
</span> </span>
</div> </div>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{log.message} {log.message}
</p> </p>
{log.details && ( {log.details && (
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}> <p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
{log.details} {log.details}
</p> </p>
)} )}
@@ -213,7 +244,7 @@ export default function LogsPage() {
</div> </div>
)} )}
</div> </div>
</div> </Card>
</div> </div>
) )
} }
-303
View File
@@ -1,303 +0,0 @@
import { useState, useEffect } from 'react'
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
interface DeviceOption {
path: string
name: string
status: string
}
interface Metadata {
type: 'movie' | 'tv' | 'music' | 'unknown'
id: number
title: string
year?: number
overview?: string
poster_path?: string
backdrop_path?: string
runtime?: number
genres?: string[]
}
interface Track {
track_number: number
title: string
duration?: number
}
interface PrescanResult {
disc_type: 'cd' | 'dvd' | 'bluray'
title: string
year?: number
confidence: number
metadata?: Metadata
tracks?: Track[]
}
export default function MetadataPreview() {
const [device, setDevice] = useState<string>('')
const [devices, setDevices] = useState<DeviceOption[]>([])
const [result, setResult] = useState<PrescanResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false)
const { theme } = useDarkMode()
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
useEffect(() => {
const loadDevices = async () => {
try {
const response = await api.get('/devices')
setDevices(response.data)
if (response.data.length === 1) {
setDevice(response.data[0].path)
}
} catch {
setDevices([])
}
}
loadDevices()
const interval = setInterval(loadDevices, 5000)
return () => clearInterval(interval)
}, [])
const handleScan = async () => {
if (!device) {
setError('Bitte wählen Sie ein Gerät aus.')
return
}
setLoading(true)
setError(null)
try {
const response = await api.post('/prescan', { device_path: device })
setResult(response.data)
} catch (err) {
setError('Pre-Scan fehlgeschlagen.')
} finally {
setLoading(false)
}
}
const handleConfirm = async () => {
if (!result) return
setConfirming(true)
try {
await api.post('/metadata/confirm', {
title: result.title,
year: result.year,
metadata: result.metadata
})
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
await api.post('/jobs', {
device_path: device,
title: result.title
})
} catch (err) {
setError('Bestätigung fehlgeschlagen.')
} finally {
setConfirming(false)
}
}
const getIcon = (type: string): LucideIcon => {
switch (type) {
case 'cd':
return Music
case 'dvd':
case 'bluray':
return Film
default:
return BookOpen
}
}
const getConfidenceColor = (confidence: number): string => {
if (confidence >= 0.9) return theme === 'dark' ? 'text-green-400 bg-green-900/30' : 'text-green-600 bg-green-100'
if (confidence >= 0.7) return theme === 'dark' ? 'text-yellow-400 bg-yellow-900/30' : 'text-yellow-600 bg-yellow-100'
return theme === 'dark' ? 'text-red-400 bg-red-900/30' : 'text-red-600 bg-red-100'
}
return (
<div className="p-6 max-w-4xl mx-auto transition-colors duration-300">
<div className="mb-8 transition-colors duration-300">
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Metadaten-Preview</h1>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
</p>
</div>
{/* Device Selection */}
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
<div className="flex gap-4 transition-colors duration-300">
<select
value={device}
onChange={(e) => setDevice(e.target.value)}
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
>
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
</option>
{devices.map((d) => (
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
</option>
))}
</select>
<button
onClick={handleScan}
disabled={!device || loading}
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white transition-colors ${
!device || loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{loading ? (
<>
<Loader2 className="animate-spin" size={20} />
Scannen...
</>
) : (
<>
<Search size={20} />
Scan starten
</>
)}
</button>
</div>
</div>
{error && (
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 transition-colors duration-300 ${theme === 'dark' ? 'bg-red-900/30 border border-red-700 text-red-400' : 'bg-red-50 border border-red-200 text-red-700'}`}>
<AlertCircle size={24} />
<span>{error}</span>
</div>
)}
{/* Results */}
{result && (
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
<div className="flex items-center gap-3 mb-2">
{getIcon(result.disc_type)({ size: 32 })}
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
{result.disc_type.toUpperCase()}
</span>
</div>
<h2 className="text-3xl font-bold">{result.title}</h2>
{result.year && (
<p className="text-lg opacity-90 mt-1">{result.year}</p>
)}
</div>
{/* Confidence Score */}
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-b border-gray-200'}`}>
<div className="flex items-center gap-2 transition-colors duration-300">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>Übereinstimmung:</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${getConfidenceColor(result.confidence)}`}>
{Math.round(result.confidence * 100)}%
</span>
</div>
</div>
{/* Metadata */}
<div className="p-6 transition-colors duration-300">
{result.metadata && (
<div className="space-y-4 transition-colors duration-300">
{result.metadata.overview && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Beschreibung</h3>
<p className={`leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{result.metadata.overview}</p>
</div>
)}
{result.metadata.genres && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
<div className="flex flex-wrap gap-2 transition-colors duration-300">
{result.metadata.genres.map((genre, index) => (
<span
key={index}
className={`px-3 py-1 rounded-full text-sm transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-700'}`}
>
{genre}
</span>
))}
</div>
</div>
)}
{result.metadata.runtime && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
</div>
)}
</div>
)}
{/* Tracks */}
{result.tracks && result.tracks.length > 0 && (
<div className="mt-6 transition-colors duration-300">
<h3 className={`text-sm font-medium uppercase mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Tracks</h3>
<div className="space-y-2 transition-colors duration-300">
{result.tracks.map((track, index) => (
<div key={index} className="flex items-center justify-between p-3 transition-colors duration-300">
<div className="flex items-center gap-3 transition-colors duration-300">
<span className={`w-8 font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>{track.track_number}</span>
<span className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-gray-900'}`}>{track.title}</span>
</div>
{track.duration && (
<span className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Action */}
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 border-t border-slate-700' : 'bg-gray-50 border-t border-gray-200'}`}>
<div className="flex items-center justify-between transition-colors duration-300">
<div className="flex items-center gap-3 text-sm transition-colors duration-300">
<CheckCircle className={theme === 'dark' ? 'text-green-400' : 'text-green-600'} size={20} />
<span className={theme === 'dark' ? 'text-slate-300' : 'text-gray-600'}>Bereit zum Ripping</span>
</div>
<button
onClick={handleConfirm}
disabled={confirming}
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
{confirming ? (
<>
<Loader2 className="animate-spin" size={20} />
Bestätigen...
</>
) : (
<>
<CheckCircle size={20} />
Rippen starten
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+84 -5
View File
@@ -12,7 +12,39 @@
FROM python:3.12-slim-bookworm AS makemkv-build FROM python:3.12-slim-bookworm AS makemkv-build
# Zurück auf die AKTUELLE Version (24.07.2026): 1.17.7 war wegen des
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt).
# Richtigstellung 25.07.2026: Hier stand, die aktuelle Version zähle, weil sie
# "die neueste AACS-Schlüssel-Datenbank mitbringt". Das ist widerlegt — MakeMKV
# bringt gar keine Disc-Schlüssel mit, und der Online-Kanal liefert nichts mehr
# (Messungen im Modul-Kopf von makemkv_daten.py). Aktuell bleiben lohnt sich
# trotzdem: Laufwerks-Unterstützung und Fehlerbehebungen. Schlüssel für neue
# UHD-Pressungen kommen ausschließlich aus der KEYDB.cfg im Datenverzeichnis.
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
# konsequent HTTP-Fehler), Tarballs von Hand laden und lokal servieren:
# mkdir ~/makemkv-mirror && curl -o … && docker run -d --name makemkv-mirror \
# -p 8099:80 -v ~/makemkv-mirror:/usr/share/nginx/html:ro nginx:alpine
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
ARG MAKEMKV_VERSION=1.18.4 ARG MAKEMKV_VERSION=1.18.4
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
# Zweite Quelle, die bei einem Fehlschlag der ersten AUTOMATISCH probiert wird.
# Absichtlich LEER: Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern —
# https://www.makemkv.com/download HTTP 200 (der Standard oben)
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
# web.archive.org-Schnappschuss HTTP 404
# Es gibt derzeit also keine belegbare zweite Quelle. Eine hier einzutragen, die
# nicht liefert, wäre schlimmer als keine: der Build scheitert dann später und
# die Konfiguration sieht trotzdem gesund aus — genau das war auf der Rippy-VM
# der Fall (dort stand die 404-Adresse, und nur die vendor-Tarballs retteten
# jeden Build, ohne dass es auffiel).
# Wer eine eigene Quelle hat (Spiegel im LAN, eigener Webserver), traegt sie ein:
# docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker
ARG MAKEMKV_URL_FALLBACK=
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
@@ -27,10 +59,47 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /build WORKDIR /build
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \ # Download mit DREIFACHEM Netz (Cloudflare drosselt BuildKit-Downloads
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \ # hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
&& tar xzf oss.tar.gz \ # 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
&& tar xzf bin.tar.gz # SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
# 3. Scheitert das, wird MAKEMKV_URL_FALLBACK versucht, falls gesetzt.
# Und wenn alles scheitert, sagt die Meldung, welche Wege es gibt — statt nur
# einen curl-Rückgabewert zu hinterlassen.
COPY docker/worker/vendor/ ./vendor/
RUN set -e; \
hole() { \
ziel="$1"; datei="$2"; \
if curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
-o "$ziel" "${MAKEMKV_URL_BASE}/${datei}"; then \
return 0; \
fi; \
if [ -n "${MAKEMKV_URL_FALLBACK}" ]; then \
echo "Erste Quelle lieferte nicht — versuche MAKEMKV_URL_FALLBACK"; \
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
-o "$ziel" "${MAKEMKV_URL_FALLBACK}/${datei}" && return 0; \
fi; \
echo "FEHLER: ${datei} war von keiner Quelle zu holen." >&2; \
echo " Geprueft: ${MAKEMKV_URL_BASE}${MAKEMKV_URL_FALLBACK:+ und ${MAKEMKV_URL_FALLBACK}}" >&2; \
echo " Sicherster Weg: Tarballs von makemkv.com/download herunterladen" >&2; \
echo " und nach docker/worker/vendor/ legen, dann erneut bauen." >&2; \
echo " Alternativ eine eigene Quelle angeben:" >&2; \
echo " docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker" >&2; \
return 1; \
}; \
if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
echo "Nutze lokale Tarballs aus vendor/"; \
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
else \
hole oss.tar.gz "makemkv-oss-${MAKEMKV_VERSION}.tar.gz"; \
hole bin.tar.gz "makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
fi; \
sha256sum oss.tar.gz bin.tar.gz; \
tar xzf oss.tar.gz; \
tar xzf bin.tar.gz
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \ RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
&& ./configure --disable-gui --prefix=/usr/local \ && ./configure --disable-gui --prefix=/usr/local \
@@ -50,6 +119,8 @@ RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip
# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \ libssl3 \
libexpat1 \ libexpat1 \
@@ -59,11 +130,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cdparanoia \ cdparanoia \
cd-discid \ cd-discid \
flac \ flac \
handbrake-cli \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=makemkv-build /usr/local /usr/local COPY --from=makemkv-build /usr/local /usr/local
RUN ldconfig RUN ldconfig
# Version fürs UI sichtbar machen (Einstellungen → System): makemkvcon hat
# keinen --version-Schalter, also kommt die Wahrheit aus dem Build selbst.
ARG MAKEMKV_VERSION=1.18.4
ENV MAKEMKV_VERSION=${MAKEMKV_VERSION}
WORKDIR /app WORKDIR /app
COPY docker/worker/requirements.txt . COPY docker/worker/requirements.txt .
@@ -73,4 +150,6 @@ COPY docker/worker/ .
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"] # Der lokale Worker bedient BEIDE Queues (rip + transcode). Ein optionaler
# Remote-GPU-Worker startet dasselbe Image nur mit "-Q transcode".
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "celery,transcode"]
+416
View File
@@ -0,0 +1,416 @@
"""Encoder-Fähigkeiten dieses Workers — ehrlich erkannt, nicht behauptet.
Jeder Worker meldet beim Start, was auf SEINER Maschine wirklich verfügbar
ist (Commander-Anforderung 23.07.: das UI zeigt an, WAS da ist). Ein
Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
"""
import os
import platform
import re
import shutil
import subprocess
from winlauf import OHNE_FENSTER
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
# Reihenfolge = Anzeige-Reihenfolge im UI.
SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2")
def parse_encoder_liste(hilfe_text: str) -> list:
"""Video-Encoder, die DIESES HandBrake wirklich anbietet.
Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder <string>`
(AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten):
-e, --encoder <string> Select video encoder:
svt_av1
x264
x265
...
--encoder-preset <string>
Der Block endet an der nächsten Option — also an der ersten Zeile, deren
erstes nicht-leeres Zeichen ein Bindestrich ist.
"""
encoder = []
im_block = False
for zeile in (hilfe_text or "").splitlines():
inhalt = zeile.strip()
if not im_block:
if HB_ENCODER_KOPF.match(inhalt):
im_block = True
continue
if not inhalt:
continue
if inhalt.startswith("-"):
break
encoder.append(inhalt.split()[0])
return encoder
# Hardware-Encoder-Familien, wie HandBrake sie benennt → Backend-Kennung.
# Reihenfolge = Anzeige-Reihenfolge; AV1 zuerst, weil es das modernste ist.
HW_FAMILIEN = (
("nvenc", "nvenc"), # NVIDIA
("qsv", "qsv"), # Intel QuickSync
("vce", "vce"), # AMD VCE/VCN
("vaapi", "vaapi"), # generisch (Linux)
)
def leite_backends_ab(hb_encoder) -> list:
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig
Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri`
für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch:
1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT)
meldete deshalb nur CPU-Encoder, obwohl HandBrakes Windows-Build
`vce_*`, `nvenc_*` und `qsv_*` beherrscht. Ein Bug, der genau den
Anwendungsfall traf, für den externe Worker gedacht sind.
2. **Die Prüfung war überflüssig.** HandBrake probiert Hardware-Encoder
beim Start selbst an und listet nur, was es wirklich benutzen kann. Auf
der Rippy-VM belegt: `--version` meldet „qsv: not available on this
system" und „Cannot load libnvidia-encode.so.1" — und genau diese
Encoder fehlen dann in `--help`. Wer die Liste hat, hat die Antwort.
Bleibt die eine Regel, die weiter gilt: **ohne HandBrake gibt es keine
Encoder.** Vorher standen `cpu-x264`/`cpu-x265` fest verdrahtet drin, und
ein reiner Rip-Worker behauptete damit, komprimieren zu können.
Hardware wird jetzt nach Familie unterschieden (nvenc/qsv/vce/vaapi) statt
alles in „vaapi" zu werfen — sonst kann das UI nicht sagen, welches Preset
passt, und AMD-Karten liefen unter einem Intel-Namen.
"""
namen = {e.lower() for e in (hb_encoder or [])}
gefunden = []
if any(n.startswith("x264") for n in namen):
gefunden.append("cpu-x264")
if any(n.startswith("x265") for n in namen):
gefunden.append("cpu-x265")
if any(n.startswith("svt_av1") for n in namen):
gefunden.append("cpu-av1")
for kennzeichen, backend in HW_FAMILIEN:
treffer = [n for n in namen if kennzeichen in n]
if not treffer:
continue
gefunden.append(backend)
# AV1 in Hardware ist die derzeit beste Kombination aus Tempo und
# Dateigröße — das muss sichtbar sein, nicht in „nvenc" verschwinden.
if any("av1" in n for n in treffer):
gefunden.append(f"{backend}-av1")
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.
Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell
(„QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2.
x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene
28-55 Stunden. Sichtbar war das nirgends — genau diese Lücke schließt die
Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen.
"""
vorhanden = set((flags_text or "").split())
for stufe in SIMD_STUFEN:
if stufe in vorhanden:
return stufe
return "unbekannt"
def _lies_cpuinfo() -> tuple:
"""(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows."""
modell, flags, kerne = "", "", 0
try:
with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f:
for zeile in f:
if zeile.startswith("processor"):
kerne += 1
elif not modell and zeile.startswith("model name"):
modell = zeile.split(":", 1)[-1].strip()
elif not flags and zeile.startswith("flags"):
flags = zeile.split(":", 1)[-1].strip()
except OSError:
pass
return modell, flags, kerne
# Merkmals-Nummern für IsProcessorFeaturePresent (Win32, winnt.h).
# Absteigend nach Stärke — der erste Treffer ist die höchste Stufe.
WIN_MERKMALE = (
("avx512f", 41), # PF_AVX512F_INSTRUCTIONS_AVAILABLE
("avx2", 40), # PF_AVX2_INSTRUCTIONS_AVAILABLE
("avx", 39), # PF_AVX_INSTRUCTIONS_AVAILABLE
("sse4_2", 38), # PF_SSE4_2_INSTRUCTIONS_AVAILABLE
)
def _simd_windows() -> str:
"""Höchste Vektor-Befehlsstufe unter Windows.
Es gibt dort kein /proc/cpuinfo, deshalb meldete der Windows-Worker bis
25.07.2026 „Vektorbefehle unbekannt" — gerade auf der Maschine, die das
Encodieren übernehmen soll, also genau da, wo die Angabe zählt.
Gefragt wird `IsProcessorFeaturePresent` aus kernel32 (Win32-API, in
winnt.h dokumentiert). Kein Zusatzpaket, kein Registry-Gefummel, keine
Fremdprozesse.
"""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
except (ImportError, AttributeError, OSError):
return "unbekannt"
for name, nummer in WIN_MERKMALE:
try:
if kernel32.IsProcessorFeaturePresent(nummer):
return name
except OSError:
return "unbekannt"
return "unbekannt"
def _modell_windows() -> str:
"""Lesbarer CPU-Name unter Windows.
platform.processor() liefert dort nur „AMD64 Family 26 Model 68 …" — die
Registry kennt den Namen, den auch der Aufkleber trägt.
"""
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
) as schluessel:
name, _ = winreg.QueryValueEx(schluessel, "ProcessorNameString")
return " ".join(str(name).split())
except (ImportError, OSError):
return ""
def cpu_merkmale() -> tuple:
"""(Modell, SIMD-Stufe, Kernzahl) — plattformübergreifend, nie leer."""
modell, flags, kerne = _lies_cpuinfo()
simd = beste_simd(flags)
if simd == "unbekannt" and platform.system() == "Windows":
simd = _simd_windows()
if not modell:
modell = _modell_windows() or platform.processor() or "unbekannt"
return modell, simd, (kerne or os.cpu_count() or 0)
def hole_handbrake_hilfe() -> str:
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"):
return ""
try:
aus = subprocess.run(
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER
)
return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired):
return ""
def erkenne_encoder() -> list:
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.
Gefragt wird ausschließlich HandBrake selbst — es probiert Hardware beim
Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre
plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab.
"""
return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe()))
def erkenne_ip() -> str:
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
Ehrlicher Vorbehalt: im Docker-Bridge-Netz ist das die Container-IP —
für die Maschinen-Zuordnung ist deshalb WORKER_NAME (Anzeigename in
compose/.env) die verlässliche Größe; die IP ist Zusatz-Indiz.
"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.0.2.1", 9)) # TEST-NET — nur Routing, kein Paket nötig
return s.getsockname()[0]
finally:
s.close()
except OSError:
return ""
def werkzeug_versionen() -> dict:
"""Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System).
MakeMKV-Version kommt aus dem Build (ENV MAKEMKV_VERSION im Dockerfile) —
makemkvcon hat keinen dokumentierten --version-Schalter (usage.txt).
HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options)
und gibt z.B. "HandBrake 1.6.1" aus.
"""
import socket
modell, simd, kerne = cpu_merkmale()
info = {
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
"hostname": socket.gethostname(),
"ip": erkenne_ip(),
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
# Tage statt Stunden braucht (kein AVX2, 4 Kerne).
"cpu_modell": modell,
"cpu_kerne": str(kerne),
"cpu_simd": simd,
# Läuft dieser Worker INNERHALB des Rippy-Containers oder auf einer
# fremden Maschine? Entscheidend, weil ein externer Worker die
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
"extern": "nein" if os.path.isdir("/app") else "ja",
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
# grundsätzlich nicht komprimieren.
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
}
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
if hb_encoder:
# 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"):
try:
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"
except (OSError, subprocess.TimeoutExpired):
info["handbrake"] = "installiert"
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
try:
import db
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip()
except Exception:
ui_key = ""
if ui_key:
info["makemkv_key"] = "ui"
elif os.getenv("MAKEMKV_APP_KEY"):
info["makemkv_key"] = "env"
else:
info["makemkv_key"] = "keiner"
# Sieht DIESER Worker eine KEYDB.cfg? Die API zeigt ihren eigenen Mount —
# bei einem Remote-Worker kann das etwas ganz anderes sein, und nur das
# Verzeichnis des rippenden Workers zählt (Befund 25.07.2026).
#
# Gemeldet wird die Angabe NUR, wenn das Datenverzeichnis hier wirklich
# eingehängt ist (os.path.ismount). Ein Remote-Transcode-Worker benutzt
# dasselbe Image — makemkvcon ist dort also vorhanden und der entrypoint
# legt den Ordner an —, er bekommt den Mount aber nicht und rippt nie.
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
try:
import makemkv_daten
daten_dir = makemkv_daten.DATEN_DIR
except Exception:
daten_dir = ""
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
try:
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
except Exception:
info["keydb"] = "unbekannt"
# Die entscheidende Zahl für 4K-UHD: wie viele Disc-Schlüssel kennt
# dieser Worker? 0 heißt, dass jede unbekannte UHD-Disc scheitert —
# makemkvcon holt sie unter Linux nie selbst (Befund 25.07.2026).
try:
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
+72 -1
View File
@@ -1,5 +1,8 @@
from celery import Celery
import os import os
import socket
from celery import Celery
from celery.signals import worker_ready
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
@@ -16,4 +19,72 @@ celery_app.conf.update(
result_serializer="json", result_serializer="json",
timezone="UTC", timezone="UTC",
enable_utc=True, enable_utc=True,
broker_connection_retry_on_startup=True,
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
# transcode-Queue (irgendein freier Worker).
worker_direct=True,
) )
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
import caps # noqa: E402
import db # noqa: E402
import zombies # noqa: E402
@worker_ready.connect
def raeume_job_leichen_auf(**kwargs):
"""Nach der Gnadenfrist: Jobs, an denen niemand arbeitet, ehrlich auf
'failed' setzen (Details und Sicherungen in zombies.py).
Läuft im Hintergrund-Thread — der Worker soll sofort Aufgaben annehmen und
nicht zwei Minuten auf die Aufräumung warten.
"""
import threading
import time
def spaeter():
time.sleep(zombies.GNADENFRIST_SEKUNDEN)
try:
db.init_db()
bericht = zombies.raeume_zombies_auf(celery_app, db)
print(f"Zombie-Erkennung: {bericht}")
except Exception as e: # darf den Worker nie mitnehmen
print(f"Zombie-Erkennung fehlgeschlagen: {e}")
threading.Thread(target=spaeter, daemon=True, name="zombie-erkennung").start()
@worker_ready.connect
def melde_faehigkeiten(**kwargs):
"""Beim Start + minütlich: Encoder-Fähigkeiten und Lebenszeichen melden.
last_seen ist die Basis der Erreichbarkeits-Anzeige im UI — ein Worker,
der sich 2 Minuten nicht meldet, gilt als offline.
"""
import threading
import time
# Anzeigename: WORKER_NAME (compose/.env) macht die Maschine im UI
# zuordenbar UND stabil — der Container-Hostname wechselt bei jedem
# Rebuild und hinterließ Offline-Leichen in der Worker-Liste.
anzeigename = os.getenv("WORKER_NAME") or socket.gethostname()
def herzschlag():
while True:
try:
db.init_db()
db.save_worker(
anzeigename,
caps.erkenne_encoder(),
caps.werkzeug_versionen(),
)
except Exception as e: # DB weg → weiterversuchen, nicht sterben
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
time.sleep(60)
threading.Thread(target=herzschlag, daemon=True, name="worker-herzschlag").start()
+189 -1
View File
@@ -36,7 +36,9 @@ jobs = Table(
Column("status", String(16), nullable=False, server_default="pending"), Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"), Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text), Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text), Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
Column("created_at", DateTime(timezone=True)), Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)), Column("finished_at", DateTime(timezone=True)),
) )
@@ -57,8 +59,194 @@ def utcnow() -> datetime:
def init_db() -> None: def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent).""" """Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
Die ALTERs stehen identisch in docker/api/db.py — wer zuerst startet,
migriert; der andere findet die Spalten dann bereits vor.
"""
metadata.create_all(engine) metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
import json
payload = json.dumps(encoder_liste)
info_payload = json.dumps(info or {})
with engine.begin() as conn:
vorhanden = conn.execute(
workers.select().where(workers.c.name == name)
).first()
if vorhanden:
conn.execute(
workers.update().where(workers.c.name == name).values(
encoders=payload, info=info_payload, last_seen=utcnow()
)
)
else:
conn.execute(
workers.insert().values(
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
)
)
def get_settings(key: str = "ui") -> dict:
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
import json
from sqlalchemy import select
try:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if zeile and zeile[0]:
return json.loads(zeile[0])
except Exception:
pass
return {}
def get_job(job_id: str) -> dict:
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def save_settings(werte: dict, key: str = "ui") -> None:
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
import json
from sqlalchemy import select
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id)
).first()
return zeile[0] if zeile else ""
def list_jobs_mit_status(stati) -> list:
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile — die
Erkennung braucht nichts weiter.
"""
from sqlalchemy import select
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati)))
).mappings().all()
return [dict(z) for z in zeilen]
def zaehle_online_worker(sekunden: int = 120) -> int:
"""Wie viele Worker gelten laut Herzschlag gerade als online?
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
melden sich weniger Worker als bekannt sind, ist die Auskunft
unvollständig — dann wird NICHTS als Leiche gewertet.
"""
from datetime import timedelta
from sqlalchemy import func, select
grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn:
anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar()
return int(anzahl or 0)
def meta_merken(job_id: str, **felder) -> None:
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
`update_job(meta=...)` würde die Spalte ersetzen — Poster, Jahr, Titel-Wahl
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
schreiben.
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern — im
schlimmsten Fall fehlt die Marke und der „Neu"-Knopf fragt nach.
"""
import json
try:
zeile = get_job(job_id)
if not zeile:
return
try:
vorher = json.loads(zeile.get("meta") or "{}")
except (ValueError, TypeError):
vorher = {}
if not isinstance(vorher, dict):
vorher = {}
vorher.update(felder)
update_job(job_id, meta=json.dumps(vorher))
except Exception as e:
try:
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
except Exception:
pass
def update_job(job_id: str, **fields) -> None: def update_job(job_id: str, **fields) -> None:
+7 -1
View File
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer). # Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray. # Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3 BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def _open_nonblock(device_path: str) -> int: def _open_nonblock(device_path: str) -> int:
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
def classify(disc_status_code: int, size_bytes: int) -> str: def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown.""" """Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED): if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd" return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2): if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd" return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown" return "unknown"

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