28 Commits

Author SHA1 Message Date
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
48 changed files with 2061 additions and 934 deletions
+14 -3
View File
@@ -1,14 +1,25 @@
# Rippy-Umgebung — nach .env kopieren und Werte eintragen. # Rippy-Umgebung — nach .env kopieren und Werte eintragen.
# Die .env liegt NUR auf der VM (gitignored), nie im Repo. # Die .env liegt NUR auf der VM (gitignored), nie im Repo.
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt) # 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 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. Die sg-Nummer variiert je Host — auf DIESER
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
OPTICAL_SR=/dev/sr0
OPTICAL_SG=/dev/sg1
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only, # (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.) # 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 # Metadaten-APIs
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org # 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=
# TVDb (optional, Serien-Fallback) # TVDb (optional, Serien-Fallback)
THETVDB_API_KEY= THETVDB_API_KEY=
+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
+6 -4
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
+50 -15
View File
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
### Laufwerk anpassen ### Laufwerk anpassen
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine `/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
`docker-compose.override.yml` an: spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
(auf frischen Systemen oft `/dev/sg0`).
```yaml Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
services: ```bash
api: lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
devices: ["/dev/sr1:/dev/sr0"] # oder: ls -la /sys/class/scsi_generic/
worker:
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
``` ```
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder ```bash
`ls -la /sys/class/scsi_generic/`. OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
```
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID **Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) — durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
@@ -150,6 +153,9 @@ bleibt All-in-one.
| `THETVDB_API_KEY` | optional | Serien-Fallback | | `THETVDB_API_KEY` | optional | Serien-Fallback |
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen | | `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build | | `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen. UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
@@ -165,7 +171,16 @@ Voraussetzungen auf dem Ziel-Host:
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben. als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in 3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
`docker-compose.yml` anpassen — muss ein rshared-fähiger Bind sein). `docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
```bash
sudo mount --bind /srv/rippy/media /srv/rippy/media
sudo mount --make-rshared /srv/rippy/media
```
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch --build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
@@ -175,9 +190,29 @@ docker compose up -d --build`.
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy. Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
### Härtung für fremde/exponierte Netze
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
nicht auf `0.0.0.0` binden.
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
den DB-Port nie offen ins unsichere Netz zu hängen.
## Entwicklung ## Entwicklung
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Grün auf CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
`main` wird automatisch auf `stable` befördert — deploye von `stable`. einen Branch, `main` (Single Source of Truth) — 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) · Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
Fahrplan: [ROADMAP.md](ROADMAP.md) Fahrplan: [ROADMAP.md](ROADMAP.md)
+89 -1
View File
@@ -1,6 +1,94 @@
# SAVEPOINT — Rippy # SAVEPOINT — Rippy
## Aktueller Stand: v3.5Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude) ## Aktueller Stand: v3.9Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
ps2exe kompiliert: eingebettetes Rippy-Disc-Icon, KEIN Konsolenfenster
(-noConsole), WinForms (-STA). Vorgebaut auf Windows
(deploy/worker-windows/build-exe.ps1) + committet — Windows-.exe geht
nicht von Linux (Rippy-Host). API: GET /worker-setup/windows-exe.
UI (Worker → Windows): „Installer herunterladen (.exe)" als Haupt-Weg.
**E2E bewiesen**: von der VM geladen → Fenster öffnet, conhost-Zähler
unverändert (kein Konsolenfenster), Icon eingebettet.
- **Wichtig — friert nichts ein**: Die .exe holt HandBrake zur Laufzeit
(GitHub latest) und den Worker-Code live von Rippy. Nur bei Änderung der
GUI (install-gui.ps1) neu bauen (build-exe.ps1), NICHT bei Tool-Updates.
- **Update-Story dokumentiert** (Commander-Frage): MakeMKV → .env-Bump +
Rebuild (Update-Check zeigt es); HandBrake Docker = Debian-stabil,
native Worker = latest beim Installieren.
---
## Vorheriger Stand: v3.8 — Grafischer Windows-Installer + Versionslage klar (24.07.2026)
- **Grafischer Windows-Installer** (statt CLI): install-gui.ps1 (WinForms,
ASCII-only) — Fenster mit Feldern (Rippy-IP, Worker-Name, Autostart) +
Install-Knopf mit Live-Log + „Worker starten". Gleiche Schritte wie der
CLI-Installer (Worker-Code, HandBrake latest, venv, Tray/Start/Uninstall).
UI (Worker → Windows): Knopf „Grafischen Installer herunterladen (.bat)" —
die .bat wird CLIENT-seitig mit der eingetragenen LAN-IP erzeugt,
Doppelklick lädt+startet die GUI von Rippy (GET /worker-setup/windows-gui).
CLI-Befehl bleibt als Profi-Alternative. Bewiesen: GUI-Fenster öffnet
sauber auf dem Commander-PC (Titel „Rippy Encoding-Worker - Installation").
- **Werkzeug-Versionslage exakt dargestellt** (Commander-Wunsch): System-Tab
erklärt jetzt klar — MakeMKV aktualisierbar (wichtig wegen Schlüssel-DB),
HandBrake im Docker-Worker bewusst Debian-stabil (1.6.1, KEIN Alarm mehr),
native Worker holen die neueste. Update-Check zeigt MakeMKV mit „✓ aktuell"
bzw. Update-Befehl, HandBrake neutral als Info.
---
## Vorheriger Stand: v3.7 — Drei Praxis-Bugs (Mounts, HandBrake, Encoder-Wahl) (24.07.2026)
Alle drei live auf der VM verifiziert:
- **Speicher-Mounts robust**: Ein toter CIFS-Mount (NAS weg/Rebuild) war
mounted:false, verschwand aus „Verfügbare Ziele" und ließ sich nicht neu
anlegen („Name existiert"). Jetzt: `reachable`-Status (bounded `timeout 3
ls` — Endpoint lädt in 0,014 s statt 10 s), `umount -l`-Fallback,
`reparieren()` (lazy abhängen + frisch mounten), Re-Add repariert statt
409, POST /storage-mounts/{name}/repair, eigene „Netzwerk-Mounts"-Liste im
UI mit Status + Reparieren/Entfernen. Bewiesen: Reparatur des `rippy`-
Mounts → mounted:true, writable:true.
- **HandBrake-Versionen konsistent**: Windows-Installer zieht dynamisch die
neueste (GitHub latest) — bewiesen: HandBrake 1.11.2 installiert (statt
fest 1.9.2). Update-UI erklärt: Docker = Debian-stabil (bewusst älter),
Windows = neueste. Nebenbei ein latenter Installer-Bug gefixt: PowerShell
5.1 liest .ps1 als ANSI — ein Gedankenstrich zerschoss das Skript →
install.ps1 ist jetzt ASCII-only.
- **Encoder-/Worker-Wahl beim Rip**: Celery `worker_direct=True`, gezieltes
Routing (transcode_queue) mit sicherem Fallback auf die geteilte Queue.
Bewiesen mit ping_worker: Task landet exakt beim gewählten Node. Dropdown
im Rip-Dialog ab 2 Online-Workern. Node-Zuordnung disambiguiert bei
mehreren Workern pro Host. Nebenfund gefixt: DeviceDiscovery leitete die
Titel-Auswahl (titles) gar nicht weiter.
⚠️ Gezieltes Routing braucht AKTUELLEN Worker-Code (worker_direct) — ein
vor dieser Version installierter Remote-Worker pingt zwar, konsumiert aber
seine Direkt-Queue nicht → neu installieren.
- **Voller Transcode-E2E weiterhin durch die UHD-Key-Lage blockiert** (keine
entschlüsselbare Disc) — die Routing-Mechanik ist per ping_worker bewiesen.
---
## Vorheriger Stand: v3.6 — Design 2.0 gelandet + Ein-Branch-Umstellung (24.07.2026)
- **Design 2.0 ist in `main`** (von Gemini umgesetzt): UI von 428
`theme === 'dark'`-Ternaries auf Tailwind-`dark:` + UI-Primitives
(`components/ui/`, `lib/design.ts`) umgebaut, „Cinematic Cinema OS"-Look.
Ampel grün (49 Tests, Ruff, Vite-Build), Live auf der VM.
- **Nur noch EIN Branch: `main`** (Commander-Entscheid). Der
`stable`-Zwischenbranch samt Grün-Gate ist abgeschafft — er war
vestigial, weil die VM ohnehin aus `main` deployt. Konsequenzen:
- ci.yml: Beförderungs-Schritt raus, die Ampel PRÜFT nur noch.
- deploy.sh: nutzt `main` statt `stable`.
- AGENTS §C / README / DESIGN-Briefing entsprechend umgeschrieben.
- Gelöschte Branches: `stable`, `design-2.0` (gemergt), `kernumbau-2026-07-23` (alt).
- **Deploy-Weg ab jetzt:** push → Ampel grün prüfen → auf der VM
`git pull` (in ~/projects/rippy) bzw. `./deploy.sh`.
---
## Vorheriger Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog → - **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
+38 -16
View File
@@ -1,26 +1,48 @@
#!/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}"
DIENST="$4"
if [ ! -d "$CLONE/.git" ]; then
git clone --branch main "$REPO_URL" "$CLONE"
fi fi
cd ~/notfall-rippy cd "$CLONE"
git fetch origin stable git fetch origin main
git reset --hard origin/stable git reset --hard origin/main
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis" cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
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
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
#
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberflaeche)
# aendert. NICHT bei MakeMKV-/HandBrake-Updates — die .exe holt HandBrake zur
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
# keine Versionen ein.
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
Set-Location $PSScriptRoot
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
$bmp = New-Object System.Drawing.Bitmap 256, 256
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = "AntiAlias"
$g.Clear([System.Drawing.Color]::Transparent)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$png = $ms.ToArray()
$icoStream = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter $icoStream
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
$bw.Write($png); $bw.Flush()
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
# 2. ps2exe sicherstellen — TLS 1.2 ist der Fix fuer den NuGet-Bootstrap-Fehler
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
if (-not (Get-Module -ListAvailable ps2exe)) {
Write-Host "Bootstrappe NuGet + PSGallery ..."
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
Write-Host "Installiere ps2exe ..."
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
}
Import-Module ps2exe
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA `
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.0.0"
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
} else {
Write-Host "FEHLER: keine .exe erzeugt"
}
+237
View File
@@ -0,0 +1,237 @@
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Python 3.10+ auf der Maschine - kein Docker, kein git.
#
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen
# das Skript).
param(
[string]$RippyHost = "",
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$InstallDir = Join-Path $env:USERPROFILE "rippy-worker"
# --- Fenster ---------------------------------------------------------------
$form = New-Object System.Windows.Forms.Form
$form.Text = "Rippy Encoding-Worker - Installation"
$form.Size = New-Object System.Drawing.Size(560, 520)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.BackColor = [System.Drawing.Color]::FromArgb(248, 250, 252)
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch)
# - kein externes .ico noetig. Erscheint in Titelleiste und Taskleiste.
try {
$ibmp = New-Object System.Drawing.Bitmap 32, 32
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
$ig.SmoothingMode = "AntiAlias"
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
} catch {}
$titel = New-Object System.Windows.Forms.Label
$titel.Text = "Rippy Encoding-Worker einrichten"
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold)
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
$titel.Location = New-Object System.Drawing.Point(24, 20)
$titel.Size = New-Object System.Drawing.Size(500, 30)
$form.Controls.Add($titel)
$info = New-Object System.Windows.Forms.Label
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine."
$info.Location = New-Object System.Drawing.Point(24, 52)
$info.Size = New-Object System.Drawing.Size(500, 34)
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139)
$form.Controls.Add($info)
# Rippy-Adresse
$lblHost = New-Object System.Windows.Forms.Label
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
$lblHost.Location = New-Object System.Drawing.Point(24, 96)
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
$form.Controls.Add($lblHost)
$txtHost = New-Object System.Windows.Forms.TextBox
$txtHost.Location = New-Object System.Drawing.Point(24, 116)
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
$txtHost.Text = $RippyHost
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
$form.Controls.Add($txtHost)
# Worker-Name
$lblName = New-Object System.Windows.Forms.Label
$lblName.Text = "Name dieses Workers (frei waehlbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 152)
$lblName.Size = New-Object System.Drawing.Size(500, 18)
$form.Controls.Add($lblName)
$txtName = New-Object System.Windows.Forms.TextBox
$txtName.Location = New-Object System.Drawing.Point(24, 172)
$txtName.Size = New-Object System.Drawing.Size(300, 26)
$txtName.Text = $WorkerName
$form.Controls.Add($txtName)
# Autostart
$chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
$chkAuto.Location = New-Object System.Drawing.Point(24, 206)
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
$chkAuto.Checked = $true
$form.Controls.Add($chkAuto)
# Log-Bereich
$log = New-Object System.Windows.Forms.TextBox
$log.Location = New-Object System.Drawing.Point(24, 240)
$log.Size = New-Object System.Drawing.Size(500, 170)
$log.Multiline = $true
$log.ReadOnly = $true
$log.ScrollBars = "Vertical"
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42)
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240)
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
$form.Controls.Add($log)
# Knoepfe
$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Installieren"
$btnInstall.Location = New-Object System.Drawing.Point(24, 424)
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
$btnInstall.ForeColor = [System.Drawing.Color]::White
$btnInstall.FlatStyle = "Flat"
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$form.Controls.Add($btnInstall)
$btnStart = New-Object System.Windows.Forms.Button
$btnStart.Text = "Worker starten"
$btnStart.Location = New-Object System.Drawing.Point(190, 424)
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
$btnStart.BackColor = [System.Drawing.Color]::FromArgb(16, 185, 129)
$btnStart.ForeColor = [System.Drawing.Color]::White
$btnStart.FlatStyle = "Flat"
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnStart.Visible = $false
$form.Controls.Add($btnStart)
function Log($text) {
$log.AppendText($text + "`r`n")
[System.Windows.Forms.Application]::DoEvents()
}
# --- Installations-Logik ---------------------------------------------------
function Do-Install {
$rHost = $txtHost.Text.Trim()
$wName = $txtName.Text.Trim()
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
$btnInstall.Enabled = $false
$log.Clear()
try {
# 1. Python finden
$python = $null
foreach ($k in @("py -3", "python")) {
try {
$v = Invoke-Expression "$k --version" 2>&1
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
} catch {}
}
if (-not $python) {
Log "FEHLER: Python 3.10+ nicht gefunden."
Log "Installieren mit: winget install Python.Python.3.12"
Log "Danach diesen Installer erneut starten."
$btnInstall.Enabled = $true
return
}
Log "Python gefunden: $python"
# 2. Ordner + Worker-Code
New-Item -ItemType Directory -Force $InstallDir | Out-Null
Set-Location $InstallDir
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
} catch {}
Log "Lade Worker-Code von $rHost ..."
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhaengigkeiten
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
Log "Installiere Python-Abhaengigkeiten (kann 1-2 min dauern) ..."
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Log "Abhaengigkeiten installiert."
# 4. HandBrakeCLI
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
Log "HandBrake bereit."
# 5. Start-Skripte + Deinstaller erzeugen
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
$uninstall = @"
param([switch]`$Force)
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
Add-Type -AssemblyName System.Windows.Forms
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
Set-Location (Split-Path `$PSScriptRoot -Parent)
Remove-Item -Recurse -Force `$PSScriptRoot
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
"@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
# 6. Autostart
if ($chkAuto.Checked) {
schtasks /create /f /tn "RippyWorker" /tr "`"$InstallDir\start-tray.bat`"" /sc onlogon | Out-Null
Log "Autostart eingerichtet (Start bei Anmeldung)."
}
Log ""
Log "FERTIG! Ordner: $InstallDir"
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
$btnStart.Visible = $true
$btnInstall.Text = "Neu installieren"
$btnInstall.Enabled = $true
} catch {
Log ""
Log "FEHLER: $_"
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
$btnInstall.Enabled = $true
}
}
$btnInstall.Add_Click({ Do-Install })
$btnStart.Add_Click({
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
$form.Close()
})
[void]$form.ShowDialog()
+17 -7
View File
@@ -1,13 +1,13 @@
# Rippy: Nativer Windows-Transcode-Worker Installation OHNE Docker. # Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
# #
# Was das Skript tut (alles in einen Unterordner "rippy-worker"): # Was das Skript tut (alles in einen Unterordner "rippy-worker"):
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten # 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket) # 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release), # 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden # falls nicht schon im PATH oder im Ordner vorhanden
# 4. Erzeugt start-worker.bat Doppelklick startet den Worker # 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
# #
# Der Worker bedient NUR die Kompressions-Queue (transcode) gerippt wird # Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk). # weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
# #
# Aufruf (PowerShell): # Aufruf (PowerShell):
@@ -22,7 +22,17 @@ param(
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$HandBrakeVersion = "1.9.2" # HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist.
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip" $HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
@@ -82,7 +92,7 @@ Write-Host "HandBrakeCLI bereit."
# 5. Start-Skript erzeugen # 5. Start-Skript erzeugen
$bat = @" $bat = @"
@echo off @echo off
rem Rippy Windows-Transcode-Worker verbindet sich mit $RippyHost rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
cd /d "%~dp0" cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0 set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
@@ -96,7 +106,7 @@ Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster # 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
$trayBat = @" $trayBat = @"
@echo off @echo off
rem Rippy-Worker mit Tray-Symbol verbindet sich mit $RippyHost rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
cd /d "%~dp0" cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0 set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
@@ -109,7 +119,7 @@ start "" venv\Scripts\pythonw.exe tray.py
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
Write-Host "start-worker.bat + start-tray.bat erzeugt." Write-Host "start-worker.bat + start-tray.bat erzeugt."
# 7. Deinstaller MUSS-Kriterium: rueckstandsfrei entfernbar # 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
$uninstall = @" $uninstall = @"
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart, # Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett. # meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+12 -11
View File
@@ -11,7 +11,7 @@ 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:-}
@@ -48,7 +48,7 @@ services:
propagation: rshared propagation: rshared
- temp:/app/temp - temp:/app/temp
devices: devices:
- /dev/sr0:/dev/sr0 - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
@@ -74,7 +74,7 @@ services:
# Einstellungen → System meldet, wenn es eine neuere Version gibt. # Einstellungen → System meldet, wenn es eine neuere Version gibt.
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4} 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}
@@ -93,13 +93,14 @@ services:
propagation: rslave propagation: rslave
- temp:/app/temp - temp:/app/temp
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:
@@ -132,7 +133,7 @@ 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 # Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne # Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
+5 -1
View File
@@ -21,6 +21,10 @@ COPY docker/api/ .
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) — # native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker. # die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/ COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
COPY deploy/worker-windows/install.ps1 worker_dist/ COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 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"]
+21
View File
@@ -7,12 +7,33 @@ 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 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): 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(
+6 -4
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
+145 -32
View File
@@ -12,6 +12,7 @@ import uuid
import db import db
import devices as device_discovery import devices as device_discovery
import makemkv_key
import mounts as mount_verwaltung import mounts as mount_verwaltung
import notify import notify
from celery_client import celery_client, start_rip from celery_client import celery_client, start_rip
@@ -46,13 +47,22 @@ async def startup_event():
except ConfigValidationError as e: except ConfigValidationError as e:
print(f"⚠️ Konfigurations-Warnung: {e}") print(f"⚠️ Konfigurations-Warnung: {e}")
# Gespeicherte Netzwerk-Speicherziele wiederherstellen # Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
# 24.07.: ~5 min "Waiting for application startup", kein Endpoint bedient, bis der
# soft-Mount per Timeout abbrach. Darum im Hintergrund: die Mounts stellen sich
# her, sobald der Server antwortet, ohne die API auszubremsen.
def remount(): def remount():
for meldung in mount_verwaltung.alle_remounten(): for meldung in mount_verwaltung.alle_remounten():
db.add_log("info", "mounts", meldung) db.add_log("info", "mounts", meldung)
await asyncio.to_thread(remount) asyncio.create_task(asyncio.to_thread(remount))
asyncio.create_task(disc_watcher()) asyncio.create_task(disc_watcher())
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
asyncio.create_task(makemkv_key.refresh_loop())
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort, # Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
@@ -338,6 +348,7 @@ class JobCreateRequest(BaseModel):
season: Optional[int] = None season: Optional[int] = None
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle) titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
MEDIA_ROOT = "/app/media" MEDIA_ROOT = "/app/media"
@@ -395,6 +406,8 @@ async def create_job(request: JobCreateRequest):
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200] titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
if titel_liste: if titel_liste:
meta_dict["titles"] = titel_liste meta_dict["titles"] = titel_liste
if request.transcode_node:
meta_dict["transcode_node"] = request.transcode_node
meta_json = json.dumps(meta_dict) if meta_dict else None meta_json = json.dumps(meta_dict) if meta_dict else None
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
@@ -511,7 +524,15 @@ async def storage_targets():
return ziele return ziele
for name in eintraege: for name in eintraege:
pfad = os.path.join(MEDIA_ROOT, name) pfad = os.path.join(MEDIA_ROOT, name)
if not os.path.isdir(pfad): try:
ist_mount = os.path.ismount(pfad)
except OSError:
# Toter CIFS-Mount (NAS weg) — als Ziel unbrauchbar, aber NICHT
# den ganzen Endpoint sprengen (Befund 24.07.: Eintrag flog raus,
# ließ sich aber nicht mehr neu anlegen). Überspringen ist ok,
# die Mount-Verwaltung (/storage-mounts) zeigt ihn zum Reparieren.
continue
if not ist_mount and not os.path.isdir(pfad):
continue continue
try: try:
nutzung = shutil.disk_usage(pfad) nutzung = shutil.disk_usage(pfad)
@@ -521,7 +542,7 @@ async def storage_targets():
ziele.append({ ziele.append({
"name": name, "name": name,
"path": pfad, "path": pfad,
"is_mount": os.path.ismount(pfad), "is_mount": ist_mount,
"free_gb": frei_gb, "free_gb": frei_gb,
}) })
return ziele return ziele
@@ -599,10 +620,16 @@ async def retry_transcode(job_id: str):
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}" basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = job.get("output_path") or f"{basis}/{job_id}" final_dir = job.get("output_path") or f"{basis}/{job_id}"
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
from celery_client import transcode_queue
celery_client.send_task( celery_client.send_task(
"worker.tasks.transcode_files", "worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir], args=[job_id, raw_dir, final_dir],
queue="transcode", queue=transcode_queue(meta.get("transcode_node")),
) )
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None) await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht") await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
@@ -639,18 +666,23 @@ async def capabilities():
zeilen = db.list_workers() zeilen = db.list_workers()
try: try:
antworten = celery_client.control.ping(timeout=1.0) or [] antworten = celery_client.control.ping(timeout=1.0) or []
online_namen = { ping_knoten = [k for antwort in antworten for k in antwort.keys()]
knoten.split("@", 1)[-1]
for antwort in antworten
for knoten in antwort.keys()
}
except Exception: except Exception:
online_namen = set() ping_knoten = []
for zeile in zeilen: for zeile in zeilen:
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem # Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info. # gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
# der WORKER_NAME steckt nur in info). Laufen MEHRERE Worker auf
# demselben Host (Befund 24.07.), disambiguiert der Name-Teil —
# sonst bekäme der falsche Worker den Node. `node` ist der
# Routing-Ziel-Knoten für die gezielte Encoder-Wahl.
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"] hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
zeile["online"] = hostname in online_namen kandidaten = [k for k in ping_knoten if k.split("@", 1)[-1] == hostname]
node = next(
(k for k in kandidaten if k.split("@", 1)[0] == zeile["name"]), None
) or (kandidaten[0] if kandidaten else None)
zeile["online"] = node is not None
zeile["node"] = node
return zeilen return zeilen
return {"workers": await asyncio.to_thread(sammle)} return {"workers": await asyncio.to_thread(sammle)}
@@ -806,39 +838,67 @@ class MountRequest(BaseModel):
@app.get("/storage-mounts") @app.get("/storage-mounts")
async def get_storage_mounts(): async def get_storage_mounts():
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status.""" """Konfigurierte Netzwerk-Speicherziele inkl. Live-Status.
eintraege = await asyncio.to_thread(db.list_mounts)
return [ `mounted` = liegt ein Mount an; `reachable` = ist er auch WIRKLICH nutzbar
{ (toter CIFS-Mount nach NAS-Ausfall: mounted=true, reachable=false → das UI
"name": e["name"], bietet dann „Reparieren" statt den Eintrag verschwinden zu lassen)."""
"type": e["typ"], def sammle():
"source": e["quelle"], eintraege = db.list_mounts()
"mounted": mount_verwaltung.ist_gemountet(e["name"]), ergebnis = []
"has_credentials": bool(e.get("username")), for e in eintraege:
} gemountet = mount_verwaltung.ist_gemountet(e["name"])
for e in eintraege ergebnis.append({
] "name": e["name"],
"type": e["typ"],
"source": e["quelle"],
"mounted": gemountet,
"reachable": mount_verwaltung.ist_erreichbar(e["name"]) if gemountet else False,
"has_credentials": bool(e.get("username")),
})
return ergebnis
return await asyncio.to_thread(sammle)
@app.post("/storage-mounts", status_code=201) @app.post("/storage-mounts", status_code=201)
async def create_storage_mount(request: MountRequest): async def create_storage_mount(request: MountRequest):
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start.""" """Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start.
Existiert der Name schon UND ist erreichbar → 409. Existiert er, ist aber
TOT (NAS war weg) → wird mit den neuen Angaben frisch repariert statt
stur „Name bereits vergeben" zu melden (Befund 24.07.: man saß sonst fest)."""
if not mount_verwaltung.validiere_name(request.name): if not mount_verwaltung.validiere_name(request.name):
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)") raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
if request.type not in ("nfs", "cifs"): if request.type not in ("nfs", "cifs"):
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein") raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
raise HTTPException(status_code=409, detail="Name bereits vergeben") vorhanden = any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts))
if vorhanden:
gemountet = await asyncio.to_thread(mount_verwaltung.ist_gemountet, request.name)
erreichbar = gemountet and await asyncio.to_thread(mount_verwaltung.ist_erreichbar, request.name)
if erreichbar:
raise HTTPException(
status_code=409,
detail="Name bereits vergeben und aktiv — erst entfernen, dann neu anlegen.",
)
# Toter/veralteter Eintrag → reparieren (lazy abhängen + frisch mounten)
aktion = mount_verwaltung.reparieren
else:
aktion = mount_verwaltung.mounten
try: try:
schreibbar = await asyncio.to_thread( schreibbar = await asyncio.to_thread(
mount_verwaltung.mounten, aktion,
request.name, request.type, request.source, request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "", request.options or "", request.username or "", request.password or "",
) )
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
# Bei Reparatur die (evtl. neuen) Zugangsdaten in der DB aktualisieren
if vorhanden:
await asyncio.to_thread(db.delete_mount, request.name)
await asyncio.to_thread( await asyncio.to_thread(
db.save_mount, db.save_mount,
request.name, request.type, request.source, request.name, request.type, request.source,
@@ -847,10 +907,37 @@ async def create_storage_mount(request: MountRequest):
await asyncio.to_thread( await asyncio.to_thread(
db.add_log, db.add_log,
"success" if schreibbar else "warning", "mounts", "success" if schreibbar else "warning", "mounts",
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}" f"Speicherziel '{request.name}' ({request.type}) "
+ ("repariert" if vorhanden else "eingehängt") + f": {request.source}"
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"), + ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
) )
return {"name": request.name, "mounted": True, "writable": schreibbar} return {"name": request.name, "mounted": True, "writable": schreibbar, "repaired": vorhanden}
@app.post("/storage-mounts/{name}/repair")
async def repair_storage_mount(name: str):
"""Toten/veralteten Mount mit den GESPEICHERTEN Zugangsdaten neu verbinden
(Reparieren-Knopf im UI) — ohne dass der Nutzer alles neu eintippt."""
eintrag = next(
(e for e in await asyncio.to_thread(db.list_mounts) if e["name"] == name), None
)
if not eintrag:
raise HTTPException(status_code=404, detail="Speicherziel nicht gefunden")
try:
schreibbar = await asyncio.to_thread(
mount_verwaltung.reparieren,
eintrag["name"], eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.add_log, "success" if schreibbar else "warning", "mounts",
f"Speicherziel '{name}' neu verbunden"
+ ("" if schreibbar else " — NUR LESBAR"),
)
return {"name": name, "mounted": True, "writable": schreibbar}
@app.get("/storage-mounts/shares") @app.get("/storage-mounts/shares")
@@ -1012,6 +1099,32 @@ async def worker_setup_windows():
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1") return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
@app.get("/worker-setup/windows-gui")
async def worker_setup_windows_gui():
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
für Fortgeschrittene; der Normalweg ist die .exe unten."""
pfad = "worker_dist/install-gui.ps1"
if not os.path.isfile(pfad):
raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen")
return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1")
@app.get("/worker-setup/windows-exe")
async def worker_setup_windows_exe():
"""Fertige Windows-Installer-.exe (Rippy-Icon, kein Konsolenfenster).
Vorgebaut auf Windows (deploy/worker-windows/build-exe.ps1) — eine
Windows-.exe lässt sich nicht auf Linux bauen. Generisch: die GUI fragt
die Rippy-Adresse selbst ab, HandBrake + Worker-Code kommen zur Laufzeit."""
pfad = "worker_dist/RippyWorkerSetup.exe"
if not os.path.isfile(pfad):
raise HTTPException(status_code=404, detail="Installer-.exe nicht im Image — API neu bauen")
return FileResponse(
pfad, media_type="application/vnd.microsoft.portable-executable",
filename="RippyWorkerSetup.exe",
)
@app.get("/worker-setup/paket") @app.get("/worker-setup/paket")
async def worker_setup_paket(): async def worker_setup_paket():
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier. """Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
+81
View File
@@ -0,0 +1,81 @@
"""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 laeuft zum Monatsende ab. Ohne gueltigen Key
faellt 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 naechsten Rip.
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Den AACS-Schluessel
zieht MakeMKV via LibreDrive ohnehin selbst aus dem Laufwerk.
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: haelt 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 ueberschreibt 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 laeuft 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))
+94 -8
View File
@@ -30,7 +30,31 @@ def validiere_name(name: str) -> bool:
def ist_gemountet(name: str) -> bool: def ist_gemountet(name: str) -> bool:
return os.path.ismount(_mountpoint(name)) """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 schreibtest(pfad: str) -> bool: def schreibtest(pfad: str) -> bool:
@@ -49,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
return False return False
def _stale_mounts_loesen(ziel: str) -> int:
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
ueber 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) haengt 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
return geloest
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str: def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext. """Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
@@ -121,9 +169,21 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
Mount-Fehlern. Mount-Fehlern.
""" """
ziel = _mountpoint(name) ziel = _mountpoint(name)
os.makedirs(ziel, exist_ok=True) # exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
if os.path.ismount(ziel): # täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
return schreibtest(ziel) try:
os.makedirs(ziel, exist_ok=True)
except FileExistsError:
pass
try:
if os.path.ismount(ziel):
return schreibtest(ziel)
except OSError:
pass # toter Mount → 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 creds_datei = None
try: try:
@@ -169,22 +229,48 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
pass 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: def aushaengen(name: str) -> None:
ziel = _mountpoint(name) ziel = _mountpoint(name)
if os.path.ismount(ziel): try:
noch_mount = os.path.ismount(ziel)
except OSError:
noch_mount = True # stale
if noch_mount:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["umount", ziel], capture_output=True, text=True, timeout=30 ["umount", ziel], capture_output=True, text=True, timeout=30
) )
if ergebnis.returncode != 0: if ergebnis.returncode != 0:
raise RuntimeError( # Toter/beschäftigter Mount → lazy detach (klappt immer)
f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}" _lazy_umount(ziel)
)
try: try:
os.rmdir(ziel) os.rmdir(ziel)
except OSError: except OSError:
pass # nicht leer oder weg — egal 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.
"""
ziel = _mountpoint(name)
try:
if os.path.ismount(ziel):
_lazy_umount(ziel)
except OSError:
_lazy_umount(ziel)
return mounten(name, typ, quelle, optionen, username, passwort)
def alle_remounten() -> list: def alle_remounten() -> list:
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen.""" """Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
meldungen = [] meldungen = []
+15
View File
@@ -42,3 +42,18 @@ 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)"
+28
View File
@@ -0,0 +1,28 @@
"""Tests fuer 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 faellt 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 laenger
# 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-")
+30
View File
@@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
def test_validiere_name_bleibt_streng(): def test_validiere_name_bleibt_streng():
assert validiere_name("nas-filme") assert validiere_name("nas-filme")
assert not 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
-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
+52 -73
View File
@@ -1,109 +1,88 @@
import { useState, useEffect } from 'react' import { useEffect, useState } from 'react'
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react' import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
import { api } from './lib/api'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AnleitungPage from './pages/Anleitung'
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 FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api'
type Page = 'dashboard' | 'anleitung' | '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')
// 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 [setupDone, setSetupDone] = useState<boolean | null>(null)
const { theme, toggleTheme } = useDarkMode()
useEffect(() => { useEffect(() => {
api.get('/setup') api.get('/setup')
.then(r => setSetupDone(!!r.data.done)) .then((r) => setSetupDone(Boolean(r.data?.done)))
.catch(() => setSetupDone(true)) // API nicht erreichbar → kein Wizard-Zwang .catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
}, []) }, [])
if (setupDone === false) { const navItems: { id: Page; label: string; icon: any }[] = [
return <FirstRunWizard onDone={() => setSetupDone(true)} />
}
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen }, { 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-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans"> <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 */}
{/* Cinema 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 dark:bg-slate-950 text-slate-100 flex flex-col border-r border-slate-800/80 shadow-2xl z-20"> <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.5 bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 rounded-xl shadow-lg shadow-amber-500/20"> <div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
<Disc size={26} className="text-white animate-pulse" /> <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 bg-gradient-to-r from-white via-slate-100 to-amber-300 bg-clip-text text-transparent"> <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">
Rippy CINEMA OS
</h1> </span>
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
</div>
</div>
</div> </div>
<div className="px-3 mb-2"> {/* Central Nav Tabs */}
<button <nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
onClick={toggleTheme}
className="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 text-slate-400 hover:text-slate-200 border border-slate-800 transition-all duration-200 text-xs font-medium"
title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
>
<span className="flex items-center gap-2">
{theme === 'dark' ? <Moon size={15} className="text-amber-400" /> : <Sun size={15} className="text-amber-500" />}
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
</span>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300">
{theme}
</span>
</button>
</div>
<nav className="flex-1 px-3 py-2 space-y-1.5">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = page === item.id const Icon = item.icon
const isActive = currentPage === item.id
return ( return (
<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.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${ className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
isActive isActive
? 'bg-gradient-to-r from-amber-500/20 via-slate-800 to-slate-800/80 text-amber-300 border-l-4 border-amber-500 shadow-md shadow-amber-500/10' ? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
: 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`} }`}
> >
<item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} /> <Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
) )
})} })}
</nav> </nav>
<footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80"> <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">
Created with <span className="text-rose-400"></span> by LucyAI, Claude & KrBrZ <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
</footer> <span>ONLINE</span>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
<div className="max-w-7xl mx-auto p-6 md:p-8">
{page === 'dashboard' && <Dashboard />}
{page === 'anleitung' && <AnleitungPage />}
{page === 'logs' && <LogsPage />}
{page === 'settings' && <SettingsPage />}
</div> </div>
</main> </div>
</div> </header>
{/* Main Content Viewport */}
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
{currentPage === 'dashboard' && <Dashboard />}
{currentPage === 'anleitung' && <AnleitungPage />}
{currentPage === 'logs' && <LogsPage />}
{currentPage === 'settings' && <SettingsPage />}
</main>
</div> </div>
) )
} }
export default App
@@ -43,6 +43,16 @@ function posterUrl(disc?: DiscInfo): string | null {
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}` 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)
@@ -78,6 +88,8 @@ export default function DeviceDiscovery() {
...(targetDir ? { target_dir: targetDir } : {}), ...(targetDir ? { target_dir: targetDir } : {}),
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}), ...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}), ...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
}) })
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"') toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
@@ -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>
)
}
+44 -2
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react' import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Input } from './ui/Input' import { Input, Select } from './ui/Input'
interface TargetConfig { interface TargetConfig {
id: string id: string
@@ -18,6 +18,18 @@ export interface RipOptionen {
season?: number season?: number
mainFeatureOnly?: boolean mainFeatureOnly?: boolean
titles?: number[] titles?: number[]
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
}
interface WorkerWahl {
name: string
node: string | null
online?: boolean
encoders: string[]
}
const ENCODER_KURZ: Record<string, string> = {
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
} }
interface TitelInfo { interface TitelInfo {
@@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [scanFehler, setScanFehler] = useState('') const [scanFehler, setScanFehler] = useState('')
const [titelListe, setTitelListe] = useState<TitelInfo[]>([]) const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set()) const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [workers, setWorkers] = useState<WorkerWahl[]>([])
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
const scanStarten = async () => { const scanStarten = async () => {
if (!deviceId) return if (!deviceId) return
@@ -109,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setScanFehler('') setScanFehler('')
setTitelListe([]) setTitelListe([])
setGewaehlt(new Set()) setGewaehlt(new Set())
setEncoderNode('')
// 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 => { api.get('/settings').then(r => {
const s = r.data || {} const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly) setNurHauptfilm(!!s.mainFeatureOnly)
@@ -144,6 +163,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
optionen.titles = [...gewaehlt].sort((a, b) => a - b) optionen.titles = [...gewaehlt].sort((a, b) => a - b)
} }
if (encoderNode) optionen.transcodeNode = encoderNode
const target = targets.find(t => t.type === selectedType) const target = targets.find(t => t.type === selectedType)
if (customPath) { if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
@@ -225,6 +245,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</label> </label>
)} )}
{/* 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>
)}
{/* Titel-Auswahl (Track-Tabelle) */} {/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && ( {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="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
+69 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
@@ -11,6 +11,7 @@ interface Mount {
type: string type: string
source: string source: string
mounted: boolean mounted: boolean
reachable?: boolean
has_credentials: boolean has_credentials: boolean
} }
@@ -70,12 +71,12 @@ export default function StorageMounts() {
const laden = async (manuell = false) => { const laden = async (manuell = false) => {
try { try {
const [m, t] = await Promise.all([ const [m, t] = await Promise.allSettled([
api.get('/storage-mounts'), api.get('/storage-mounts'),
api.get('/storage-targets'), api.get('/storage-targets'),
]) ])
setMounts(m.data) if (m.status === 'fulfilled') setMounts(m.value.data || [])
setTargets(t.data) if (t.status === 'fulfilled') setTargets(t.value.data || [])
if (manuell) toast('success', 'Speicherziele aktualisiert') if (manuell) toast('success', 'Speicherziele aktualisiert')
} catch { } catch {
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden') if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
@@ -149,6 +150,20 @@ export default function StorageMounts() {
} }
} }
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Vorhandene Ziele */} {/* Vorhandene Ziele */}
@@ -190,6 +205,56 @@ export default function StorageMounts() {
</CardContent> </CardContent>
</Card> </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 */} {/* Ordner-Verwaltung */}
<Card> <Card>
<button <button
+59 -10
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react' import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
@@ -38,10 +38,20 @@ export default function WorkerVerwaltung() {
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [kopiert, setKopiert] = useState(false) const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux') const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
const [rippyHost, setRippyHost] = useState(window.location.hostname) // 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() 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 = const siehtOeffentlichAus =
rippyHost !== '' &&
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') && !rippyHost.endsWith('.local') &&
rippyHost !== 'localhost' && rippyHost !== 'localhost' &&
@@ -65,13 +75,13 @@ export default function WorkerVerwaltung() {
const installBefehl = variante === 'linux' const installBefehl = variante === 'linux'
? [ ? [
`git clone <rippy-repo-url> rippy && cd rippy`, `git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`, `RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`, ` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n') ].join('\n')
: [ : [
`iwr http://${rippyHost}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`, `iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``, `powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
` -RippyHost ${rippyHost} -WorkerName mein-pc`, ` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
].join('\n') ].join('\n')
const kopieren = () => { const kopieren = () => {
@@ -81,6 +91,16 @@ export default function WorkerVerwaltung() {
}) })
} }
// 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Verbundene Worker */} {/* Verbundene Worker */}
@@ -157,15 +177,22 @@ export default function WorkerVerwaltung() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="max-w-md"> <div className="max-w-md">
<Input <Input
label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)" label="LAN-IP der Rippy-Maschine (Docker-Host)"
value={rippyHost} value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())} onChange={e => setRippyHost(e.target.value.trim())}
placeholder="192.168.178.162" placeholder="z. B. 192.168.178.162"
/> />
<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 && ( {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"> <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). Worker verbinden sich Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die
direkt mit Redis/Postgres (Ports 6379/5432) das geht nur im Heimnetz. Hier die <strong>LAN-IP</strong> eintragen, z. B. 192.168.178.162. <strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
</p> </p>
)} )}
</div> </div>
@@ -183,10 +210,32 @@ export default function WorkerVerwaltung() {
))} ))}
</div> </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"> <p className="text-xs text-slate-500 dark:text-slate-400">
{variante === 'linux' {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.' ? '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.'
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch.'} : 'Alternativ (für Profis) per PowerShell-Befehl:'}
</p> </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"> <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">
+9 -5
View File
@@ -11,12 +11,16 @@ export function Card({ children, className = '', glow = false, onClick }: CardPr
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className={`rounded-2xl border transition-all duration-300 ${ className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
glow ? 'shadow-[0_0_30px_-5px_rgba(245,158,11,0.15)] border-amber-500/30' : 'shadow-lg shadow-black/5 border-slate-200/80 dark:border-slate-800/80' glow
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${ ? 'glass-panel-amber'
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : '' : '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}`} } ${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} {children}
</div> </div>
) )
@@ -32,7 +36,7 @@ export function CardHeader({ children, className = '' }: { children: ReactNode;
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) { export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
return ( return (
<h2 className={`text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}> <h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
{children} {children}
</h2> </h2>
) )
@@ -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>
)
}
+7 -25
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') document.documentElement.classList.add('dark')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches localStorage.setItem('theme', 'dark')
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
setTheme('dark')
document.documentElement.classList.add('dark')
} else {
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) document.documentElement.classList.add('dark')
localStorage.setItem('theme', newTheme)
if (newTheme === '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>
) )
+46 -2
View File
@@ -4,7 +4,11 @@
@layer base { @layer base {
body { body {
@apply m-0 bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-400; @apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
background-image:
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
background-attachment: fixed;
} }
} }
@@ -35,5 +39,45 @@
} }
.animate-disc-spin { .animate-disc-spin {
animation: disc-spin 14s linear infinite; 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
);
} }
+8 -6
View File
@@ -20,10 +20,10 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
} }
export default function AnleitungPage() { export default function AnleitungPage() {
const fett = "font-semibold text-slate-900 dark:text-slate-100" const fett = "font-semibold text-slate-100"
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-6xl mx-auto">
<PageHeader <PageHeader
title="Anleitung & Hilfe" title="Anleitung & Hilfe"
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server" subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
@@ -106,11 +106,13 @@ export default function AnleitungPage() {
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker"> <Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p> <p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker zeigt für Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker.
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ, Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
ohne Docker nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht (.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 danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk). automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
</p> </p>
<p> <p>
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
+434 -323
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react' import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, Layers } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { StatusBadge, TypeBadge } from '../components/ui/Badge' import { StatusBadge, TypeBadge } from '../components/ui/Badge'
@@ -10,7 +9,16 @@ import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery' import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal' import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
import { ENCODER_BADGES } from '../lib/design'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job { interface Job {
id: string id: string
@@ -22,6 +30,19 @@ interface Job {
progress: number progress: number
title?: string title?: string
can_retry?: boolean can_retry?: boolean
meta?: JobMeta | null
}
interface SystemInfo {
api_version: string
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
workers: { name: string; encoders: string[] }[]
}
function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
} }
async function fetchJobs(): Promise<Job[]> { async function fetchJobs(): Promise<Job[]> {
@@ -33,39 +54,14 @@ async function fetchJobs(): Promise<Job[]> {
} }
} }
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="w-32">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
<span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-300/30 dark:border-slate-700/30">
<div
className={`h-2 rounded-full transition-all duration-500 ease-out ${
progress === 100
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
: 'bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-600 shadow-sm shadow-indigo-500/50'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
}
interface WorkerInfo {
name: string
encoders: string[]
}
export default function Dashboard() { export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null) const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false) const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const { toast } = useToast() const { toast } = useToast()
const jobLoeschen = async (job: Job) => { const jobLoeschen = async (job: Job) => {
@@ -92,27 +88,51 @@ export default function Dashboard() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setJobs(await fetchJobs()) const [jobsData, sysData] = await Promise.all([
fetchJobs(),
api.get('/system/info').then(r => r.data).catch(() => null),
])
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
setSparklinePoints(prev => [...prev.slice(1), newPoint])
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
loadData() loadData()
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {}) const interval = setInterval(loadData, 4000)
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
const interval = setInterval(loadData, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending')
const completedJobs = jobs.filter(j => j.status === 'completed')
const failedJobs = jobs.filter(j => j.status === 'failed')
const stats = { const mainPlatz = systemInfo?.plaetze?.[0]
pending: jobs.filter(j => j.status === 'pending').length, const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
processing: jobs.filter(j => j.status === 'processing').length, const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
completed: jobs.filter(j => j.status === 'completed').length, const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
failed: jobs.filter(j => j.status === 'failed').length, const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
} const workerCount = systemInfo?.workers?.length || 1
const sparklinePath = sparklinePoints.map((val, idx) => {
const x = (idx / (sparklinePoints.length - 1)) * 100
const y = 50 - (val / 100) * 40
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const filteredJobs = jobs.filter(j => {
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
if (activeTab === 'queue') return j.status === 'pending'
if (activeTab === 'completed') return j.status === 'completed'
if (activeTab === 'failed') return j.status === 'failed'
return true
})
if (loading) { if (loading) {
return ( return (
@@ -123,291 +143,398 @@ export default function Dashboard() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<PageHeader
title="Dashboard" {/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
/>
{/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
{aktiverJob && (
<Card glow className="relative overflow-hidden border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 p-6 shadow-2xl">
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="relative flex-shrink-0">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 flex items-center justify-center p-1 shadow-xl shadow-amber-500/20 animate-disc-spin">
<div className="w-full h-full rounded-full border-4 border-dashed border-white/40 flex items-center justify-center">
<Disc size={36} className="text-white" />
</div>
</div>
<span className="absolute -top-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-amber-500"></span>
</span>
</div>
<div className="flex-1 min-w-0 text-center md:text-left space-y-2">
<div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
</span>
<TypeBadge type={aktiverJob.type} />
</div>
<h2 className="text-2xl font-bold text-slate-100 truncate">
{aktiverJob.title || 'Aktiver Job'}
</h2>
<p className="text-xs text-slate-400">
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
</p>
<div className="pt-2">
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
</div>
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
<div
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
style={{ width: `${aktiverJob.progress}%` }}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
</Button>
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
>
Abbrechen
</Button>
</div>
</div>
</Card>
)}
{/* System-Telemetrie Leiste */}
<Card>
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
<div className="flex items-center gap-2.5">
<Activity size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Encoder:</span>
{workers.length === 0 ? (
<span className="text-slate-400 dark:text-slate-500 italic">kein Worker</span>
) : (
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).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_BADGES[e]?.label || e}
</span>
))
)}
</div>
<div className="flex items-center gap-2.5">
<Clock size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
{aktiverJob ? (
<span className="text-slate-900 dark:text-slate-200 font-medium">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span>
) : (
<span className="text-slate-400 dark:text-slate-500 italic">kein Job</span>
)}
</div>
{plaetze.length > 0 && (
<div className="flex items-center gap-2.5 ml-auto">
<HardDrive size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
{plaetze.map(p => (
<span
key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${
p.frei_gb < 60
? 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30 font-semibold'
: 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
}`}
>
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span>
))}
</div>
)}
</CardContent>
</Card>
{/* Laufwerke & Erkennung */}
<DeviceDiscovery /> <DeviceDiscovery />
{/* Stats Cards */} {/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
<StatCard <CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
icon={Clock}
label="Warteschlange"
value={stats.pending.toString()}
badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20"
/>
<StatCard
icon={Activity}
label="Bearbeitung"
value={stats.processing.toString()}
badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20"
/>
<StatCard
icon={CheckCircle}
label="Fertig"
value={stats.completed.toString()}
badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20"
/>
<StatCard
icon={AlertCircle}
label="Fehler"
value={stats.failed.toString()}
badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20"
/>
</div>
{/* Recent Jobs Table */}
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Neueste Jobs</CardTitle>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span> <CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
<Layers size={22} className="text-amber-400" />
Ripping Operations &amp; Medien-Manager
</CardTitle>
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Master Filter Tabs */}
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
<button
onClick={() => setActiveTab('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'all'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Alle ({jobs.length})</span>
</button>
<button
onClick={() => setActiveTab('active')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'active'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{aktiverJob && <span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />}
<span>Aktiv ({aktiverJob ? 1 : 0})</span>
</button>
<button
onClick={() => setActiveTab('queue')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'queue'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Warteschlange ({queueJobs.length})</span>
</button>
<button
onClick={() => setActiveTab('completed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'completed'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Fertig ({completedJobs.length})</span>
</button>
{failedJobs.length > 0 && (
<button
onClick={() => setActiveTab('failed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'failed'
? 'bg-rose-500 text-white shadow-md font-bold'
: 'text-rose-400 hover:text-rose-200'
}`}
>
<span>Fehler ({failedJobs.length})</span>
</button>
)}
</div>
{jobs.length > 0 && ( {jobs.length > 0 && (
<a <a
href="/api/jobs/export" href="/api/jobs/export"
download download
title="Job-Historie als CSV herunterladen (öffnet in Excel)" title="Job-Historie als CSV herunterladen"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
> >
<FileSpreadsheet size={13} /> <FileSpreadsheet size={14} />
CSV CSV
</a> </a>
)} )}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<Button {completedJobs.length > 0 && (
variant="secondary" <Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
size="sm" <Trash2 size={13} /> Aufräumen
onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
>
<Trash2 size={13} />
Erledigte aufräumen
</Button> </Button>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="p-0">
<table className="w-full"> {/* Live Rip Banner inside Panel when a Job is active */}
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800"> {aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
<tr> <div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Typ</th> <div className="flex flex-col sm:flex-row items-center justify-between gap-6">
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Titel</th> <div className="flex items-center gap-4">
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Status</th> {posterUrl(aktiverJob.meta) ? (
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Gerät</th> <img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Fortschritt</th> ) : (
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Startzeit</th> <div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Aktion</th> <Disc size={32} className="text-amber-400 animate-disc-spin" />
</tr> </div>
</thead> )}
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
{jobs.length === 0 ? ( <div className="space-y-1">
<tr> <div className="flex items-center gap-2 flex-wrap">
<td colSpan={7} className="px-6 py-12 text-center"> <span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div> AKTIVER RIP IN ARBEIT
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p> </span>
</td> <TypeBadge type={aktiverJob.type} />
</tr> <StatusBadge status={aktiverJob.status} />
) : ( </div>
jobs.slice(0, 10).map(job => ( <h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40"> <p className="text-xs text-slate-400 font-mono">
<td className="px-6 py-4 whitespace-nowrap"> Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
<TypeBadge type={job.type} /> </p>
</td> </div>
<td className="px-6 py-4 whitespace-nowrap"> </div>
<button
onClick={() => setDetailJobId(job.id)} <div className="flex items-center gap-4 w-full sm:w-auto justify-end">
title="Details zu diesem Titel anzeigen" <div className="w-36 space-y-1 text-right">
className="text-sm font-medium text-left hover:underline text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors" <div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
> <div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
{job.title || 'Unbekannt'} <div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
</button> </div>
</td> </div>
<td className="px-6 py-4 whitespace-nowrap"> <Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
<StatusBadge status={job.status} /> Details
</td> </Button>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400"> <Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
{job.device} Abbrechen
</td> </Button>
<td className="px-6 py-4 whitespace-nowrap"> </div>
<ProgressBar progress={job.progress} /> </div>
</td> </div>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400"> )}
{new Date(job.startTime).toLocaleString('de-DE')}
</td> {/* Unified Jobs Table */}
<td className="px-6 py-4 whitespace-nowrap"> {filteredJobs.length === 0 ? (
{job.status === 'completed' && ( <div className="text-center py-16">
<Button <div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
variant="secondary" <Disc size={32} />
size="sm" </div>
onClick={() => setDetailJobId(job.id)} <p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
title="Fertige Dateien herunterladen (öffnet Job-Details)" <p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
> </div>
<Download size={13} className="text-emerald-500" /> ) : (
Download <div className="overflow-x-auto">
</Button> <table className="w-full text-left border-collapse">
)} <thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
{job.status === 'failed' && job.can_retry && ( <tr>
<Button <th className="px-6 py-3.5">Typ</th>
variant="secondary" <th className="px-6 py-3.5">Titel &amp; Inhaltsangabe</th>
size="sm" <th className="px-6 py-3.5">Status &amp; Phase</th>
onClick={() => { <th className="px-6 py-3.5">Laufwerk</th>
api.post(`/jobs/${job.id}/retry-transcode`) <th className="px-6 py-3.5">Fortschritt</th>
.then(() => toast('success', 'Kompression neu eingereiht')) <th className="px-6 py-3.5">Startzeit</th>
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen')) <th className="px-6 py-3.5 text-right">Aktionen</th>
}}
title="Kompression neu anstoßen"
>
Neu komprimieren
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
title="Job aus der Liste entfernen"
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
title="Job abbrechen"
>
Abbrechen
</Button>
)}
</td>
</tr> </tr>
)) </thead>
)} <tbody className="divide-y divide-slate-800/80 text-sm">
</tbody> {filteredJobs.map(job => {
</table> const poster = posterUrl(job.meta)
</div> return (
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{poster ? (
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80 shadow-md" />
) : (
<div className="w-10 h-14 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
<Film size={18} />
</div>
)}
<div className="min-w-0">
<button
onClick={() => setDetailJobId(job.id)}
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1 text-base"
>
{job.title || 'Unbekannter Titel'}
</button>
{job.meta?.overview && (
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
{job.meta.overview}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="w-32 space-y-1">
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
<span>{job.progress}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details
</Button>
{job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
</Button>
)}
{job.status === 'failed' && job.can_retry && (
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
}}
>
<RotateCcw size={13} /> Neu
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card> </Card>
{/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left: Server Status Card */}
<div className="lg:col-span-7">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200 flex items-center gap-2">
<Server size={18} className="text-amber-400" />
Server Status (Echte Live-Daten)
</CardTitle>
</CardHeader>
<CardContent className="p-5 flex-1 flex items-center">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
<div className="space-y-4 flex-1 w-full">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
<span className="text-slate-200">{workerCount} Worker Online</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
style={{ width: `${Math.max(10, belegtPercent)}%` }}
/>
</div>
</div>
</div>
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
<path
d={`${sparklinePath} L 100 50 L 0 50 Z`}
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right: Recently Ripped Posters */}
<div className="lg:col-span-5">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
{completedJobs.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col justify-center">
{completedJobs.length === 0 ? (
<div className="text-center py-6 text-slate-500 text-xs font-mono">
Noch keine gerippten Medien in der Datenbank
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{completedJobs.slice(0, 3).map((item) => {
const poster = posterUrl(item.meta)
return (
<div key={item.id} className="group relative rounded-xl overflow-hidden bg-slate-900 border border-slate-800 flex flex-col justify-between p-2">
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-slate-800">
{poster ? (
<img src={poster} alt={item.title || ''} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-500 p-2 text-center">
<Film size={24} className="text-amber-400 mb-1" />
<span className="text-[9px] uppercase font-bold text-slate-400">{item.type}</span>
</div>
)}
</div>
<div className="space-y-1 text-left">
<h4 className="text-xs font-bold text-slate-100 truncate" title={item.title}>{item.title || 'Unbekannt'}</h4>
<p className="text-[10px] text-slate-400 font-mono">{new Date(item.startTime).toLocaleDateString('de-DE')}</p>
<Button
variant="amber"
size="sm"
className="w-full text-[10px] py-1 h-auto"
onClick={() => setDetailJobId(item.id)}
>
Details
</Button>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* POS 4: Live-Log Section */}
<LiveLogSection /> <LiveLogSection />
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} /> <JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
@@ -424,19 +551,3 @@ export default function Dashboard() {
</div> </div>
) )
} }
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
return (
<Card>
<CardContent className="p-5 flex items-center gap-4">
<div className={`p-3 rounded-xl border ${badgeStyle}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
<p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
</div>
</CardContent>
</Card>
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ export default function LogsPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-5xl mx-auto">
<PageHeader <PageHeader
title="System-Logs" title="System-Logs"
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick" subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
+40 -10
View File
@@ -198,7 +198,7 @@ export default function SettingsPage() {
] ]
return ( return (
<div className="max-w-4xl space-y-6"> <div className="max-w-6xl mx-auto space-y-6">
<PageHeader <PageHeader
title="Einstellungen" title="Einstellungen"
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen" subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
@@ -213,7 +213,7 @@ export default function SettingsPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex border-b border-slate-200/80 dark:border-slate-800 overflow-x-auto bg-slate-50/50 dark:bg-slate-950/50"> <div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeTab === tab.id const isActive = activeTab === tab.id
return ( return (
@@ -222,11 +222,11 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${ className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
isActive isActive
? 'border-amber-500 text-amber-600 dark:text-amber-400 bg-white dark:bg-slate-900 font-semibold' ? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200' : 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
}`} }`}
> >
<tab.icon size={17} className={isActive ? 'text-amber-500' : ''} /> <tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
<span>{tab.label}</span> <span>{tab.label}</span>
</button> </button>
) )
@@ -636,6 +636,15 @@ export default function SettingsPage() {
</div> </div>
)) ))
)} )}
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) wichtig wegen der
Disc-Schlüssel-Datenbank.
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> für die Kompression völlig
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
dagegen automatisch die neueste HandBrake-Version.
</p>
</div> </div>
<Input <Input
@@ -653,15 +662,36 @@ export default function SettingsPage() {
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'} {updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
</Button> </Button>
</div> </div>
{updates && ( {updates ? (
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300"> <div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div> {/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'} <div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
{updates.makemkv?.update
? (
<p className="text-xs mt-1">
Update verfügbar in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
dann auf der Rippy-Maschine <code>docker compose build worker &amp;&amp; docker compose up -d worker</code>.
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
</p>
)
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400"> aktuell</span>}
</div> </div>
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
<div> <div>
<strong>HandBrake:</strong> installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'} <strong>HandBrake (Docker-Worker):</strong> {updates.handbrake?.installiert || '—'}
<span className="text-xs ml-1 text-slate-500 dark:text-slate-400"> stabile Debian-Version, bewusst so, kein Update nötig</span>
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
Neueste offizielle Version wäre {updates.handbrake?.verfuegbar || 'unbekannt'} die nutzen die
<strong> nativen Worker (Windows)</strong> automatisch. Der Docker-Worker bleibt bei der
Debian-Version (für die Kompression identisch, robustere Builds).
</p>
</div> </div>
</div> </div>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
</p>
)} )}
</div> </div>
</section> </section>
+6
View File
@@ -20,6 +20,12 @@ celery_app.conf.update(
timezone="UTC", timezone="UTC",
enable_utc=True, enable_utc=True,
broker_connection_retry_on_startup=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,
) )
+36 -4
View File
@@ -47,6 +47,22 @@ from ripping import (
API_URL = os.getenv("API_URL", "http://api:8000") API_URL = os.getenv("API_URL", "http://api:8000")
def _transcode_queue(node: str):
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
gewählter Worker via worker_direct, wenn online — sonst geteilte Queue."""
if not node:
return "transcode"
try:
from celery.utils import worker_direct
antworten = celery_app.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 pfad_lokal(pfad: str, mapping: str = None) -> str: def pfad_lokal(pfad: str, mapping: str = None) -> str:
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion). """Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
@@ -430,13 +446,18 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
) )
if ergebnis.get("status") == "success" and transcode_an: if ergebnis.get("status") == "success" and transcode_an:
# Kompression als eigener Task auf der transcode-Queue — kann vom # Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden. # (worker_direct), sonst an die geteilte transcode-Queue (irgendein
# freier Worker, inkl. Remote-GPU).
ziel_queue = _transcode_queue(meta.get("transcode_node"))
db.update_job(job_id, status="transcoding", progress=0) db.update_job(job_id, status="transcoding", progress=0)
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht") gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
db.add_log("info", "worker",
f"Job {job_id}: Rip fertig, Kompression eingereiht"
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
transcode_files.apply_async( transcode_files.apply_async(
args=[job_id, raw_dir, final_dir], args=[job_id, raw_dir, final_dir],
queue="transcode", queue=ziel_queue,
) )
return {"status": "ripped", "raw_dir": raw_dir} return {"status": "ripped", "raw_dir": raw_dir}
@@ -444,6 +465,17 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return ergebnis return ergebnis
@celery_app.task(name="worker.tasks.ping_worker")
def ping_worker():
"""Winziger Task zum Beweisen des gezielten Routings: gibt Hostname +
Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet
er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI)."""
import socket
import caps
return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()}
@celery_app.task(name="worker.tasks.scan_tracks") @celery_app.task(name="worker.tasks.scan_tracks")
def scan_tracks(device_path: str): def scan_tracks(device_path: str):
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle). """Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
+9 -6
View File
@@ -1,5 +1,9 @@
# Design 2.0 — Briefing (Übergabe an Gemini) # Design 2.0 — Briefing (Übergabe an Gemini)
> ✅ **ERLEDIGT (24.07.2026).** Design 2.0 wurde umgesetzt und ist in `main`.
> Der Arbeitsbranch `design-2.0` wurde nach dem Merge gelöscht (es gibt nur
> noch `main`). Dieses Dokument bleibt als historische Auftragsbeschreibung.
>
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau > Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini) > „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude. > eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
@@ -224,12 +228,11 @@ den dann fertigen Primitives).
## 7. Arbeitsweise & Branch ## 7. Arbeitsweise & Branch
- **Auf einem eigenen Branch arbeiten** (angelegt: `design-2.0`). Wichtig: - **Auf einem eigenen Branch arbeiten** (war: `design-2.0`). Ein
Die Deploy-Kette befördert **nur `main`** automatisch auf `stable` und Feature-Branch lässt die Ampel laufen (Feedback grün/rot), deployt aber
von dort auf die Live-VM. Ein Feature-Branch lässt die Ampel laufen nicht — ideal für einen großen Umbau; nach Abnahme nach `main` gemerged.
(Feedback grün/rot), deployt aber NICHT — ideal für einen großen Umbau. (Hinweis: Seit 24.07.2026 ist `main` der einzige Branch — deployt wird
Erst wenn Design 2.0 fertig & abgenommen ist, wird der Branch nach `main` direkt daraus, die Ampel prüft nur noch.)
gemerged.
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives: - **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
Deutsch, WAS + WARUM. Deutsch, WAS + WARUM.
-52
View File
@@ -1,52 +0,0 @@
# Rippy udev-Setup
## Problem
Rippy findet kein Laufwerk, obwohl eines am Host angeschlossen ist.
## Ursache
Die `docker-compose.yml` nutzte anonyme Volumes für `/dev/disc`, `/dev/cdrom`, `/dev/dvd`:
```yaml
volumes:
- disc:/dev/disc # ❌ Anonymes Volume, leer
```
## Lösung
Direkte Mounts vom Host in den Worker-Container:
### 1. docker-compose.yml anpassen
```yaml
volumes:
- /dev/cdrom:/dev/cdrom:ro
- /dev/dvd:/dev/dvd:ro
- /dev/sr0:/dev/sr0:ro
- disc:/dev/disc # ✅ Shared-Mount für udev-Symlinks
```
### 2. Worker-Dockerfile erweitern
- `isoinfo` für Disc-Typ-Erkennung
- `udev` für Geräteverwaltung
### 3. udev-Regel installieren
Kopiere `99-disc-ripper.rules` nach `/etc/udev/rules.d/`
### 4. udev-Daemon ausführen
Der Daemon (`udev_daemon.py`) läuft im Worker-Container und erstellt Symlinks mit UUID/Serial.
## Installation auf Arcane VM
```bash
cd /path/to/rippy/udev
bash install-arcane.sh
```
## Testen
1. Leg eine CD/DVD ein
2. Logs prüfen: `docker logs -f rippy-worker`
3. Symlinks: `docker exec rippy-worker ls -la /dev/disc`
4. Devices: `docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0`
## Dateien
- `99-disc-ripper.rules` — udev-Regel für Einwurf/Auswurf
- `udev_daemon.py` — Daemon für Disc-Erkennung
- `rippy-udev.service` — systemd-Service (optional)
- `install-arcane.sh` — Installations-Skript für Arcane VM
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
# Installations-Skript für Arcane VM (Debian 12)
# Auf der Arcane VM ausführen
set -e
echo "=== Rippy udev-Setup für Arcane VM ==="
# 1. udev-Regel installieren
echo "Installiere udev-Regel..."
cp 99-disc-ripper.rules /etc/udev/rules.d/
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
# 2. udev-Daemon in Worker-Container kopieren
echo "Kopiere udev-Daemon in Worker-Container..."
docker cp udev_daemon.py rippy-worker:/app/udev/
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
echo "✅ udev-Daemon und Regeln in Container kopiert"
# 3. udev-Regel neu laden (im Host)
echo "Lade udev-Regeln im Host neu..."
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln im Host neu geladen"
# 4. udev-Regel in Container neu laden
echo "Lade udev-Regeln im Container neu..."
docker exec -i rippy-worker udevadm control --reload-rules
docker exec -i rippy-worker udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln im Container neu geladen"
# 5. Service aktivieren (optional - wenn im Container als systemd läuft)
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
echo "Aktiviere systemd-Service..."
docker exec -i rippy-worker systemctl daemon-reload
docker exec -i rippy-worker systemctl enable rippy-udev
echo "✅ systemd-Service aktiviert"
fi
echo "=== Setup abgeschlossen ==="
echo ""
echo "Testen:"
echo " 1. Leg eine CD/DVD ein"
echo " 2. Logs prüfen: docker logs -f rippy-worker"
echo " 3. Symlinks: docker exec rippy-worker ls -la /dev/disc"
echo " 4. Devices: docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0"
echo ""
echo "Hinweis: /dev/disc ist ein Shared-Mount, der udev-Daemon erstellt Symlinks"
echo " mit UUID/Serial für jedes erkannte Laufwerk."
-43
View File
@@ -1,43 +0,0 @@
#!/bin/bash
# Installations-Skript für Rippy udev-Regel + Service
# Auf dem Host (Arcane VM / Proxmox LXC) ausführen
set -e
echo "=== Rippy udev-Setup ==="
# Prüfen ob wir root sind
if [ "$EUID" -ne 0 ]; then
echo "❌ Bitte als root ausführen (sudo bash install-udev.sh)"
exit 1
fi
# 1. udev-Regel installieren
echo "Installiere udev-Regel..."
cp 99-disc-ripper.rules /etc/udev/rules.d/
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
# 2. udev-Daemon in den Container kopieren
echo "Kopiere udev-Daemon in Worker-Container..."
docker cp udev_daemon.py rippy-worker:/app/udev/
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
echo "✅ udev-Daemon und Regeln in Container kopiert"
# 3. udev-Regel neu laden
echo "Lade udev-Regeln neu..."
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln neu geladen"
# 4. Service aktivieren (optional - wenn im Container als systemd läuft)
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
echo "Aktiviere systemd-Service..."
docker exec -i rippy-worker systemctl daemon-reload
docker exec -i rippy-worker systemctl enable rippy-udev
echo "✅ systemd-Service aktiviert"
fi
echo "=== Setup abgeschlossen ==="
echo "Teste Disc-Einwurf: Leg eine CD/DVD ein"
echo "Prüfe Logs: docker logs -f rippy-worker"
-29
View File
@@ -1,29 +0,0 @@
# systemd-Service für Rippy udev-Daemon
# Diese Datei muss nach /etc/systemd/system/rippy-udev.service kopiert werden
[Unit]
Description=Rippy Disc Detection Daemon
After=dev-disc.device
Requires=dev-disc.device
[Service]
Type=simple
ExecStart=/usr/bin/python3 /app/udev_daemon.py
Restart=always
RestartSec=5
WorkingDirectory=/app
User=root
Group=root
# Environment
Environment=REDIS_URL=redis://redis:6379/0
Environment=PATH=/usr/local/bin:/usr/bin:/bin
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""
udev-Daemon für Rippy
Erkennt Disc-Einwurf/-auswurf und erstellt Celery-Jobs.
"""
import os
import sys
import subprocess
import redis
import json
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
DISC_DEVICE_PATH = "/dev/disc"
# Redis verbinden
def get_redis_client():
try:
return redis.from_url(REDIS_URL)
except Exception as e:
print(f"Fehler beim Verbinden mit Redis: {e}")
sys.exit(1)
def get_device_info(device_name: str) -> dict:
"""Ermittle Device-Info via udevadm."""
try:
result = subprocess.run(
["udevadm", "info", "-q", "property", "-n", f"/dev/{device_name}"],
capture_output=True,
text=True,
timeout=5
)
info = {}
for line in result.stdout.strip().split('\n'):
if '=' in line:
key, value = line.split('=', 1)
info[key] = value
return info
except Exception as e:
print(f"Fehler beim Auslesen von Device {device_name}: {e}")
return {}
def create_device_symlink(device_name: str, info: dict) -> str:
"""Erstelle Symlink mit UUID/Serial-Nummer."""
serial = info.get("ID_SERIAL", "unknown")
model = info.get("ID_MODEL", "unknown")
symlink_name = f"{model}_{serial}".replace(" ", "_")
symlink_path = f"{DISC_DEVICE_PATH}/{symlink_name}"
os.makedirs(DISC_DEVICE_PATH, exist_ok=True)
# Alten Symlink entfernen falls vorhanden
if os.path.exists(symlink_path):
os.unlink(symlink_path)
# Neuen Symlink erstellen
os.symlink(f"/dev/{device_name}", symlink_path)
return symlink_path
def delete_device_symlink(device_name: str):
"""Lösche Symlink beim Auswurf."""
symlink_path = f"{DISC_DEVICE_PATH}/{device_name}"
if os.path.exists(symlink_path):
os.unlink(symlink_path)
def detect_disc_type(device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
try:
result = subprocess.run(
["isoinfo", "-d", "-i", device_path],
capture_output=True,
text=True,
timeout=10
)
output = result.stdout.lower()
if "rock ridge" in output or "joliet" in output:
if "blu-ray" in output:
return "bluray"
return "dvd"
elif "cda" in output:
return "cd"
return "unknown"
except Exception:
return "unknown"
def create_job(device_path: str, disc_type: str):
"""Erstelle Celery-Job für Disc-Erkennung."""
try:
client = get_redis_client()
job_data = {
"device_path": device_path,
"disc_type": disc_type,
"timestamp": str(int(subprocess.check_output(["date", "+%s"]).decode().strip())),
"status": "pending"
}
client.lpush("rippy:jobs", json.dumps(job_data))
print(f"Job erstellt: {disc_type} -> {device_path}")
except Exception as e:
print(f"Fehler beim Erstellen des Jobs: {e}")
def handle_add(device_name: str):
"""Handle Disc-Einwurf."""
print(f"Disc-Einwurf erkannt: {device_name}")
info = get_device_info(device_name)
if not info:
print("Konnte Device-Info nicht auslesen")
return
symlink_path = create_device_symlink(device_name, info)
print(f"Symlink erstellt: {symlink_path}")
disc_type = detect_disc_type(symlink_path)
print(f"Disc-Typ erkannt: {disc_type}")
create_job(symlink_path, disc_type)
def handle_remove(device_name: str):
"""Handle Disc-Auswurf."""
print(f"Disc-Auswurf erkannt: {device_name}")
delete_device_symlink(device_name)
print(f"Symlink gelöscht: /dev/disc/{device_name}")
def main():
"""Hauptfunktion."""
if len(sys.argv) < 3:
print("Usage: udev_daemon.py <action> <device>")
sys.exit(1)
action = sys.argv[1]
device = sys.argv[2]
if action == "add":
handle_add(device)
elif action == "remove":
handle_remove(device)
else:
print(f"Unbekannte Aktion: {action}")
if __name__ == "__main__":
main()