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.
|
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||||
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
||||||
|
|
||||||
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
|
||||||
|
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
|
||||||
POSTGRES_PASSWORD=rippy
|
POSTGRES_PASSWORD=rippy
|
||||||
|
|
||||||
|
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||||
|
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||||
|
# generischen sg-Knoten des Laufwerks. Die sg-Nummer variiert je Host — auf DIESER
|
||||||
|
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
|
||||||
|
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
|
||||||
|
OPTICAL_SR=/dev/sr0
|
||||||
|
OPTICAL_SG=/dev/sg1
|
||||||
|
|
||||||
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||||
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.)
|
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||||
|
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
|
||||||
|
|
||||||
# Metadaten-APIs
|
# Metadaten-APIs
|
||||||
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
|
||||||
|
# später im UI setzbar): kostenlos auf themoviedb.org
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
# TVDb (optional, Serien-Fallback)
|
# TVDb (optional, Serien-Fallback)
|
||||||
THETVDB_API_KEY=
|
THETVDB_API_KEY=
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
||||||
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
*.exe binary
|
||||||
|
*.ico binary
|
||||||
|
|||||||
@@ -72,13 +72,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
exit $rot
|
exit $rot
|
||||||
|
|
||||||
- name: Grün-Gate — grünen Stand auf 'stable' befördern (Arcane deployt von dort)
|
- name: Ergebnis
|
||||||
if: success()
|
if: success()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF_NAME:-}" = "main" ]; then
|
# Single Source of Truth: 'main' (24.07.2026, Commander-Entscheid —
|
||||||
git push origin +HEAD:refs/heads/stable
|
# der stable-Zwischenbranch ist weg). Die Ampel PRÜFT nur noch und
|
||||||
echo "✅ main → stable befördert. Arcane-GitSync zieht 'stable' und deployt."
|
# befördert nichts; deployt wird direkt aus 'main' (git pull auf der
|
||||||
else
|
# VM bzw. ./deploy.sh). Rot heißt weiterhin: nicht deployen.
|
||||||
echo "Kein main-Push — keine Beförderung."
|
echo "✅ AMPEL GRÜN. Deploy-Quelle ist 'main' — von dort deployen."
|
||||||
fi
|
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel,
|
|||||||
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
||||||
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
||||||
|
|
||||||
**C. Deployment läuft AUTOMATISCH über das Grün-Gate.** Ampel grün auf `main` →
|
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||||
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und
|
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||||
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist
|
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||||
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen.
|
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
||||||
|
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
||||||
|
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||||
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
||||||
|
|
||||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||||
|
|||||||
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
|||||||
|
|
||||||
### Laufwerk anpassen
|
### Laufwerk anpassen
|
||||||
|
|
||||||
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
|
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
||||||
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
|
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
||||||
`docker-compose.override.yml` an:
|
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
||||||
|
(auf frischen Systemen oft `/dev/sg0`).
|
||||||
|
|
||||||
```yaml
|
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
||||||
services:
|
```bash
|
||||||
api:
|
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
||||||
devices: ["/dev/sr1:/dev/sr0"]
|
# oder: ls -la /sys/class/scsi_generic/
|
||||||
worker:
|
|
||||||
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
|
|
||||||
```
|
```
|
||||||
|
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
||||||
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
|
```bash
|
||||||
`ls -la /sys/class/scsi_generic/`.
|
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
|
||||||
|
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
|
||||||
|
```
|
||||||
|
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
|
||||||
|
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
|
||||||
|
|
||||||
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
||||||
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
||||||
@@ -150,6 +153,9 @@ bleibt All-in-one.
|
|||||||
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
||||||
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
||||||
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
||||||
|
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
|
||||||
|
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
|
||||||
|
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
|
||||||
|
|
||||||
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
||||||
|
|
||||||
@@ -165,7 +171,16 @@ Voraussetzungen auf dem Ziel-Host:
|
|||||||
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
||||||
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
||||||
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
||||||
`docker-compose.yml` anpassen — muss ein rshared-fähiger Bind sein).
|
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
|
||||||
|
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
|
||||||
|
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
|
||||||
|
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
|
||||||
|
```bash
|
||||||
|
sudo mount --bind /srv/rippy/media /srv/rippy/media
|
||||||
|
sudo mount --make-rshared /srv/rippy/media
|
||||||
|
```
|
||||||
|
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
|
||||||
|
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
|
||||||
|
|
||||||
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
||||||
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
||||||
@@ -175,9 +190,29 @@ docker compose up -d --build`.
|
|||||||
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
||||||
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
||||||
|
|
||||||
|
### Härtung für fremde/exponierte Netze
|
||||||
|
|
||||||
|
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
|
||||||
|
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
|
||||||
|
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
|
||||||
|
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
|
||||||
|
|
||||||
|
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
|
||||||
|
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
|
||||||
|
nicht auf `0.0.0.0` binden.
|
||||||
|
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
|
||||||
|
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
|
||||||
|
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
|
||||||
|
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||||
|
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
|
||||||
|
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||||
|
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
|
||||||
|
den DB-Port nie offen ins unsichere Netz zu hängen.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Grün auf
|
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||||
`main` wird automatisch auf `stable` befördert — deploye von `stable`.
|
einen Branch, `main` (Single Source of Truth) — die Ampel prüft nur,
|
||||||
|
deployt wird direkt aus `main`. Rot heißt: nicht deployen.
|
||||||
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
||||||
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
||||||
|
|||||||
+89
-1
@@ -1,6 +1,94 @@
|
|||||||
# SAVEPOINT — Rippy
|
# 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 →
|
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
|
||||||
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# NOTFALL-Deploy für Rippy (Normalweg = Grün-Gate: push → Ampel grün → CI
|
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||||
# befördert auf 'stable' → Arcane-GitSync deployt).
|
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||||
# Dieses Skript nur nutzen, wenn der Automat klemmt (Arcane down o. ä.).
|
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
|
||||||
|
# zu deployenden Commit GRUEN ist.
|
||||||
#
|
#
|
||||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy, Branch stable) und fasst Arcanes
|
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||||
# im Arcane-Verzeichnis kollidiert mit dessen Promote (read-only .git-Objekte).
|
# reicht ohnehin der einfache Weg (siehe README):
|
||||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
# git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
|
||||||
|
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
|
||||||
|
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
|
||||||
|
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
|
||||||
|
#
|
||||||
|
# Konfiguration ueber Umgebungsvariablen:
|
||||||
|
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
|
||||||
|
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
|
||||||
|
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
|
||||||
|
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
|
||||||
|
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VM="arcane@192.168.178.162"
|
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
|
||||||
|
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
|
||||||
|
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
|
||||||
|
DIENST="${1:-}"
|
||||||
|
|
||||||
ssh "$VM" 'bash -s' <<REMOTE
|
ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
REPO_URL="$1"
|
||||||
git clone --branch stable https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||||
|
CLONE="${3:-$HOME/notfall-rippy}"
|
||||||
|
DIENST="$4"
|
||||||
|
|
||||||
|
if [ ! -d "$CLONE/.git" ]; then
|
||||||
|
git clone --branch main "$REPO_URL" "$CLONE"
|
||||||
fi
|
fi
|
||||||
cd ~/notfall-rippy
|
cd "$CLONE"
|
||||||
git fetch origin stable
|
git fetch origin main
|
||||||
git reset --hard origin/stable
|
git reset --hard origin/main
|
||||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||||
docker compose -p rippy up -d --build $DIENST
|
docker compose -p rippy up -d --build $DIENST
|
||||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|||||||
Binary file not shown.
@@ -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"):
|
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
||||||
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
||||||
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
# 4. Erzeugt start-worker.bat — Doppelklick startet den Worker
|
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||||
#
|
#
|
||||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) — gerippt wird
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||||
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
||||||
#
|
#
|
||||||
# Aufruf (PowerShell):
|
# Aufruf (PowerShell):
|
||||||
@@ -22,7 +22,17 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$HandBrakeVersion = "1.9.2"
|
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||||
|
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
|
||||||
|
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
|
||||||
|
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist.
|
||||||
|
$HandBrakeVersion = "1.11.2"
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||||
|
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||||
|
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||||
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
|
||||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||||
@@ -82,7 +92,7 @@ Write-Host "HandBrakeCLI bereit."
|
|||||||
# 5. Start-Skript erzeugen
|
# 5. Start-Skript erzeugen
|
||||||
$bat = @"
|
$bat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy Windows-Transcode-Worker — verbindet sich mit $RippyHost
|
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
@@ -96,7 +106,7 @@ Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
|||||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||||
$trayBat = @"
|
$trayBat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy-Worker mit Tray-Symbol — verbindet sich mit $RippyHost
|
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
@@ -109,7 +119,7 @@ start "" venv\Scripts\pythonw.exe tray.py
|
|||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||||
|
|
||||||
# 7. Deinstaller — MUSS-Kriterium: rueckstandsfrei entfernbar
|
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
||||||
$uninstall = @"
|
$uninstall = @"
|
||||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||||
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+12
-11
@@ -11,7 +11,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/api/Dockerfile
|
dockerfile: docker/api/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||||
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
propagation: rshared
|
propagation: rshared
|
||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
devices:
|
devices:
|
||||||
- /dev/sr0:/dev/sr0
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -74,7 +74,7 @@ services:
|
|||||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- RIP_OUTPUT_DIR=/app/media
|
- RIP_OUTPUT_DIR=/app/media
|
||||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||||
@@ -93,13 +93,14 @@ services:
|
|||||||
propagation: rslave
|
propagation: rslave
|
||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
devices:
|
devices:
|
||||||
- /dev/sr0:/dev/sr0
|
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||||
# neu starten.
|
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||||
- /dev/sg1:/dev/sg1
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
|
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -132,7 +133,7 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=rippy
|
- POSTGRES_USER=rippy
|
||||||
- POSTGRES_PASSWORD=rippy
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||||
- POSTGRES_DB=rippy
|
- POSTGRES_DB=rippy
|
||||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ COPY docker/api/ .
|
|||||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
# die Zielmaschine braucht weder git noch Docker.
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
COPY deploy/worker-windows/install.ps1 worker_dist/
|
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
|
||||||
|
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||||
|
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||||
|
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||||
|
COPY deploy/worker-windows/RippyWorkerSetup.exe worker_dist/
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
||||||
|
|||||||
@@ -7,12 +7,33 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_queue(node: str = None):
|
||||||
|
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
|
||||||
|
wenn er gerade online ist, sonst die geteilte transcode-Queue.
|
||||||
|
|
||||||
|
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
|
||||||
|
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
|
||||||
|
landet bei irgendeinem freien Worker.
|
||||||
|
"""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||||
return celery_client.send_task(
|
return celery_client.send_task(
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
|||||||
try:
|
try:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
|
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
|
||||||
|
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
|
||||||
|
# sich jederzeit im UI (Einstellungen → System) nachtragen.
|
||||||
if not settings.tmdb_api_key:
|
if not settings.tmdb_api_key:
|
||||||
raise ConfigValidationError(
|
raise ConfigValidationError(
|
||||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
+145
-32
@@ -12,6 +12,7 @@ import uuid
|
|||||||
|
|
||||||
import db
|
import db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
@@ -46,13 +47,22 @@ async def startup_event():
|
|||||||
except ConfigValidationError as e:
|
except ConfigValidationError as e:
|
||||||
print(f"⚠️ Konfigurations-Warnung: {e}")
|
print(f"⚠️ Konfigurations-Warnung: {e}")
|
||||||
|
|
||||||
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
|
# Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
|
||||||
|
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
|
||||||
|
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
|
||||||
|
# 24.07.: ~5 min "Waiting for application startup", kein Endpoint bedient, bis der
|
||||||
|
# soft-Mount per Timeout abbrach. Darum im Hintergrund: die Mounts stellen sich
|
||||||
|
# her, sobald der Server antwortet, ohne die API auszubremsen.
|
||||||
def remount():
|
def remount():
|
||||||
for meldung in mount_verwaltung.alle_remounten():
|
for meldung in mount_verwaltung.alle_remounten():
|
||||||
db.add_log("info", "mounts", meldung)
|
db.add_log("info", "mounts", meldung)
|
||||||
await asyncio.to_thread(remount)
|
asyncio.create_task(asyncio.to_thread(remount))
|
||||||
|
|
||||||
asyncio.create_task(disc_watcher())
|
asyncio.create_task(disc_watcher())
|
||||||
|
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
|
||||||
|
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
|
||||||
|
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
|
||||||
|
asyncio.create_task(makemkv_key.refresh_loop())
|
||||||
|
|
||||||
|
|
||||||
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
||||||
@@ -338,6 +348,7 @@ class JobCreateRequest(BaseModel):
|
|||||||
season: Optional[int] = None
|
season: Optional[int] = None
|
||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
|
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -395,6 +406,8 @@ async def create_job(request: JobCreateRequest):
|
|||||||
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
||||||
if titel_liste:
|
if titel_liste:
|
||||||
meta_dict["titles"] = titel_liste
|
meta_dict["titles"] = titel_liste
|
||||||
|
if request.transcode_node:
|
||||||
|
meta_dict["transcode_node"] = request.transcode_node
|
||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -511,7 +524,15 @@ async def storage_targets():
|
|||||||
return ziele
|
return ziele
|
||||||
for name in eintraege:
|
for name in eintraege:
|
||||||
pfad = os.path.join(MEDIA_ROOT, name)
|
pfad = os.path.join(MEDIA_ROOT, name)
|
||||||
if not os.path.isdir(pfad):
|
try:
|
||||||
|
ist_mount = os.path.ismount(pfad)
|
||||||
|
except OSError:
|
||||||
|
# Toter CIFS-Mount (NAS weg) — als Ziel unbrauchbar, aber NICHT
|
||||||
|
# den ganzen Endpoint sprengen (Befund 24.07.: Eintrag flog raus,
|
||||||
|
# ließ sich aber nicht mehr neu anlegen). Überspringen ist ok,
|
||||||
|
# die Mount-Verwaltung (/storage-mounts) zeigt ihn zum Reparieren.
|
||||||
|
continue
|
||||||
|
if not ist_mount and not os.path.isdir(pfad):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
nutzung = shutil.disk_usage(pfad)
|
nutzung = shutil.disk_usage(pfad)
|
||||||
@@ -521,7 +542,7 @@ async def storage_targets():
|
|||||||
ziele.append({
|
ziele.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"path": pfad,
|
"path": pfad,
|
||||||
"is_mount": os.path.ismount(pfad),
|
"is_mount": ist_mount,
|
||||||
"free_gb": frei_gb,
|
"free_gb": frei_gb,
|
||||||
})
|
})
|
||||||
return ziele
|
return ziele
|
||||||
@@ -599,10 +620,16 @@ async def retry_transcode(job_id: str):
|
|||||||
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
||||||
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
||||||
|
|
||||||
|
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
from celery_client import transcode_queue
|
||||||
celery_client.send_task(
|
celery_client.send_task(
|
||||||
"worker.tasks.transcode_files",
|
"worker.tasks.transcode_files",
|
||||||
args=[job_id, raw_dir, final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue="transcode",
|
queue=transcode_queue(meta.get("transcode_node")),
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
|
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
|
||||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
|
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
|
||||||
@@ -639,18 +666,23 @@ async def capabilities():
|
|||||||
zeilen = db.list_workers()
|
zeilen = db.list_workers()
|
||||||
try:
|
try:
|
||||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
online_namen = {
|
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
|
||||||
knoten.split("@", 1)[-1]
|
|
||||||
for antwort in antworten
|
|
||||||
for knoten in antwort.keys()
|
|
||||||
}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
online_namen = set()
|
ping_knoten = []
|
||||||
for zeile in zeilen:
|
for zeile in zeilen:
|
||||||
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem
|
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
||||||
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info.
|
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
|
||||||
|
# der WORKER_NAME steckt nur in info). Laufen MEHRERE Worker auf
|
||||||
|
# demselben Host (Befund 24.07.), disambiguiert der Name-Teil —
|
||||||
|
# sonst bekäme der falsche Worker den Node. `node` ist der
|
||||||
|
# Routing-Ziel-Knoten für die gezielte Encoder-Wahl.
|
||||||
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
|
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
|
||||||
zeile["online"] = hostname in online_namen
|
kandidaten = [k for k in ping_knoten if k.split("@", 1)[-1] == hostname]
|
||||||
|
node = next(
|
||||||
|
(k for k in kandidaten if k.split("@", 1)[0] == zeile["name"]), None
|
||||||
|
) or (kandidaten[0] if kandidaten else None)
|
||||||
|
zeile["online"] = node is not None
|
||||||
|
zeile["node"] = node
|
||||||
return zeilen
|
return zeilen
|
||||||
|
|
||||||
return {"workers": await asyncio.to_thread(sammle)}
|
return {"workers": await asyncio.to_thread(sammle)}
|
||||||
@@ -806,39 +838,67 @@ class MountRequest(BaseModel):
|
|||||||
|
|
||||||
@app.get("/storage-mounts")
|
@app.get("/storage-mounts")
|
||||||
async def get_storage_mounts():
|
async def get_storage_mounts():
|
||||||
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
|
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Status.
|
||||||
eintraege = await asyncio.to_thread(db.list_mounts)
|
|
||||||
return [
|
`mounted` = liegt ein Mount an; `reachable` = ist er auch WIRKLICH nutzbar
|
||||||
{
|
(toter CIFS-Mount nach NAS-Ausfall: mounted=true, reachable=false → das UI
|
||||||
"name": e["name"],
|
bietet dann „Reparieren" statt den Eintrag verschwinden zu lassen)."""
|
||||||
"type": e["typ"],
|
def sammle():
|
||||||
"source": e["quelle"],
|
eintraege = db.list_mounts()
|
||||||
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
|
ergebnis = []
|
||||||
"has_credentials": bool(e.get("username")),
|
for e in eintraege:
|
||||||
}
|
gemountet = mount_verwaltung.ist_gemountet(e["name"])
|
||||||
for e in eintraege
|
ergebnis.append({
|
||||||
]
|
"name": e["name"],
|
||||||
|
"type": e["typ"],
|
||||||
|
"source": e["quelle"],
|
||||||
|
"mounted": gemountet,
|
||||||
|
"reachable": mount_verwaltung.ist_erreichbar(e["name"]) if gemountet else False,
|
||||||
|
"has_credentials": bool(e.get("username")),
|
||||||
|
})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/storage-mounts", status_code=201)
|
@app.post("/storage-mounts", status_code=201)
|
||||||
async def create_storage_mount(request: MountRequest):
|
async def create_storage_mount(request: MountRequest):
|
||||||
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start."""
|
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start.
|
||||||
|
|
||||||
|
Existiert der Name schon UND ist erreichbar → 409. Existiert er, ist aber
|
||||||
|
TOT (NAS war weg) → wird mit den neuen Angaben frisch repariert statt
|
||||||
|
stur „Name bereits vergeben" zu melden (Befund 24.07.: man saß sonst fest)."""
|
||||||
if not mount_verwaltung.validiere_name(request.name):
|
if not mount_verwaltung.validiere_name(request.name):
|
||||||
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
|
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
|
||||||
if request.type not in ("nfs", "cifs"):
|
if request.type not in ("nfs", "cifs"):
|
||||||
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
|
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
|
||||||
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
|
|
||||||
raise HTTPException(status_code=409, detail="Name bereits vergeben")
|
vorhanden = any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts))
|
||||||
|
if vorhanden:
|
||||||
|
gemountet = await asyncio.to_thread(mount_verwaltung.ist_gemountet, request.name)
|
||||||
|
erreichbar = gemountet and await asyncio.to_thread(mount_verwaltung.ist_erreichbar, request.name)
|
||||||
|
if erreichbar:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Name bereits vergeben und aktiv — erst entfernen, dann neu anlegen.",
|
||||||
|
)
|
||||||
|
# Toter/veralteter Eintrag → reparieren (lazy abhängen + frisch mounten)
|
||||||
|
aktion = mount_verwaltung.reparieren
|
||||||
|
else:
|
||||||
|
aktion = mount_verwaltung.mounten
|
||||||
|
|
||||||
try:
|
try:
|
||||||
schreibbar = await asyncio.to_thread(
|
schreibbar = await asyncio.to_thread(
|
||||||
mount_verwaltung.mounten,
|
aktion,
|
||||||
request.name, request.type, request.source,
|
request.name, request.type, request.source,
|
||||||
request.options or "", request.username or "", request.password or "",
|
request.options or "", request.username or "", request.password or "",
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
# Bei Reparatur die (evtl. neuen) Zugangsdaten in der DB aktualisieren
|
||||||
|
if vorhanden:
|
||||||
|
await asyncio.to_thread(db.delete_mount, request.name)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.save_mount,
|
db.save_mount,
|
||||||
request.name, request.type, request.source,
|
request.name, request.type, request.source,
|
||||||
@@ -847,10 +907,37 @@ async def create_storage_mount(request: MountRequest):
|
|||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log,
|
db.add_log,
|
||||||
"success" if schreibbar else "warning", "mounts",
|
"success" if schreibbar else "warning", "mounts",
|
||||||
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}"
|
f"Speicherziel '{request.name}' ({request.type}) "
|
||||||
|
+ ("repariert" if vorhanden else "eingehängt") + f": {request.source}"
|
||||||
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
|
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
|
||||||
)
|
)
|
||||||
return {"name": request.name, "mounted": True, "writable": schreibbar}
|
return {"name": request.name, "mounted": True, "writable": schreibbar, "repaired": vorhanden}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/storage-mounts/{name}/repair")
|
||||||
|
async def repair_storage_mount(name: str):
|
||||||
|
"""Toten/veralteten Mount mit den GESPEICHERTEN Zugangsdaten neu verbinden
|
||||||
|
(Reparieren-Knopf im UI) — ohne dass der Nutzer alles neu eintippt."""
|
||||||
|
eintrag = next(
|
||||||
|
(e for e in await asyncio.to_thread(db.list_mounts) if e["name"] == name), None
|
||||||
|
)
|
||||||
|
if not eintrag:
|
||||||
|
raise HTTPException(status_code=404, detail="Speicherziel nicht gefunden")
|
||||||
|
try:
|
||||||
|
schreibbar = await asyncio.to_thread(
|
||||||
|
mount_verwaltung.reparieren,
|
||||||
|
eintrag["name"], eintrag["typ"], eintrag["quelle"],
|
||||||
|
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
||||||
|
eintrag.get("passwort") or "",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "success" if schreibbar else "warning", "mounts",
|
||||||
|
f"Speicherziel '{name}' neu verbunden"
|
||||||
|
+ ("" if schreibbar else " — NUR LESBAR"),
|
||||||
|
)
|
||||||
|
return {"name": name, "mounted": True, "writable": schreibbar}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/storage-mounts/shares")
|
@app.get("/storage-mounts/shares")
|
||||||
@@ -1012,6 +1099,32 @@ async def worker_setup_windows():
|
|||||||
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/windows-gui")
|
||||||
|
async def worker_setup_windows_gui():
|
||||||
|
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
|
||||||
|
für Fortgeschrittene; der Normalweg ist die .exe unten."""
|
||||||
|
pfad = "worker_dist/install-gui.ps1"
|
||||||
|
if not os.path.isfile(pfad):
|
||||||
|
raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen")
|
||||||
|
return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/windows-exe")
|
||||||
|
async def worker_setup_windows_exe():
|
||||||
|
"""Fertige Windows-Installer-.exe (Rippy-Icon, kein Konsolenfenster).
|
||||||
|
|
||||||
|
Vorgebaut auf Windows (deploy/worker-windows/build-exe.ps1) — eine
|
||||||
|
Windows-.exe lässt sich nicht auf Linux bauen. Generisch: die GUI fragt
|
||||||
|
die Rippy-Adresse selbst ab, HandBrake + Worker-Code kommen zur Laufzeit."""
|
||||||
|
pfad = "worker_dist/RippyWorkerSetup.exe"
|
||||||
|
if not os.path.isfile(pfad):
|
||||||
|
raise HTTPException(status_code=404, detail="Installer-.exe nicht im Image — API neu bauen")
|
||||||
|
return FileResponse(
|
||||||
|
pfad, media_type="application/vnd.microsoft.portable-executable",
|
||||||
|
filename="RippyWorkerSetup.exe",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/paket")
|
@app.get("/worker-setup/paket")
|
||||||
async def worker_setup_paket():
|
async def worker_setup_paket():
|
||||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||||
|
|||||||
@@ -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:
|
def ist_gemountet(name: str) -> bool:
|
||||||
return os.path.ismount(_mountpoint(name))
|
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
|
||||||
|
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
|
||||||
|
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
|
||||||
|
try:
|
||||||
|
return os.path.ismount(_mountpoint(name))
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ist_erreichbar(name: str) -> bool:
|
||||||
|
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
|
||||||
|
|
||||||
|
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
|
||||||
|
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
|
||||||
|
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
|
||||||
|
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
return ergebnis.returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def schreibtest(pfad: str) -> bool:
|
def schreibtest(pfad: str) -> bool:
|
||||||
@@ -49,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_mounts_loesen(ziel: str) -> int:
|
||||||
|
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||||
|
|
||||||
|
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||||
|
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||||
|
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||||
|
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||||
|
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS.
|
||||||
|
Rueckgabe: Zahl der geloesten Schichten.
|
||||||
|
"""
|
||||||
|
geloest = 0
|
||||||
|
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["umount", "-l", ziel], capture_output=True, timeout=10
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
break
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
break # nichts (mehr) gemountet
|
||||||
|
geloest += 1
|
||||||
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
||||||
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||||
|
|
||||||
@@ -121,9 +169,21 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
Mount-Fehlern.
|
Mount-Fehlern.
|
||||||
"""
|
"""
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
os.makedirs(ziel, exist_ok=True)
|
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||||
if os.path.ismount(ziel):
|
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||||
return schreibtest(ziel)
|
try:
|
||||||
|
os.makedirs(ziel, exist_ok=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if os.path.ismount(ziel):
|
||||||
|
return schreibtest(ziel)
|
||||||
|
except OSError:
|
||||||
|
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||||
|
|
||||||
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||||
|
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||||
|
_stale_mounts_loesen(ziel)
|
||||||
|
|
||||||
creds_datei = None
|
creds_datei = None
|
||||||
try:
|
try:
|
||||||
@@ -169,22 +229,48 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _lazy_umount(ziel: str) -> None:
|
||||||
|
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
|
||||||
|
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
|
||||||
|
gar nicht mehr entfernen (Befund 24.07.)."""
|
||||||
|
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
def aushaengen(name: str) -> None:
|
def aushaengen(name: str) -> None:
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
if os.path.ismount(ziel):
|
try:
|
||||||
|
noch_mount = os.path.ismount(ziel)
|
||||||
|
except OSError:
|
||||||
|
noch_mount = True # stale
|
||||||
|
if noch_mount:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||||
)
|
)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
raise RuntimeError(
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||||
f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}"
|
_lazy_umount(ziel)
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
os.rmdir(ziel)
|
os.rmdir(ziel)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # nicht leer oder weg — egal
|
pass # nicht leer oder weg — egal
|
||||||
|
|
||||||
|
|
||||||
|
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
|
username: str = "", passwort: str = "") -> bool:
|
||||||
|
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
||||||
|
|
||||||
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||||
|
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
if os.path.ismount(ziel):
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
except OSError:
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||||
|
|
||||||
|
|
||||||
def alle_remounten() -> list:
|
def alle_remounten() -> list:
|
||||||
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
||||||
meldungen = []
|
meldungen = []
|
||||||
|
|||||||
@@ -42,3 +42,18 @@ def test_worker_task_name_passt_zum_celery_client():
|
|||||||
|
|
||||||
quelle = inspect.getsource(celery_client.start_rip)
|
quelle = inspect.getsource(celery_client.start_rip)
|
||||||
assert '"worker.tasks.rip_disc"' in quelle
|
assert '"worker.tasks.rip_disc"' in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_remount_blockiert_den_api_start_nicht():
|
||||||
|
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
||||||
|
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
||||||
|
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
||||||
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||||
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||||
|
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||||
|
|||||||
@@ -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():
|
def test_validiere_name_bleibt_streng():
|
||||||
assert validiere_name("nas-filme")
|
assert validiere_name("nas-filme")
|
||||||
assert not validiere_name("NAS Filme")
|
assert not validiere_name("NAS Filme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||||
|
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||||
|
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
aufrufe.append(cmd)
|
||||||
|
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||||
|
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||||
|
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||||
|
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounten_loest_stale_vor_dem_mount():
|
||||||
|
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||||
|
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
|
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react'
|
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||||
import { api } from './lib/api'
|
|
||||||
import { useDarkMode } from './context/ThemeContext'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import AnleitungPage from './pages/Anleitung'
|
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
|
import AnleitungPage from './pages/Anleitung'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
|
import { api } from './lib/api'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
|
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||||
|
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||||
const { theme, toggleTheme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/setup')
|
api.get('/setup')
|
||||||
.then(r => setSetupDone(!!r.data.done))
|
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||||
.catch(() => setSetupDone(true)) // API nicht erreichbar → kein Wizard-Zwang
|
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (setupDone === false) {
|
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||||
return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||||
|
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans">
|
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||||
<div className="flex h-screen overflow-hidden">
|
{/* Top Header Navigation Bar */}
|
||||||
{/* Cinema Sidebar */}
|
<header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
|
||||||
<aside className="w-64 bg-slate-900 dark:bg-slate-950 text-slate-100 flex flex-col border-r border-slate-800/80 shadow-2xl z-20">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Logo */}
|
||||||
<div className="p-2.5 bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 rounded-xl shadow-lg shadow-amber-500/20">
|
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
|
||||||
<Disc size={26} className="text-white animate-pulse" />
|
<span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
|
||||||
</div>
|
RIPPY
|
||||||
<div>
|
</span>
|
||||||
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-300 bg-clip-text text-transparent">
|
<span className="px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
Rippy
|
CINEMA OS
|
||||||
</h1>
|
</span>
|
||||||
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 mb-2">
|
{/* Central Nav Tabs */}
|
||||||
<button
|
<nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
|
||||||
onClick={toggleTheme}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 text-slate-400 hover:text-slate-200 border border-slate-800 transition-all duration-200 text-xs font-medium"
|
|
||||||
title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{theme === 'dark' ? <Moon size={15} className="text-amber-400" /> : <Sun size={15} className="text-amber-500" />}
|
|
||||||
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300">
|
|
||||||
{theme}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1.5">
|
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = page === item.id
|
const Icon = item.icon
|
||||||
|
const isActive = currentPage === item.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setPage(item.id as Page)}
|
onClick={() => setCurrentPage(item.id)}
|
||||||
className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gradient-to-r from-amber-500/20 via-slate-800 to-slate-800/80 text-amber-300 border-l-4 border-amber-500 shadow-md shadow-amber-500/10'
|
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
|
||||||
: 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} />
|
<Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80">
|
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||||
Created with <span className="text-rose-400">❤️</span> by LucyAI, Claude & KrBrZ
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
</footer>
|
<span>ONLINE</span>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
|
||||||
<div className="max-w-7xl mx-auto p-6 md:p-8">
|
|
||||||
{page === 'dashboard' && <Dashboard />}
|
|
||||||
{page === 'anleitung' && <AnleitungPage />}
|
|
||||||
{page === 'logs' && <LogsPage />}
|
|
||||||
{page === 'settings' && <SettingsPage />}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Viewport */}
|
||||||
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ function posterUrl(disc?: DiscInfo): string | null {
|
|||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function typLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'cd': return 'Audio CD'
|
||||||
|
case 'dvd': return 'DVD'
|
||||||
|
case 'bluray': return 'Blu-ray'
|
||||||
|
case 'uhd': return '4K UHD'
|
||||||
|
default: return 'Unbekannt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -78,6 +88,8 @@ export default function DeviceDiscovery() {
|
|||||||
...(targetDir ? { target_dir: targetDir } : {}),
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
})
|
})
|
||||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Disc, Sparkles, CheckCircle, Shield, Zap, Tv, HardDrive } from 'lucide-react'
|
||||||
|
import { RadialProgress } from './ui/RadialProgress'
|
||||||
|
import { TypeBadge, StatusBadge } from './ui/Badge'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string
|
||||||
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||||
|
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
|
||||||
|
device: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
progress: number
|
||||||
|
title?: string
|
||||||
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w500${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCinemaBannerProps {
|
||||||
|
aktiverJob?: Job | null
|
||||||
|
completedJobs?: Job[]
|
||||||
|
onDetailClick?: (jobId: string) => void
|
||||||
|
onCancelClick?: (jobId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroCinemaBanner({
|
||||||
|
aktiverJob,
|
||||||
|
completedJobs = [],
|
||||||
|
onDetailClick,
|
||||||
|
onCancelClick,
|
||||||
|
}: HeroCinemaBannerProps) {
|
||||||
|
// Fall 1: Aktiver Job (Rippt oder Komprimiert live)
|
||||||
|
if (aktiverJob) {
|
||||||
|
const isTranscoding = aktiverJob.status === 'transcoding'
|
||||||
|
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
|
||||||
|
const poster = posterUrl(aktiverJob.meta)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-3xl overflow-hidden border-2 border-amber-500/50 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
{poster ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img src={poster} alt="" className="w-28 h-40 rounded-2xl object-cover shadow-2xl border-2 border-amber-500/40" />
|
||||||
|
<div className="absolute -bottom-3 -right-3 w-14 h-14 rounded-full disc-texture shadow-xl flex items-center justify-center animate-disc-spin border border-amber-400">
|
||||||
|
<Disc size={20} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-28 h-28 rounded-full disc-texture shadow-2xl flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-500/60">
|
||||||
|
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||||
|
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center sm:text-left">
|
||||||
|
<div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap">
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md">
|
||||||
|
NOW RIPPING
|
||||||
|
</span>
|
||||||
|
<TypeBadge type={aktiverJob.type} />
|
||||||
|
<StatusBadge status={aktiverJob.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
|
||||||
|
{aktiverJob.title || 'Aktiver Ripping-Prozess'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 font-mono">
|
||||||
|
Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong> · {statusText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
|
||||||
|
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
||||||
|
<Button variant="amber" size="md" onClick={() => onDetailClick && onDetailClick(aktiverJob.id)}>
|
||||||
|
<Sparkles size={16} /> Details
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="md" onClick={() => onCancelClick && onCancelClick(aktiverJob.id)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: Kein aktiver Job -> 5-Panel Kachel-Galerie VOLLSTÄNDIG ausfüllen (Zero "Löcher"!)
|
||||||
|
// Wir füllen freie Slots mit informativen Cinema-Engine-Showcase-Kacheln auf!
|
||||||
|
const realMovieTiles = completedJobs.slice(0, 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-[#0e1322] shadow-[0_0_40px_-5px_rgba(245,158,11,0.18)] mb-8 p-3">
|
||||||
|
{/* Top Subtle Amber Line */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-amber-500/40 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
|
||||||
|
{/* Echte Medien-Kacheln aus der API */}
|
||||||
|
{realMovieTiles.map((job) => {
|
||||||
|
const poster = posterUrl(job.meta)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
onClick={() => onDetailClick && onDetailClick(job.id)}
|
||||||
|
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
|
||||||
|
>
|
||||||
|
{poster ? (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt={job.title || ''}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-slate-950 flex flex-col items-center justify-center p-3 text-center">
|
||||||
|
<Disc size={32} className="text-amber-400 mb-2" />
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase">{job.type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 space-y-1">
|
||||||
|
<TypeBadge type={job.type} />
|
||||||
|
<h4 className="text-xs font-bold text-slate-100 truncate group-hover:text-amber-400 transition-colors">
|
||||||
|
{job.title || 'Unbekannt'}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
|
||||||
|
{realMovieTiles.length < 5 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-[#131929] to-slate-950 border-2 border-amber-500/40 flex items-center justify-center p-4 shadow-inner group">
|
||||||
|
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-400/60">
|
||||||
|
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||||
|
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_16px_rgba(245,158,11,0.9)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2.5 px-3 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-widest bg-amber-500 text-slate-950 shadow-md">
|
||||||
|
DISC ENGINE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Drive Status */}
|
||||||
|
{realMovieTiles.length < 4 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-indigo-950/80 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold">
|
||||||
|
<CheckCircle size={14} /> DRIVE READY
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Automatische Erkennung</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Lege eine Disc ein — Rippy rippt sofort los.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-amber-400 font-bold">
|
||||||
|
4K UHD · BLU-RAY · DVD · CD
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Media-Server Sync */}
|
||||||
|
{realMovieTiles.length < 3 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold">
|
||||||
|
<Tv size={14} /> MEDIA SERVER
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Auto-NFO & 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 { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react'
|
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,6 +18,18 @@ export interface RipOptionen {
|
|||||||
season?: number
|
season?: number
|
||||||
mainFeatureOnly?: boolean
|
mainFeatureOnly?: boolean
|
||||||
titles?: number[]
|
titles?: number[]
|
||||||
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerWahl {
|
||||||
|
name: string
|
||||||
|
node: string | null
|
||||||
|
online?: boolean
|
||||||
|
encoders: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENCODER_KURZ: Record<string, string> = {
|
||||||
|
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TitelInfo {
|
interface TitelInfo {
|
||||||
@@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [scanFehler, setScanFehler] = useState('')
|
const [scanFehler, setScanFehler] = useState('')
|
||||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
|
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||||
|
|
||||||
const scanStarten = async () => {
|
const scanStarten = async () => {
|
||||||
if (!deviceId) return
|
if (!deviceId) return
|
||||||
@@ -109,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setScanFehler('')
|
setScanFehler('')
|
||||||
setTitelListe([])
|
setTitelListe([])
|
||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
|
setEncoderNode('')
|
||||||
|
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||||
|
api.get('/capabilities').then(r => {
|
||||||
|
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||||
|
}).catch(() => setWorkers([]))
|
||||||
api.get('/settings').then(r => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
@@ -144,6 +163,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||||
}
|
}
|
||||||
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
const target = targets.find(t => t.type === selectedType)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
@@ -225,6 +245,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||||
|
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||||
|
{selectedType !== 'music' && workers.length >= 2 && (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
label="Encoder / Worker für die Kompression"
|
||||||
|
value={encoderNode}
|
||||||
|
onChange={e => setEncoderNode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Automatisch (erster freier Worker)</option>
|
||||||
|
{workers.map(w => (
|
||||||
|
<option key={w.node!} value={w.node!}>
|
||||||
|
{w.name} — {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
{deviceId && selectedType !== 'music' && (
|
{deviceId && selectedType !== 'music' && (
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
@@ -11,6 +11,7 @@ interface Mount {
|
|||||||
type: string
|
type: string
|
||||||
source: string
|
source: string
|
||||||
mounted: boolean
|
mounted: boolean
|
||||||
|
reachable?: boolean
|
||||||
has_credentials: boolean
|
has_credentials: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +71,12 @@ export default function StorageMounts() {
|
|||||||
|
|
||||||
const laden = async (manuell = false) => {
|
const laden = async (manuell = false) => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.allSettled([
|
||||||
api.get('/storage-mounts'),
|
api.get('/storage-mounts'),
|
||||||
api.get('/storage-targets'),
|
api.get('/storage-targets'),
|
||||||
])
|
])
|
||||||
setMounts(m.data)
|
if (m.status === 'fulfilled') setMounts(m.value.data || [])
|
||||||
setTargets(t.data)
|
if (t.status === 'fulfilled') setTargets(t.value.data || [])
|
||||||
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||||
} catch {
|
} catch {
|
||||||
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||||
@@ -149,6 +150,20 @@ export default function StorageMounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reparieren = async (mountName: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.post(`/storage-mounts/${mountName}/repair`)
|
||||||
|
toast(r.data.writable ? 'success' : 'error',
|
||||||
|
r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`)
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vorhandene Ziele */}
|
{/* Vorhandene Ziele */}
|
||||||
@@ -190,6 +205,56 @@ export default function StorageMounts() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
|
||||||
|
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
|
||||||
|
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
|
||||||
|
{mounts.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
|
<span>Netzwerk-Mounts</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{mounts.map(m => {
|
||||||
|
const aktiv = m.mounted && m.reachable !== false
|
||||||
|
const tot = m.mounted && m.reachable === false
|
||||||
|
return (
|
||||||
|
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{aktiv
|
||||||
|
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
: tot
|
||||||
|
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
||||||
|
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
||||||
|
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
||||||
|
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{!aktiv && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
||||||
|
<Wrench size={14} /> Reparieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ordner-Verwaltung */}
|
{/* Ordner-Verwaltung */}
|
||||||
<Card>
|
<Card>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
|
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
@@ -38,10 +38,20 @@ export default function WorkerVerwaltung() {
|
|||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
const [rippyHost, setRippyHost] = useState(window.location.hostname)
|
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||||
|
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
|
||||||
|
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
|
||||||
|
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
|
||||||
|
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||||
|
const [rippyHost, setRippyHost] = useState('')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||||
|
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||||
|
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
|
||||||
|
|
||||||
const siehtOeffentlichAus =
|
const siehtOeffentlichAus =
|
||||||
|
rippyHost !== '' &&
|
||||||
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
||||||
!rippyHost.endsWith('.local') &&
|
!rippyHost.endsWith('.local') &&
|
||||||
rippyHost !== 'localhost' &&
|
rippyHost !== 'localhost' &&
|
||||||
@@ -65,13 +75,13 @@ export default function WorkerVerwaltung() {
|
|||||||
const installBefehl = variante === 'linux'
|
const installBefehl = variante === 'linux'
|
||||||
? [
|
? [
|
||||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
|
||||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: [
|
: [
|
||||||
`iwr http://${rippyHost}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||||
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||||
` -RippyHost ${rippyHost} -WorkerName mein-pc`,
|
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const kopieren = () => {
|
const kopieren = () => {
|
||||||
@@ -81,6 +91,16 @@ export default function WorkerVerwaltung() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fertige Installer-.exe (Rippy-Icon, kein Konsolenfenster) direkt laden.
|
||||||
|
// Generisch — die GUI fragt die Rippy-Adresse selbst ab.
|
||||||
|
const exeHerunterladen = () => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = '/api/worker-setup/windows-exe'
|
||||||
|
a.download = 'RippyWorkerSetup.exe'
|
||||||
|
document.body.appendChild(a); a.click(); a.remove()
|
||||||
|
toast('success', 'RippyWorkerSetup.exe wird heruntergeladen — doppelklicken zum Installieren')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Verbundene Worker */}
|
{/* Verbundene Worker */}
|
||||||
@@ -157,15 +177,22 @@ export default function WorkerVerwaltung() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<Input
|
<Input
|
||||||
label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
|
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||||
value={rippyHost}
|
value={rippyHost}
|
||||||
onChange={e => setRippyHost(e.target.value.trim())}
|
onChange={e => setRippyHost(e.target.value.trim())}
|
||||||
placeholder="192.168.178.162"
|
placeholder="z. B. 192.168.178.162"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
|
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||||
|
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
|
||||||
|
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
|
||||||
|
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
|
||||||
|
an (Ports 6379/5432) — das klappt nur mit der echten LAN-IP im Heimnetz.
|
||||||
|
</p>
|
||||||
{siehtOeffentlichAus && (
|
{siehtOeffentlichAus && (
|
||||||
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||||
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
|
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||||
direkt mit Redis/Postgres (Ports 6379/5432) — das geht nur im Heimnetz. Hier die <strong>LAN-IP</strong> eintragen, z. B. 192.168.178.162.
|
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,10 +210,32 @@ export default function WorkerVerwaltung() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
|
||||||
|
{variante === 'windows' && (
|
||||||
|
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
|
||||||
|
im Fenster Adresse + Name eintragen, „Installieren". Kein Konsolenfenster.
|
||||||
|
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
|
||||||
|
</p>
|
||||||
|
<Button variant="amber" onClick={exeHerunterladen}>
|
||||||
|
<Download size={16} /> Installer herunterladen (.exe)
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
|
||||||
|
→ „Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
|
||||||
|
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{variante === 'linux'
|
{variante === 'linux'
|
||||||
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||||
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch.'}
|
: 'Alternativ (für Profis) per PowerShell-Befehl:'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
|
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ export function Card({ children, className = '', glow = false, onClick }: CardPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-2xl border transition-all duration-300 ${
|
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||||
glow ? 'shadow-[0_0_30px_-5px_rgba(245,158,11,0.15)] border-amber-500/30' : 'shadow-lg shadow-black/5 border-slate-200/80 dark:border-slate-800/80'
|
glow
|
||||||
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${
|
? 'glass-panel-amber'
|
||||||
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : ''
|
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||||
|
} text-slate-900 dark:text-slate-100 ${
|
||||||
|
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
|
{/* Top subtle highlight line for 3D glass depth */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -32,7 +36,7 @@ export function CardHeader({ children, className = '' }: { children: ReactNode;
|
|||||||
|
|
||||||
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<h2 className={`text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
interface RadialProgressProps {
|
||||||
|
progress: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
label?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||||
|
|
||||||
|
const isComplete = progress === 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
{/* Background track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-slate-800 dark:text-slate-800/80 fill-none"
|
||||||
|
/>
|
||||||
|
{/* Progress track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="url(#progressGradient)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="fill-none transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
|
||||||
|
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
|
||||||
|
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,25 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark'
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: Theme
|
theme: 'dark'
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
document.documentElement.classList.add('dark')
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
localStorage.setItem('theme', 'dark')
|
||||||
|
|
||||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
|
||||||
setTheme('dark')
|
|
||||||
document.documentElement.classList.add('dark')
|
|
||||||
} else {
|
|
||||||
setTheme('light')
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
// 100% Dark Mode locked per Commander directive
|
||||||
setTheme(newTheme)
|
document.documentElement.classList.add('dark')
|
||||||
localStorage.setItem('theme', newTheme)
|
|
||||||
if (newTheme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark')
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
+46
-2
@@ -4,7 +4,11 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply m-0 bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-400;
|
@apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,5 +39,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-disc-spin {
|
.animate-disc-spin {
|
||||||
animation: disc-spin 14s linear infinite;
|
animation: disc-spin 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Glass & Shimmer Effects */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-amber {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metallic Disc Texture Gradient */
|
||||||
|
.disc-texture {
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
#1e293b 0deg,
|
||||||
|
#475569 45deg,
|
||||||
|
#1e293b 90deg,
|
||||||
|
#64748b 135deg,
|
||||||
|
#1e293b 180deg,
|
||||||
|
#475569 225deg,
|
||||||
|
#1e293b 270deg,
|
||||||
|
#64748b 315deg,
|
||||||
|
#1e293b 360deg
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AnleitungPage() {
|
export default function AnleitungPage() {
|
||||||
const fett = "font-semibold text-slate-900 dark:text-slate-100"
|
const fett = "font-semibold text-slate-100"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Anleitung & Hilfe"
|
title="Anleitung & Hilfe"
|
||||||
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
||||||
@@ -106,11 +106,13 @@ export default function AnleitungPage() {
|
|||||||
|
|
||||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||||
<p>
|
<p>
|
||||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker zeigt für
|
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||||
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ,
|
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
|
||||||
ohne Docker — nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht
|
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
|
||||||
|
„Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
|
||||||
|
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
|
||||||
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
||||||
|
|||||||
+434
-323
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
|
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, Layers } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { PageHeader } from '../components/ui/PageHeader'
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
||||||
@@ -10,7 +9,16 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import JobDetailModal from '../components/JobDetailModal'
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,6 +30,19 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
api_version: string
|
||||||
|
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
||||||
|
workers: { name: string; encoders: string[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
@@ -33,39 +54,14 @@ async function fetchJobs(): Promise<Job[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
|
||||||
return (
|
|
||||||
<div className="w-32">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
|
|
||||||
<span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-200 dark:bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-300/30 dark:border-slate-700/30">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-500 ease-out ${
|
|
||||||
progress === 100
|
|
||||||
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
|
|
||||||
: 'bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-600 shadow-sm shadow-indigo-500/50'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkerInfo {
|
|
||||||
name: string
|
|
||||||
encoders: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
|
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const jobLoeschen = async (job: Job) => {
|
const jobLoeschen = async (job: Job) => {
|
||||||
@@ -92,27 +88,51 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setJobs(await fetchJobs())
|
const [jobsData, sysData] = await Promise.all([
|
||||||
|
fetchJobs(),
|
||||||
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
|
])
|
||||||
|
setJobs(jobsData)
|
||||||
|
if (sysData) setSystemInfo(sysData)
|
||||||
|
|
||||||
|
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
||||||
|
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
||||||
|
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
const interval = setInterval(loadData, 4000)
|
||||||
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
|
|
||||||
const interval = setInterval(loadData, 5000)
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
|
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||||
|
|
||||||
const stats = {
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
pending: jobs.filter(j => j.status === 'pending').length,
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
processing: jobs.filter(j => j.status === 'processing').length,
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
completed: jobs.filter(j => j.status === 'completed').length,
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
failed: jobs.filter(j => j.status === 'failed').length,
|
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||||
}
|
const workerCount = systemInfo?.workers?.length || 1
|
||||||
|
|
||||||
|
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||||
|
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||||
|
const y = 50 - (val / 100) * 40
|
||||||
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter(j => {
|
||||||
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
|
if (activeTab === 'queue') return j.status === 'pending'
|
||||||
|
if (activeTab === 'completed') return j.status === 'completed'
|
||||||
|
if (activeTab === 'failed') return j.status === 'failed'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -123,291 +143,398 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
|
||||||
title="Dashboard"
|
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
|
||||||
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
|
|
||||||
{aktiverJob && (
|
|
||||||
<Card glow className="relative overflow-hidden border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 p-6 shadow-2xl">
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 flex items-center justify-center p-1 shadow-xl shadow-amber-500/20 animate-disc-spin">
|
|
||||||
<div className="w-full h-full rounded-full border-4 border-dashed border-white/40 flex items-center justify-center">
|
|
||||||
<Disc size={36} className="text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="absolute -top-1 -right-1 flex h-4 w-4">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-4 w-4 bg-amber-500"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 text-center md:text-left space-y-2">
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
|
|
||||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
|
||||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
|
|
||||||
</span>
|
|
||||||
<TypeBadge type={aktiverJob.type} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-slate-100 truncate">
|
|
||||||
{aktiverJob.title || 'Aktiver Job'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
|
|
||||||
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
|
|
||||||
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
|
|
||||||
style={{ width: `${aktiverJob.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
|
||||||
Details
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* System-Telemetrie Leiste */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Activity size={17} className="text-amber-500" />
|
|
||||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Encoder:</span>
|
|
||||||
{workers.length === 0 ? (
|
|
||||||
<span className="text-slate-400 dark:text-slate-500 italic">kein Worker</span>
|
|
||||||
) : (
|
|
||||||
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
|
|
||||||
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
|
|
||||||
e.startsWith('cpu')
|
|
||||||
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
|
||||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
|
||||||
}`}>
|
|
||||||
{ENCODER_BADGES[e]?.label || e}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Clock size={17} className="text-amber-500" />
|
|
||||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
|
|
||||||
{aktiverJob ? (
|
|
||||||
<span className="text-slate-900 dark:text-slate-200 font-medium">
|
|
||||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-400 dark:text-slate-500 italic">kein Job</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{plaetze.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2.5 ml-auto">
|
|
||||||
<HardDrive size={17} className="text-amber-500" />
|
|
||||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
|
|
||||||
{plaetze.map(p => (
|
|
||||||
<span
|
|
||||||
key={p.name}
|
|
||||||
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
|
|
||||||
className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${
|
|
||||||
p.frei_gb < 60
|
|
||||||
? 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30 font-semibold'
|
|
||||||
: 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Laufwerke & Erkennung */}
|
|
||||||
<DeviceDiscovery />
|
<DeviceDiscovery />
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
|
||||||
<StatCard
|
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
|
||||||
icon={Clock}
|
|
||||||
label="Warteschlange"
|
|
||||||
value={stats.pending.toString()}
|
|
||||||
badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={Activity}
|
|
||||||
label="Bearbeitung"
|
|
||||||
value={stats.processing.toString()}
|
|
||||||
badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={CheckCircle}
|
|
||||||
label="Fertig"
|
|
||||||
value={stats.completed.toString()}
|
|
||||||
badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={AlertCircle}
|
|
||||||
label="Fehler"
|
|
||||||
value={stats.failed.toString()}
|
|
||||||
badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Jobs Table */}
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Neueste Jobs</CardTitle>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span>
|
<CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
|
||||||
|
<Layers size={22} className="text-amber-400" />
|
||||||
|
Ripping Operations & Medien-Manager
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* Master Filter Tabs */}
|
||||||
|
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Alle ({jobs.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'active'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{aktiverJob && <span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />}
|
||||||
|
<span>Aktiv ({aktiverJob ? 1 : 0})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('queue')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'queue'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Warteschlange ({queueJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('completed')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'completed'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Fertig ({completedJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{failedJobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('failed')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'failed'
|
||||||
|
? 'bg-rose-500 text-white shadow-md font-bold'
|
||||||
|
: 'text-rose-400 hover:text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Fehler ({failedJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{jobs.length > 0 && (
|
{jobs.length > 0 && (
|
||||||
<a
|
<a
|
||||||
href="/api/jobs/export"
|
href="/api/jobs/export"
|
||||||
download
|
download
|
||||||
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
|
title="Job-Historie als CSV herunterladen"
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet size={13} />
|
<FileSpreadsheet size={14} />
|
||||||
CSV
|
CSV
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
|
||||||
<Button
|
{completedJobs.length > 0 && (
|
||||||
variant="secondary"
|
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||||
size="sm"
|
<Trash2 size={13} /> Aufräumen
|
||||||
onClick={() => setAufraeumenOffen(true)}
|
|
||||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
Erledigte aufräumen
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<CardContent className="p-0">
|
||||||
<table className="w-full">
|
{/* Live Rip Banner inside Panel when a Job is active */}
|
||||||
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
|
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
|
||||||
<tr>
|
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Typ</th>
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Titel</th>
|
<div className="flex items-center gap-4">
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Status</th>
|
{posterUrl(aktiverJob.meta) ? (
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Gerät</th>
|
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Fortschritt</th>
|
) : (
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Startzeit</th>
|
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
|
||||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Aktion</th>
|
<Disc size={32} className="text-amber-400 animate-disc-spin" />
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
)}
|
||||||
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
|
||||||
{jobs.length === 0 ? (
|
<div className="space-y-1">
|
||||||
<tr>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<td colSpan={7} className="px-6 py-12 text-center">
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
|
||||||
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
|
AKTIVER RIP IN ARBEIT
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
</span>
|
||||||
</td>
|
<TypeBadge type={aktiverJob.type} />
|
||||||
</tr>
|
<StatusBadge status={aktiverJob.status} />
|
||||||
) : (
|
</div>
|
||||||
jobs.slice(0, 10).map(job => (
|
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
|
||||||
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
<p className="text-xs text-slate-400 font-mono">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
|
||||||
<TypeBadge type={job.type} />
|
</p>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setDetailJobId(job.id)}
|
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||||
title="Details zu diesem Titel anzeigen"
|
<div className="w-36 space-y-1 text-right">
|
||||||
className="text-sm font-medium text-left hover:underline text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors"
|
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||||
>
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
{job.title || 'Unbekannt'}
|
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
</button>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
<StatusBadge status={job.status} />
|
Details
|
||||||
</td>
|
</Button>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
|
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
|
||||||
{job.device}
|
Abbrechen
|
||||||
</td>
|
</Button>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</div>
|
||||||
<ProgressBar progress={job.progress} />
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
|
)}
|
||||||
{new Date(job.startTime).toLocaleString('de-DE')}
|
|
||||||
</td>
|
{/* Unified Jobs Table */}
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{filteredJobs.length === 0 ? (
|
||||||
{job.status === 'completed' && (
|
<div className="text-center py-16">
|
||||||
<Button
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
|
||||||
variant="secondary"
|
<Disc size={32} />
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => setDetailJobId(job.id)}
|
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
|
||||||
title="Fertige Dateien herunterladen (öffnet Job-Details)"
|
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
|
||||||
>
|
</div>
|
||||||
<Download size={13} className="text-emerald-500" />
|
) : (
|
||||||
Download
|
<div className="overflow-x-auto">
|
||||||
</Button>
|
<table className="w-full text-left border-collapse">
|
||||||
)}
|
<thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||||
{job.status === 'failed' && job.can_retry && (
|
<tr>
|
||||||
<Button
|
<th className="px-6 py-3.5">Typ</th>
|
||||||
variant="secondary"
|
<th className="px-6 py-3.5">Titel & Inhaltsangabe</th>
|
||||||
size="sm"
|
<th className="px-6 py-3.5">Status & Phase</th>
|
||||||
onClick={() => {
|
<th className="px-6 py-3.5">Laufwerk</th>
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
<th className="px-6 py-3.5">Fortschritt</th>
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
<th className="px-6 py-3.5">Startzeit</th>
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
|
<th className="px-6 py-3.5 text-right">Aktionen</th>
|
||||||
}}
|
|
||||||
title="Kompression neu anstoßen"
|
|
||||||
>
|
|
||||||
Neu komprimieren
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
|
||||||
<button
|
|
||||||
onClick={() => jobLoeschen(job)}
|
|
||||||
title="Job aus der Liste entfernen"
|
|
||||||
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
|
|
||||||
title="Job abbrechen"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className="divide-y divide-slate-800/80 text-sm">
|
||||||
</tbody>
|
{filteredJobs.map(job => {
|
||||||
</table>
|
const poster = posterUrl(job.meta)
|
||||||
</div>
|
return (
|
||||||
|
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<TypeBadge type={job.type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80 shadow-md" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-14 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
|
||||||
|
<Film size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailJobId(job.id)}
|
||||||
|
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1 text-base"
|
||||||
|
>
|
||||||
|
{job.title || 'Unbekannter Titel'}
|
||||||
|
</button>
|
||||||
|
{job.meta?.overview && (
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
|
||||||
|
{job.meta.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
|
{job.device}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="w-32 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
|
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{job.status === 'completed' && (
|
||||||
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
<Download size={13} /> Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === 'failed' && job.can_retry && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||||
|
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||||
|
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> Neu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
|
<button
|
||||||
|
onClick={() => jobLoeschen(job)}
|
||||||
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
|
||||||
|
{/* Left: Server Status Card */}
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
|
||||||
|
<CardHeader className="border-b border-slate-800">
|
||||||
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
|
<Server size={18} className="text-amber-400" />
|
||||||
|
Server Status (Echte Live-Daten)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-5 flex-1 flex items-center">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
||||||
|
<div className="space-y-4 flex-1 w-full">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
||||||
|
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
||||||
|
<span className="text-slate-200">{workerCount} Worker Online</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
||||||
|
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
||||||
|
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
||||||
|
<path
|
||||||
|
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Recently Ripped Posters */}
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
|
||||||
|
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
|
||||||
|
{completedJobs.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||||
|
<Trash2 size={13} /> Aufräumen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 flex-1 flex flex-col justify-center">
|
||||||
|
{completedJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-slate-500 text-xs font-mono">
|
||||||
|
Noch keine gerippten Medien in der Datenbank
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{completedJobs.slice(0, 3).map((item) => {
|
||||||
|
const poster = posterUrl(item.meta)
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="group relative rounded-xl overflow-hidden bg-slate-900 border border-slate-800 flex flex-col justify-between p-2">
|
||||||
|
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-slate-800">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt={item.title || ''} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center text-slate-500 p-2 text-center">
|
||||||
|
<Film size={24} className="text-amber-400 mb-1" />
|
||||||
|
<span className="text-[9px] uppercase font-bold text-slate-400">{item.type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-left">
|
||||||
|
<h4 className="text-xs font-bold text-slate-100 truncate" title={item.title}>{item.title || 'Unbekannt'}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono">{new Date(item.startTime).toLocaleDateString('de-DE')}</p>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-[10px] py-1 h-auto"
|
||||||
|
onClick={() => setDetailJobId(item.id)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* POS 4: Live-Log Section */}
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
|
|
||||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
@@ -424,19 +551,3 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-5 flex items-center gap-4">
|
|
||||||
<div className={`p-3 rounded-xl border ${badgeStyle}`}>
|
|
||||||
<Icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
|
|
||||||
<p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-5xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="System-Logs"
|
title="System-Logs"
|
||||||
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default function SettingsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Einstellungen"
|
title="Einstellungen"
|
||||||
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||||||
@@ -213,7 +213,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex border-b border-slate-200/80 dark:border-slate-800 overflow-x-auto bg-slate-50/50 dark:bg-slate-950/50">
|
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTab === tab.id
|
const isActive = activeTab === tab.id
|
||||||
return (
|
return (
|
||||||
@@ -222,11 +222,11 @@ export default function SettingsPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
|
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-amber-500 text-amber-600 dark:text-amber-400 bg-white dark:bg-slate-900 font-semibold'
|
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon size={17} className={isActive ? 'text-amber-500' : ''} />
|
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -636,6 +636,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
||||||
|
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — wichtig wegen der
|
||||||
|
Disc-Schlüssel-Datenbank.
|
||||||
|
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
||||||
|
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
||||||
|
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
||||||
|
dagegen automatisch die neueste HandBrake-Version.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -653,15 +662,36 @@ export default function SettingsPage() {
|
|||||||
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{updates && (
|
{updates ? (
|
||||||
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<div>
|
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
|
||||||
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
|
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
|
||||||
|
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||||||
|
{updates.makemkv?.update
|
||||||
|
? (
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||||||
|
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
||||||
|
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
|
||||||
<div>
|
<div>
|
||||||
<strong>HandBrake:</strong> installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'}
|
<strong>HandBrake (Docker-Worker):</strong> {updates.handbrake?.installiert || '—'}
|
||||||
|
<span className="text-xs ml-1 text-slate-500 dark:text-slate-400">— stabile Debian-Version, bewusst so, kein Update nötig</span>
|
||||||
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
Neueste offizielle Version wäre {updates.handbrake?.verfuegbar || 'unbekannt'} — die nutzen die
|
||||||
|
<strong> nativen Worker (Windows)</strong> automatisch. Der Docker-Worker bleibt bei der
|
||||||
|
Debian-Version (für die Kompression identisch, robustere Builds).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ celery_app.conf.update(
|
|||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
|
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
|
||||||
|
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
|
||||||
|
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
|
||||||
|
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
|
||||||
|
# transcode-Queue (irgendein freier Worker).
|
||||||
|
worker_direct=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+36
-4
@@ -47,6 +47,22 @@ from ripping import (
|
|||||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
|
||||||
|
|
||||||
|
def _transcode_queue(node: str):
|
||||||
|
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||||
|
gewählter Worker via worker_direct, wenn online — sonst geteilte Queue."""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
antworten = celery_app.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
||||||
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
||||||
|
|
||||||
@@ -430,13 +446,18 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ergebnis.get("status") == "success" and transcode_an:
|
if ergebnis.get("status") == "success" and transcode_an:
|
||||||
# Kompression als eigener Task auf der transcode-Queue — kann vom
|
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||||
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden.
|
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||||
|
# freier Worker, inkl. Remote-GPU).
|
||||||
|
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||||
db.update_job(job_id, status="transcoding", progress=0)
|
db.update_job(job_id, status="transcoding", progress=0)
|
||||||
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht")
|
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||||
|
db.add_log("info", "worker",
|
||||||
|
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||||
|
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
|
||||||
transcode_files.apply_async(
|
transcode_files.apply_async(
|
||||||
args=[job_id, raw_dir, final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue="transcode",
|
queue=ziel_queue,
|
||||||
)
|
)
|
||||||
return {"status": "ripped", "raw_dir": raw_dir}
|
return {"status": "ripped", "raw_dir": raw_dir}
|
||||||
|
|
||||||
@@ -444,6 +465,17 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.ping_worker")
|
||||||
|
def ping_worker():
|
||||||
|
"""Winziger Task zum Beweisen des gezielten Routings: gibt Hostname +
|
||||||
|
Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet
|
||||||
|
er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI)."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import caps
|
||||||
|
return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="worker.tasks.scan_tracks")
|
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||||
def scan_tracks(device_path: str):
|
def scan_tracks(device_path: str):
|
||||||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Design 2.0 — Briefing (Übergabe an Gemini)
|
# Design 2.0 — Briefing (Übergabe an Gemini)
|
||||||
|
|
||||||
|
> ✅ **ERLEDIGT (24.07.2026).** Design 2.0 wurde umgesetzt und ist in `main`.
|
||||||
|
> Der Arbeitsbranch `design-2.0` wurde nach dem Merge gelöscht (es gibt nur
|
||||||
|
> noch `main`). Dieses Dokument bleibt als historische Auftragsbeschreibung.
|
||||||
|
>
|
||||||
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
|
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
|
||||||
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
||||||
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
||||||
@@ -224,12 +228,11 @@ den dann fertigen Primitives).
|
|||||||
|
|
||||||
## 7. Arbeitsweise & Branch
|
## 7. Arbeitsweise & Branch
|
||||||
|
|
||||||
- **Auf einem eigenen Branch arbeiten** (angelegt: `design-2.0`). Wichtig:
|
- **Auf einem eigenen Branch arbeiten** (war: `design-2.0`). Ein
|
||||||
Die Deploy-Kette befördert **nur `main`** automatisch auf `stable` und
|
Feature-Branch lässt die Ampel laufen (Feedback grün/rot), deployt aber
|
||||||
von dort auf die Live-VM. Ein Feature-Branch lässt die Ampel laufen
|
nicht — ideal für einen großen Umbau; nach Abnahme nach `main` gemerged.
|
||||||
(Feedback grün/rot), deployt aber NICHT — ideal für einen großen Umbau.
|
(Hinweis: Seit 24.07.2026 ist `main` der einzige Branch — deployt wird
|
||||||
Erst wenn Design 2.0 fertig & abgenommen ist, wird der Branch nach `main`
|
direkt daraus, die Ampel prüft nur noch.)
|
||||||
gemerged.
|
|
||||||
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
||||||
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
||||||
Deutsch, WAS + WARUM.
|
Deutsch, WAS + WARUM.
|
||||||
|
|||||||
@@ -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