Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0832ef777c | |||
| 6a082cf77c | |||
| 46a8f50c34 | |||
| 3f47b504c2 | |||
| 71c618073e | |||
| 20cdfedb85 | |||
| b1d9ece9b2 | |||
| b337525bed | |||
| 4fb29d16cf | |||
| 57171b4c88 | |||
| 3aef344b95 | |||
| dc1ceda0c2 | |||
| 2de0899ac6 | |||
| 47d07d6168 | |||
| 4ba02047db | |||
| fe1387b1ec | |||
| 67a4a76afa | |||
| b130eeb967 | |||
| bf80f9dfd4 | |||
| e0864828c2 | |||
| 60abf42359 | |||
| e18315814c | |||
| 9b19b74d39 | |||
| 9bc8ad11b2 | |||
| 0f20d9d8f4 | |||
| f0a8455fc2 | |||
| 3643f74eb1 | |||
| b8162753a9 |
+14
-3
@@ -1,14 +1,25 @@
|
||||
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||
# 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
|
||||
|
||||
# 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,
|
||||
# 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
|
||||
# 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=
|
||||
# TVDb (optional, Serien-Fallback)
|
||||
THETVDB_API_KEY=
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
||||
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
||||
*.sh text eol=lf
|
||||
*.exe binary
|
||||
*.ico binary
|
||||
|
||||
@@ -72,13 +72,12 @@ jobs:
|
||||
fi
|
||||
exit $rot
|
||||
|
||||
- name: Grün-Gate — grünen Stand auf 'stable' befördern (Arcane deployt von dort)
|
||||
- name: Ergebnis
|
||||
if: success()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ]; then
|
||||
git push origin +HEAD:refs/heads/stable
|
||||
echo "✅ main → stable befördert. Arcane-GitSync zieht 'stable' und deployt."
|
||||
else
|
||||
echo "Kein main-Push — keine Beförderung."
|
||||
fi
|
||||
# Single Source of Truth: 'main' (24.07.2026, Commander-Entscheid —
|
||||
# der stable-Zwischenbranch ist weg). Die Ampel PRÜFT nur noch und
|
||||
# befördert nichts; deployt wird direkt aus 'main' (git pull auf der
|
||||
# VM bzw. ./deploy.sh). Rot heißt weiterhin: nicht deployen.
|
||||
echo "✅ AMPEL GRÜN. Deploy-Quelle ist 'main' — von dort deployen."
|
||||
|
||||
@@ -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.
|
||||
(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` →
|
||||
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und
|
||||
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist
|
||||
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen.
|
||||
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||
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.)
|
||||
|
||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||
|
||||
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
||||
|
||||
### Laufwerk anpassen
|
||||
|
||||
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
|
||||
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
|
||||
`docker-compose.override.yml` an:
|
||||
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
||||
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
||||
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
||||
(auf frischen Systemen oft `/dev/sg0`).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
devices: ["/dev/sr1:/dev/sr0"]
|
||||
worker:
|
||||
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
|
||||
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
||||
```bash
|
||||
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
||||
# oder: ls -la /sys/class/scsi_generic/
|
||||
```
|
||||
|
||||
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
|
||||
`ls -la /sys/class/scsi_generic/`.
|
||||
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
||||
```bash
|
||||
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
|
||||
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 |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
@@ -165,7 +171,16 @@ Voraussetzungen auf dem Ziel-Host:
|
||||
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
||||
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
||||
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
|
||||
--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
|
||||
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
|
||||
|
||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Grün auf
|
||||
`main` wird automatisch auf `stable` befördert — deploye von `stable`.
|
||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||
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) ·
|
||||
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
+89
-1
@@ -1,6 +1,94 @@
|
||||
# SAVEPOINT — Rippy
|
||||
|
||||
## Aktueller Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
|
||||
## Aktueller Stand: v3.9 — Windows-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 →
|
||||
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# NOTFALL-Deploy für Rippy (Normalweg = Grün-Gate: push → Ampel grün → CI
|
||||
# befördert auf 'stable' → Arcane-GitSync deployt).
|
||||
# Dieses Skript nur nutzen, wenn der Automat klemmt (Arcane down o. ä.).
|
||||
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||
# 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
|
||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
||||
# im Arcane-Verzeichnis kollidiert mit dessen Promote (read-only .git-Objekte).
|
||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
||||
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||
# reicht ohnehin der einfache Weg (siehe README):
|
||||
# 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
|
||||
|
||||
VM="arcane@192.168.178.162"
|
||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||
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
|
||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
||||
git clone --branch stable https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
||||
REPO_URL="$1"
|
||||
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
|
||||
cd ~/notfall-rippy
|
||||
git fetch origin stable
|
||||
git reset --hard origin/stable
|
||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
||||
cd "$CLONE"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||
docker compose -p rippy up -d --build $DIENST
|
||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"):
|
||||
# 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)
|
||||
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||
# 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).
|
||||
#
|
||||
# Aufruf (PowerShell):
|
||||
@@ -22,7 +22,17 @@ param(
|
||||
)
|
||||
|
||||
$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"
|
||||
|
||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||
@@ -82,7 +92,7 @@ Write-Host "HandBrakeCLI bereit."
|
||||
# 5. Start-Skript erzeugen
|
||||
$bat = @"
|
||||
@echo off
|
||||
rem Rippy Windows-Transcode-Worker — verbindet sich mit $RippyHost
|
||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
@@ -96,7 +106,7 @@ Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||
$trayBat = @"
|
||||
@echo off
|
||||
rem Rippy-Worker mit Tray-Symbol — verbindet sich mit $RippyHost
|
||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
@@ -109,7 +119,7 @@ start "" venv\Scripts\pythonw.exe tray.py
|
||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||
|
||||
# 7. Deinstaller — MUSS-Kriterium: rueckstandsfrei entfernbar
|
||||
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
||||
$uninstall = @"
|
||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+12
-11
@@ -11,7 +11,7 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/api/Dockerfile
|
||||
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
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
propagation: rshared
|
||||
- temp:/app/temp
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||
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
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||
@@ -93,13 +93,14 @@ services:
|
||||
propagation: rslave
|
||||
- temp:/app/temp
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
||||
# neu starten.
|
||||
- /dev/sg1:/dev/sg1
|
||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -132,7 +133,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=rippy
|
||||
- POSTGRES_PASSWORD=rippy
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||
- POSTGRES_DB=rippy
|
||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||
|
||||
@@ -21,6 +21,10 @@ COPY docker/api/ .
|
||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||
# die Zielmaschine braucht weder git noch Docker.
|
||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||
COPY deploy/worker-windows/install.ps1 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"]
|
||||
|
||||
@@ -7,12 +7,33 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.utils import worker_direct
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
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):
|
||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||
return celery_client.send_task(
|
||||
|
||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
||||
try:
|
||||
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:
|
||||
raise ConfigValidationError(
|
||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
||||
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
+145
-32
@@ -12,6 +12,7 @@ import uuid
|
||||
|
||||
import db
|
||||
import devices as device_discovery
|
||||
import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
import notify
|
||||
from celery_client import celery_client, start_rip
|
||||
@@ -46,13 +47,22 @@ async def startup_event():
|
||||
except ConfigValidationError as 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():
|
||||
for meldung in mount_verwaltung.alle_remounten():
|
||||
db.add_log("info", "mounts", meldung)
|
||||
await asyncio.to_thread(remount)
|
||||
asyncio.create_task(asyncio.to_thread(remount))
|
||||
|
||||
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,
|
||||
@@ -338,6 +348,7 @@ class JobCreateRequest(BaseModel):
|
||||
season: Optional[int] = None
|
||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||
|
||||
|
||||
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]
|
||||
if 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
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -511,7 +524,15 @@ async def storage_targets():
|
||||
return ziele
|
||||
for name in eintraege:
|
||||
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
|
||||
try:
|
||||
nutzung = shutil.disk_usage(pfad)
|
||||
@@ -521,7 +542,7 @@ async def storage_targets():
|
||||
ziele.append({
|
||||
"name": name,
|
||||
"path": pfad,
|
||||
"is_mount": os.path.ismount(pfad),
|
||||
"is_mount": ist_mount,
|
||||
"free_gb": frei_gb,
|
||||
})
|
||||
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'}"
|
||||
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(
|
||||
"worker.tasks.transcode_files",
|
||||
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.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
|
||||
@@ -639,18 +666,23 @@ async def capabilities():
|
||||
zeilen = db.list_workers()
|
||||
try:
|
||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||
online_namen = {
|
||||
knoten.split("@", 1)[-1]
|
||||
for antwort in antworten
|
||||
for knoten in antwort.keys()
|
||||
}
|
||||
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
|
||||
except Exception:
|
||||
online_namen = set()
|
||||
ping_knoten = []
|
||||
for zeile in zeilen:
|
||||
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem
|
||||
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info.
|
||||
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
||||
# 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"]
|
||||
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 {"workers": await asyncio.to_thread(sammle)}
|
||||
@@ -806,39 +838,67 @@ class MountRequest(BaseModel):
|
||||
|
||||
@app.get("/storage-mounts")
|
||||
async def get_storage_mounts():
|
||||
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
|
||||
eintraege = await asyncio.to_thread(db.list_mounts)
|
||||
return [
|
||||
{
|
||||
"name": e["name"],
|
||||
"type": e["typ"],
|
||||
"source": e["quelle"],
|
||||
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
|
||||
"has_credentials": bool(e.get("username")),
|
||||
}
|
||||
for e in eintraege
|
||||
]
|
||||
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Status.
|
||||
|
||||
`mounted` = liegt ein Mount an; `reachable` = ist er auch WIRKLICH nutzbar
|
||||
(toter CIFS-Mount nach NAS-Ausfall: mounted=true, reachable=false → das UI
|
||||
bietet dann „Reparieren" statt den Eintrag verschwinden zu lassen)."""
|
||||
def sammle():
|
||||
eintraege = db.list_mounts()
|
||||
ergebnis = []
|
||||
for e in eintraege:
|
||||
gemountet = mount_verwaltung.ist_gemountet(e["name"])
|
||||
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)
|
||||
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):
|
||||
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
|
||||
if request.type not in ("nfs", "cifs"):
|
||||
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:
|
||||
schreibbar = await asyncio.to_thread(
|
||||
mount_verwaltung.mounten,
|
||||
aktion,
|
||||
request.name, request.type, request.source,
|
||||
request.options or "", request.username or "", request.password or "",
|
||||
)
|
||||
except RuntimeError as 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(
|
||||
db.save_mount,
|
||||
request.name, request.type, request.source,
|
||||
@@ -847,10 +907,37 @@ async def create_storage_mount(request: MountRequest):
|
||||
await asyncio.to_thread(
|
||||
db.add_log,
|
||||
"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)"),
|
||||
)
|
||||
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")
|
||||
@@ -1012,6 +1099,32 @@ async def worker_setup_windows():
|
||||
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")
|
||||
async def worker_setup_paket():
|
||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||
|
||||
@@ -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
@@ -30,7 +30,31 @@ def validiere_name(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:
|
||||
@@ -49,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
|
||||
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:
|
||||
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||
|
||||
@@ -121,9 +169,21 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
Mount-Fehlern.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||
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
|
||||
try:
|
||||
@@ -169,22 +229,48 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
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:
|
||||
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(
|
||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}"
|
||||
)
|
||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||
_lazy_umount(ziel)
|
||||
try:
|
||||
os.rmdir(ziel)
|
||||
except OSError:
|
||||
pass # nicht leer oder weg — egal
|
||||
|
||||
|
||||
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
username: str = "", passwort: str = "") -> bool:
|
||||
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
||||
|
||||
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||
"""
|
||||
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:
|
||||
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
||||
meldungen = []
|
||||
|
||||
@@ -42,3 +42,18 @@ def test_worker_task_name_passt_zum_celery_client():
|
||||
|
||||
quelle = inspect.getsource(celery_client.start_rip)
|
||||
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)"
|
||||
|
||||
@@ -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-")
|
||||
@@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
||||
def test_validiere_name_bleibt_streng():
|
||||
assert validiere_name("nas-filme")
|
||||
assert not validiere_name("NAS Filme")
|
||||
|
||||
|
||||
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
aufrufe = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
aufrufe.append(cmd)
|
||||
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||
|
||||
|
||||
def test_mounten_loest_stale_vor_dem_mount():
|
||||
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||
import inspect
|
||||
|
||||
import mounts
|
||||
|
||||
quelle = inspect.getsource(mounts.mounten)
|
||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM postgres:16
|
||||
|
||||
COPY init.sql /docker-entrypoint-initdb.d/
|
||||
|
||||
EXPOSE 5432
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE DATABASE rippy;
|
||||
CREATE USER rippy WITH PASSWORD 'rippy123';
|
||||
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
|
||||
@@ -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"]
|
||||
@@ -1,6 +0,0 @@
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
loglevel notice
|
||||
databases 16
|
||||
+52
-73
@@ -1,109 +1,88 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react'
|
||||
import { api } from './lib/api'
|
||||
import { useDarkMode } from './context/ThemeContext'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
export default function App() {
|
||||
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 { theme, toggleTheme } = useDarkMode()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/setup')
|
||||
.then(r => setSetupDone(!!r.data.done))
|
||||
.catch(() => setSetupDone(true)) // API nicht erreichbar → kein Wizard-Zwang
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
}, [])
|
||||
|
||||
if (setupDone === false) {
|
||||
return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
||||
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||
{ 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 (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans">
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Cinema Sidebar */}
|
||||
<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="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
<Disc size={26} className="text-white animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
Rippy
|
||||
</h1>
|
||||
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||
{/* Top Header Navigation Bar */}
|
||||
<header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
|
||||
<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">
|
||||
RIPPY
|
||||
</span>
|
||||
<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">
|
||||
CINEMA OS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 mb-2">
|
||||
<button
|
||||
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">
|
||||
{/* Central Nav Tabs */}
|
||||
<nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
|
||||
{navItems.map((item) => {
|
||||
const isActive = page === item.id
|
||||
const Icon = item.icon
|
||||
const isActive = currentPage === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setPage(item.id as Page)}
|
||||
className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||
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'
|
||||
: 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent'
|
||||
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
|
||||
: '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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80">
|
||||
Created with <span className="text-rose-400">❤️</span> by LucyAI, Claude & KrBrZ
|
||||
</footer>
|
||||
</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 className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span>ONLINE</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
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() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -78,6 +88,8 @@ export default function DeviceDiscovery() {
|
||||
...(targetDir ? { target_dir: targetDir } : {}),
|
||||
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||
})
|
||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||
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 & Poster</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Jellyfin, Plex & 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 & Subtitles erhalten.</p>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-slate-400">
|
||||
x265 HandBrake Kompression
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
import { Input, Select } from './ui/Input'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -18,6 +18,18 @@ export interface RipOptionen {
|
||||
season?: number
|
||||
mainFeatureOnly?: boolean
|
||||
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 {
|
||||
@@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const [scanFehler, setScanFehler] = useState('')
|
||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||
|
||||
const scanStarten = async () => {
|
||||
if (!deviceId) return
|
||||
@@ -109,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setScanFehler('')
|
||||
setTitelListe([])
|
||||
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 => {
|
||||
const s = r.data || {}
|
||||
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) {
|
||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||
}
|
||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (customPath) {
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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) */}
|
||||
{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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
@@ -11,6 +11,7 @@ interface Mount {
|
||||
type: string
|
||||
source: string
|
||||
mounted: boolean
|
||||
reachable?: boolean
|
||||
has_credentials: boolean
|
||||
}
|
||||
|
||||
@@ -70,12 +71,12 @@ export default function StorageMounts() {
|
||||
|
||||
const laden = async (manuell = false) => {
|
||||
try {
|
||||
const [m, t] = await Promise.all([
|
||||
const [m, t] = await Promise.allSettled([
|
||||
api.get('/storage-mounts'),
|
||||
api.get('/storage-targets'),
|
||||
])
|
||||
setMounts(m.data)
|
||||
setTargets(t.data)
|
||||
if (m.status === 'fulfilled') setMounts(m.value.data || [])
|
||||
if (t.status === 'fulfilled') setTargets(t.value.data || [])
|
||||
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||
} catch {
|
||||
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Vorhandene Ziele */}
|
||||
@@ -190,6 +205,56 @@ export default function StorageMounts() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
|
||||
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
|
||||
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
|
||||
{mounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive size={20} className="text-amber-500" />
|
||||
<span>Netzwerk-Mounts</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2.5">
|
||||
{mounts.map(m => {
|
||||
const aktiv = m.mounted && m.reachable !== false
|
||||
const tot = m.mounted && m.reachable === false
|
||||
return (
|
||||
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{aktiv
|
||||
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
||||
: tot
|
||||
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
||||
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
||||
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
||||
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{!aktiv && (
|
||||
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
||||
<Wrench size={14} /> Reparieren
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Ordner-Verwaltung */}
|
||||
<Card>
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
@@ -38,10 +38,20 @@ export default function WorkerVerwaltung() {
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [kopiert, setKopiert] = useState(false)
|
||||
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()
|
||||
|
||||
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
|
||||
|
||||
const siehtOeffentlichAus =
|
||||
rippyHost !== '' &&
|
||||
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
||||
!rippyHost.endsWith('.local') &&
|
||||
rippyHost !== 'localhost' &&
|
||||
@@ -65,13 +75,13 @@ export default function WorkerVerwaltung() {
|
||||
const installBefehl = variante === 'linux'
|
||||
? [
|
||||
`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`,
|
||||
].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 \``,
|
||||
` -RippyHost ${rippyHost} -WorkerName mein-pc`,
|
||||
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||
].join('\n')
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Verbundene Worker */}
|
||||
@@ -157,15 +177,22 @@ export default function WorkerVerwaltung() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
|
||||
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||
value={rippyHost}
|
||||
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 && (
|
||||
<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
|
||||
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.
|
||||
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,10 +210,32 @@ export default function WorkerVerwaltung() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
|
||||
{variante === 'windows' && (
|
||||
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
|
||||
im Fenster Adresse + Name eintragen, „Installieren". Kein Konsolenfenster.
|
||||
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
|
||||
</p>
|
||||
<Button variant="amber" onClick={exeHerunterladen}>
|
||||
<Download size={16} /> Installer herunterladen (.exe)
|
||||
</Button>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
|
||||
→ „Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
|
||||
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{variante === 'linux'
|
||||
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||
: '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>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -11,12 +11,16 @@ export function Card({ children, className = '', glow = false, onClick }: CardPr
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`rounded-2xl border transition-all duration-300 ${
|
||||
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'
|
||||
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${
|
||||
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : ''
|
||||
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||
glow
|
||||
? 'glass-panel-amber'
|
||||
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||
} text-slate-900 dark:text-slate-100 ${
|
||||
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Top subtle highlight line for 3D glass depth */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -32,7 +36,7 @@ export function CardHeader({ children, className = '' }: { children: ReactNode;
|
||||
|
||||
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
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}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,25 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
import { createContext, useContext, useEffect, ReactNode } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
theme: 'dark'
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
setTheme('dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
setTheme('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
// 100% Dark Mode locked per Commander directive
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
|
||||
+46
-2
@@ -4,7 +4,11 @@
|
||||
|
||||
@layer base {
|
||||
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 {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
|
||||
}
|
||||
|
||||
export default function AnleitungPage() {
|
||||
const fett = "font-semibold text-slate-900 dark:text-slate-100"
|
||||
const fett = "font-semibold text-slate-100"
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
<PageHeader
|
||||
title="Anleitung & Hilfe"
|
||||
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
||||
@@ -106,11 +106,13 @@ export default function AnleitungPage() {
|
||||
|
||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||
<p>
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker zeigt für
|
||||
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ,
|
||||
ohne Docker — nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
|
||||
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
|
||||
„Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
|
||||
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
|
||||
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
|
||||
</p>
|
||||
<p>
|
||||
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
||||
|
||||
+434
-323
@@ -1,8 +1,7 @@
|
||||
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 { useToast } from '../context/ToastContext'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
||||
@@ -10,7 +9,16 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
import JobDetailModal from '../components/JobDetailModal'
|
||||
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 {
|
||||
id: string
|
||||
@@ -22,6 +30,19 @@ interface Job {
|
||||
progress: number
|
||||
title?: string
|
||||
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[]> {
|
||||
@@ -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() {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||
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 jobLoeschen = async (job: Job) => {
|
||||
@@ -92,27 +88,51 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
||||
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
|
||||
const interval = setInterval(loadData, 5000)
|
||||
const interval = setInterval(loadData, 4000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
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 = {
|
||||
pending: jobs.filter(j => j.status === 'pending').length,
|
||||
processing: jobs.filter(j => j.status === 'processing').length,
|
||||
completed: jobs.filter(j => j.status === 'completed').length,
|
||||
failed: jobs.filter(j => j.status === 'failed').length,
|
||||
}
|
||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||
const workerCount = systemInfo?.workers?.length || 1
|
||||
|
||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||
const y = 50 - (val / 100) * 40
|
||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
}).join(' ')
|
||||
|
||||
const 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) {
|
||||
return (
|
||||
@@ -123,291 +143,398 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
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 */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
|
||||
<DeviceDiscovery />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
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>
|
||||
{/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
|
||||
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
|
||||
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
|
||||
<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 & 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 && (
|
||||
<a
|
||||
href="/api/jobs/export"
|
||||
download
|
||||
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
|
||||
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"
|
||||
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-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<FileSpreadsheet size={13} />
|
||||
<FileSpreadsheet size={14} />
|
||||
CSV
|
||||
</a>
|
||||
)}
|
||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setAufraeumenOffen(true)}
|
||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Erledigte aufräumen
|
||||
|
||||
{completedJobs.length > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||
<Trash2 size={13} /> Aufräumen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.slice(0, 10).map(job => (
|
||||
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<TypeBadge type={job.type} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
title="Details zu diesem Titel anzeigen"
|
||||
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"
|
||||
>
|
||||
{job.title || 'Unbekannt'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
|
||||
{job.device}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<ProgressBar progress={job.progress} />
|
||||
</td>
|
||||
<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>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{job.status === 'completed' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
title="Fertige Dateien herunterladen (öffnet Job-Details)"
|
||||
>
|
||||
<Download size={13} className="text-emerald-500" />
|
||||
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 || 'Neu anstoßen fehlgeschlagen'))
|
||||
}}
|
||||
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>
|
||||
<CardContent className="p-0">
|
||||
{/* Live Rip Banner inside Panel when a Job is active */}
|
||||
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
|
||||
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{posterUrl(aktiverJob.meta) ? (
|
||||
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
|
||||
) : (
|
||||
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
|
||||
<Disc size={32} className="text-amber-400 animate-disc-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
|
||||
AKTIVER RIP IN ARBEIT
|
||||
</span>
|
||||
<TypeBadge type={aktiverJob.type} />
|
||||
<StatusBadge status={aktiverJob.status} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
|
||||
<p className="text-xs text-slate-400 font-mono">
|
||||
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||
<div className="w-36 space-y-1 text-right">
|
||||
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="amber" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Jobs Table */}
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<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">
|
||||
<Disc size={32} />
|
||||
</div>
|
||||
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5">Typ</th>
|
||||
<th className="px-6 py-3.5">Titel & Inhaltsangabe</th>
|
||||
<th className="px-6 py-3.5">Status & Phase</th>
|
||||
<th className="px-6 py-3.5">Laufwerk</th>
|
||||
<th className="px-6 py-3.5">Fortschritt</th>
|
||||
<th className="px-6 py-3.5">Startzeit</th>
|
||||
<th className="px-6 py-3.5 text-right">Aktionen</th>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/80 text-sm">
|
||||
{filteredJobs.map(job => {
|
||||
const poster = posterUrl(job.meta)
|
||||
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>
|
||||
|
||||
{/* 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 />
|
||||
|
||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||
@@ -424,19 +551,3 @@ export default function Dashboard() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function LogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
title="System-Logs"
|
||||
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function SettingsPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<PageHeader
|
||||
title="Einstellungen"
|
||||
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||||
@@ -213,7 +213,7 @@ export default function SettingsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{/* 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) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
@@ -222,11 +222,11 @@ export default function SettingsPage() {
|
||||
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 ${
|
||||
isActive
|
||||
? 'border-amber-500 text-amber-600 dark:text-amber-400 bg-white dark:bg-slate-900 font-semibold'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||||
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||||
: '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>
|
||||
</button>
|
||||
)
|
||||
@@ -636,6 +636,15 @@ export default function SettingsPage() {
|
||||
</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>
|
||||
|
||||
<Input
|
||||
@@ -653,15 +662,36 @@ export default function SettingsPage() {
|
||||
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||||
</Button>
|
||||
</div>
|
||||
{updates && (
|
||||
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<div>
|
||||
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||||
{updates ? (
|
||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
|
||||
<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 && 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>
|
||||
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,12 @@ celery_app.conf.update(
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
broker_connection_retry_on_startup=True,
|
||||
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
|
||||
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
|
||||
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
|
||||
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
|
||||
# transcode-Queue (irgendein freier Worker).
|
||||
worker_direct=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+36
-4
@@ -47,6 +47,22 @@ from ripping import (
|
||||
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:
|
||||
"""Ü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:
|
||||
# Kompression als eigener Task auf der transcode-Queue — kann vom
|
||||
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden.
|
||||
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||
# (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.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(
|
||||
args=[job_id, raw_dir, final_dir],
|
||||
queue="transcode",
|
||||
queue=ziel_queue,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
def scan_tracks(device_path: str):
|
||||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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
|
||||
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
||||
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
||||
@@ -224,12 +228,11 @@ den dann fertigen Primitives).
|
||||
|
||||
## 7. Arbeitsweise & Branch
|
||||
|
||||
- **Auf einem eigenen Branch arbeiten** (angelegt: `design-2.0`). Wichtig:
|
||||
Die Deploy-Kette befördert **nur `main`** automatisch auf `stable` und
|
||||
von dort auf die Live-VM. Ein Feature-Branch lässt die Ampel laufen
|
||||
(Feedback grün/rot), deployt aber NICHT — ideal für einen großen Umbau.
|
||||
Erst wenn Design 2.0 fertig & abgenommen ist, wird der Branch nach `main`
|
||||
gemerged.
|
||||
- **Auf einem eigenen Branch arbeiten** (war: `design-2.0`). Ein
|
||||
Feature-Branch lässt die Ampel laufen (Feedback grün/rot), deployt aber
|
||||
nicht — ideal für einen großen Umbau; nach Abnahme nach `main` gemerged.
|
||||
(Hinweis: Seit 24.07.2026 ist `main` der einzige Branch — deployt wird
|
||||
direkt daraus, die Ampel prüft nur noch.)
|
||||
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
||||
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
||||
Deutsch, WAS + WARUM.
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user