Compare commits
72 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 | |||
| bd00d4c728 | |||
| 90b708302e | |||
| 7004d798d6 | |||
| 7b6f836475 | |||
| fdbe1748e5 | |||
| 404a002d5c | |||
| 3987c31b1e | |||
| 76b4286ad3 | |||
| 3a4eb25392 | |||
| 325d95af6f | |||
| 8eb5653848 | |||
| e7a7d5fdb4 | |||
| c9270d08e1 | |||
| 5778ac4645 | |||
| ab75134931 | |||
| 5d9be4d046 | |||
| 13c1ae6064 | |||
| e032a9df1c | |||
| 048562a6ba | |||
| 731ed316df | |||
| 6517644d1f | |||
| 6b7d391737 | |||
| 37711f87c1 | |||
| 07be54e62a | |||
| 85f352b14f | |||
| 241ce3ec78 | |||
| f2d5961c33 | |||
| 90eb17d407 | |||
| 3d384c1e86 | |||
| 7652afe955 | |||
| b584cc29ad | |||
| e350523f7e | |||
| dfef585ec8 | |||
| 06cab545da | |||
| ef137b64cd | |||
| 2474b683e2 | |||
| 009898efae | |||
| bd6409038e | |||
| 1aa51ca943 | |||
| 5528e0f652 | |||
| 96c6e1fe42 | |||
| 4aa237541f | |||
| 2f6a01c9e4 | |||
| ef84392848 |
+39
-3
@@ -1,4 +1,40 @@
|
|||||||
POSTGRES_PASSWORD=rippy123
|
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||||
SECRET_KEY=change-me-in-production
|
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
||||||
|
|
||||||
|
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
|
||||||
|
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
|
||||||
|
POSTGRES_PASSWORD=rippy
|
||||||
|
|
||||||
|
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||||
|
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||||
|
# generischen sg-Knoten des Laufwerks. Die sg-Nummer variiert je Host — auf DIESER
|
||||||
|
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
|
||||||
|
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
|
||||||
|
OPTICAL_SR=/dev/sr0
|
||||||
|
OPTICAL_SG=/dev/sg1
|
||||||
|
|
||||||
|
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||||
|
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||||
|
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
|
||||||
|
|
||||||
|
# Metadaten-APIs
|
||||||
|
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
|
||||||
|
# später im UI setzbar): kostenlos auf themoviedb.org
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
THEtvdb_API_KEY=
|
# TVDb (optional, Serien-Fallback)
|
||||||
|
THETVDB_API_KEY=
|
||||||
|
# OMDb (optional, Fallback-Quelle): kostenloser Key auf omdbapi.com/apikey.aspx
|
||||||
|
OMDB_API_KEY=
|
||||||
|
|
||||||
|
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
|
||||||
|
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
|
||||||
|
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
|
||||||
|
# Bequemer: im UI unter Einstellungen → System eintragen (gilt ab dem
|
||||||
|
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
|
||||||
|
MAKEMKV_APP_KEY=
|
||||||
|
|
||||||
|
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
|
||||||
|
# Update: Version hier anheben, dann auf der Rippy-Maschine
|
||||||
|
# docker compose build worker && docker compose up -d worker
|
||||||
|
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
|
||||||
|
#MAKEMKV_VERSION=1.18.4
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
+4
-2
@@ -18,8 +18,10 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
# Venv-Muster auf Root verankert — das nackte `lib/` verschluckte auch
|
||||||
lib64/
|
# docker/ui/src/lib/ und ließ api.ts nie in einen Commit (Ampel-Rot 23.07.).
|
||||||
|
/lib/
|
||||||
|
/lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel,
|
|||||||
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
||||||
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
||||||
|
|
||||||
**C. Deployment läuft AUTOMATISCH über das Grün-Gate.** Ampel grün auf `main` →
|
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||||
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und
|
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||||
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist
|
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||||
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen.
|
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
||||||
|
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
||||||
|
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||||
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
||||||
|
|
||||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||||
@@ -53,9 +55,10 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
|||||||
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||||
|
|
||||||
## Aktueller Stand (21.07.2026)
|
## Aktueller Stand (24.07.2026)
|
||||||
|
|
||||||
- ✅ **Etappe 8 abgeschlossen:** Dark Mode mit Theme-Toggle, React-UI mit TailwindCSS
|
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
||||||
- ✅ **Git Sync:** Arcane VM (192.168.178.162) mit Git Repository konfiguriert
|
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||||
- ✅ **Savepoint v1.8:** Separation of Concerns geplant, Dark Mode implementiert
|
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
||||||
- 📝 **Nächste Etappe:** SoC-Refactoring (Theme-Context auslagern)
|
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
||||||
|
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||||
|
|||||||
+34
-4
@@ -35,8 +35,8 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
|
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
|
||||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||||
| JWT-Auth (Access 15min/Refresh 7 Tage) | ✔ M | | |
|
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
||||||
| Rate-Limiting (100/min pro API-Key) | ✔ M | | |
|
| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
|
||||||
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||||
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
||||||
@@ -44,7 +44,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
|
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
|
||||||
| GPL-v3-Compliance-Checkliste | ✔ M | | |
|
| GPL-v3-Compliance-Checkliste | ✔ M | | |
|
||||||
| Source-Release-Endpoint | ✔ M | | |
|
| Source-Release-Endpoint | ✔ M | | |
|
||||||
| HandBrake-Transcoding (optional) | | ✔ K | |
|
| HandBrake-Kompression NACH dem Lossless-Rip (Stufe 2) | ✔ M | | |
|
||||||
| Prometheus+Grafana Monitoring | | ✔ K | |
|
| Prometheus+Grafana Monitoring | | ✔ K | |
|
||||||
| PBS-Snapshot-Backup-Hooks | | ✔ K | |
|
| PBS-Snapshot-Backup-Hooks | | ✔ K | |
|
||||||
| Multi-Disc-Parallelisierung | | | ✔ S |
|
| Multi-Disc-Parallelisierung | | | ✔ S |
|
||||||
@@ -68,7 +68,12 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
- Commander bestätigt oder korrigiert manuell.
|
- Commander bestätigt oder korrigiert manuell.
|
||||||
6. **Ripping** (nach Bestätigung):
|
6. **Ripping** (nach Bestätigung):
|
||||||
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
|
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
|
||||||
- DVD/Blu-ray: `makemkvcon --all --progress` → MKV.
|
- DVD/Blu-ray: `makemkvcon` → verlustfreies MKV als ZWISCHENPRODUKT in /app/temp
|
||||||
|
(MakeMKV ist der einzige Weg durch AACS — HandBrake kann verschlüsselte
|
||||||
|
Discs nicht lesen), danach **HandBrake-Kompression auf Arbeitsgröße**
|
||||||
|
(x265; Commander-Entscheid 23.07.2026: 40-GB-Rohdateien sind kein
|
||||||
|
brauchbares Endprodukt). Roh-Datei wird nach Erfolg gelöscht
|
||||||
|
(Setting keepOriginal behält sie).
|
||||||
7. **Post-Processing & Jellyfin-Formatierung**:
|
7. **Post-Processing & Jellyfin-Formatierung**:
|
||||||
- Dateien in Jellyfin-konformer Ordnerstruktur.
|
- Dateien in Jellyfin-konformer Ordnerstruktur.
|
||||||
- NFO-Dateien im Kodi/NFO-Schema.
|
- NFO-Dateien im Kodi/NFO-Schema.
|
||||||
@@ -156,3 +161,28 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
- Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis)
|
- Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis)
|
||||||
|
|
||||||
**Ergebnis:** Konzept ist wasserdicht für MVP-Phase.
|
**Ergebnis:** Konzept ist wasserdicht für MVP-Phase.
|
||||||
|
|
||||||
|
## 10. Konzept-Fortschreibungen (dokumentierte Erweiterungen, keine Abweichungen)
|
||||||
|
|
||||||
|
- **24.07.2026 — Media-Server-Neutralität:** Die Jellyfin-Muss-Features
|
||||||
|
(Ordnerstruktur, NFO, Poster) bleiben vollständig bestehen; sie sind jetzt
|
||||||
|
über das Setting `mediaServer` auf Emby und Kodi ausgeweitet (identisches
|
||||||
|
Kodi-NFO-Schema) und für Plex auf die reine Benennung reduziert. Jellyfin
|
||||||
|
bleibt Referenz- und Empfehlungssystem im Einrichtungs-Assistenten.
|
||||||
|
- **24.07.2026 — Benachrichtigungen:** Webhook bei Job-Ende (Discord/Slack/
|
||||||
|
ntfy/generisch) als gebautes Feature — im UX-Flow Punkt 10 („Abschluss")
|
||||||
|
war das als Protokollierung angelegt, jetzt meldet Rippy aktiv.
|
||||||
|
- **23./24.07.2026 — udev → ioctl:** Der im Konzept beschriebene udev-Daemon
|
||||||
|
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
|
||||||
|
Kernel-ioctl (3 s) — gleiches Verhalten, universell lauffähig.
|
||||||
|
- **24.07.2026 — AUTH GESTRICHEN (Commander-Entscheid):** Das Muss-Feature
|
||||||
|
„JWT-Auth" ist komplett entfernt (Endpoints, auth.py, Abhängigkeiten,
|
||||||
|
Env-Pflicht). Begründung: Rippy läuft ausschließlich im Heimnetz, das UI
|
||||||
|
hatte nie einen Login-Flow — die Auth-Oberfläche war Placebo und die
|
||||||
|
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||||
|
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||||
|
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
||||||
|
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
|
||||||
|
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||||
|
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||||
|
EINDEUTIGER Zuordnung wird umbenannt).
|
||||||
|
|||||||
@@ -1,119 +1,218 @@
|
|||||||
# README — Rippy
|
# Rippy — die All-in-one Disc-Ripping-Maschine
|
||||||
|
|
||||||
> **Moderner Ripping-Daemon mit Metadaten-Preview, Jellyfin-Formatierung und JWT-Auth.**
|
Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
|
||||||
|
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
|
||||||
|
abgelegt, wo DU willst (lokal, NAS, jede Freigabe). Modernes Web-UI,
|
||||||
|
Echtzeit-Fortschritt, komplett in Docker, komplett lokal.
|
||||||
|
|
||||||
---
|
## Schnellstart
|
||||||
|
|
||||||
## Features
|
Voraussetzungen: Docker + Docker Compose, ein optisches Laufwerk am Host.
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| Disc-Erkennung (CD/DVD/Blu-ray) | ✅ |
|
|
||||||
| Metadaten-Lookup (TMDB/MusicBrainz/TheTVDB) | ✅ |
|
|
||||||
| Pre-Scan ohne Ripping | ✅ |
|
|
||||||
| Jellyfin-Formatierung (NFO + Images) | ✅ |
|
|
||||||
| JWT-Auth + Rate-Limiting | ✅ |
|
|
||||||
| React-UI mit Dashboard | ✅ |
|
|
||||||
| SQLite-Cache für API-Rate-Limits | ✅ |
|
|
||||||
| Multi-Disc-Set-Handling | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Proxmox LXC (Debian 12) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ API │ │ Worker │ │ UI │ │
|
|
||||||
│ │ FastAPI │◄─►│ Celery+ │ │ React+ │ │
|
|
||||||
│ │ │ │ Redis │ │ Nginx │ │
|
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ PostgreSQL│ │ udev │ │ Media │ │
|
|
||||||
│ │ │ │ Daemon │ │ Store │ │
|
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git
|
git clone <repo-url> rippy && cd rippy
|
||||||
cd rippy
|
cp .env.example .env # optional: API-Keys — geht auch später im UI
|
||||||
docker compose up -d
|
mkdir -p /srv/rippy/media # Ablage-Basis (anpassbar in docker-compose.yml)
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port-Übersicht
|
Dann `http://<host>` öffnen — der **Einrichtungs-Assistent** startet beim
|
||||||
|
ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
||||||
|
|
||||||
| Service | Port | URL |
|
### Laufwerk anpassen
|
||||||
|---------|------|-----|
|
|
||||||
| UI | 80 | http://localhost:80 |
|
|
||||||
| API | 8000 | http://localhost:8000 |
|
|
||||||
| PostgreSQL | 5432 | localhost:5432 |
|
|
||||||
| Redis | 6379 | localhost:6379 |
|
|
||||||
|
|
||||||
---
|
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
||||||
|
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
||||||
|
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
||||||
|
(auf frischen Systemen oft `/dev/sg0`).
|
||||||
|
|
||||||
## API
|
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
||||||
|
```bash
|
||||||
|
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
||||||
|
# oder: ls -la /sys/class/scsi_generic/
|
||||||
|
```
|
||||||
|
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
||||||
|
```bash
|
||||||
|
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
|
||||||
|
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
|
||||||
|
```
|
||||||
|
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
|
||||||
|
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
|
||||||
|
|
||||||
### Endpoints
|
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
||||||
|
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
||||||
|
NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
## Wie es funktioniert
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/health` | GET | Health check |
|
|
||||||
| `/jobs` | GET | Alle Jobs |
|
|
||||||
| `/devices` | GET | Alle Geräte |
|
|
||||||
| `/prescan` | POST | Pre-Scan durchführen |
|
|
||||||
| `/metadata/lookup` | POST | Metadaten lookup |
|
|
||||||
| `/metadata/confirm` | POST | Metadaten bestätigen |
|
|
||||||
| `/jellyfin/format` | POST | Für Jellyfin formatieren |
|
|
||||||
| `/token` | POST | Login (JWT) |
|
|
||||||
| `/token/refresh` | POST | Refresh Token |
|
|
||||||
| `/api-keys` | POST/GET/DELETE | API-Key Management |
|
|
||||||
|
|
||||||
---
|
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
|
||||||
|
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
|
||||||
|
sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
|
||||||
|
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
|
||||||
|
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
|
||||||
|
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
|
||||||
|
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
|
||||||
|
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
|
||||||
|
Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
|
||||||
|
gerippte Discs erkennt Rippy am Fingerabdruck und warnt. Vor dem Start
|
||||||
|
prüft Rippy den freien Platz gegen die Disc-Größe. Alle Tonspuren und
|
||||||
|
Untertitel bleiben erhalten (wichtig für Anime/O-Ton). Audio-CDs laufen
|
||||||
|
über abcde → FLAC + MusicBrainz.
|
||||||
|
**Serien**: Im Dialog „Serien" wählen → Serienname + Staffel angeben —
|
||||||
|
Ablage wird `<Serie>/Season NN`, und die Episoden werden nach dem Rip
|
||||||
|
per Laufzeitabgleich (TMDB) automatisch zu „Serie S01E02.mkv" benannt
|
||||||
|
(nur bei eindeutiger Zuordnung — sonst bleiben die Namen, mit Log).
|
||||||
|
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
||||||
|
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
|
||||||
|
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
||||||
|
sich ohne Neu-Rip neu anstoßen.
|
||||||
|
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
|
||||||
|
Einrichtungs-Assistenten) wählst du dein System — Jellyfin, Emby, Kodi,
|
||||||
|
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
|
||||||
|
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
|
||||||
|
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
|
||||||
|
nur die Benennung. **Jellyfin/Emby**: Server-URL + API-Key eintragen,
|
||||||
|
dann stößt Rippy nach jedem fertigen Rip sofort einen Bibliotheks-Scan
|
||||||
|
an — Disc rein, Film erscheint im Server.
|
||||||
|
5. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
||||||
|
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
||||||
|
Laufwerk. ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
||||||
|
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
|
||||||
|
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
|
||||||
|
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
|
||||||
|
Klartext-Meldung ab statt nach 40 GB mit voller Platte.
|
||||||
|
|
||||||
## Tech-Stack
|
## Speicherziele (NAS, Freigaben)
|
||||||
|
|
||||||
| Layer | Tech |
|
Unter **Einstellungen → Speicherziele** hängst du NFS- oder SMB-Freigaben
|
||||||
|-------|------|
|
direkt aus dem UI ein — sie erscheinen sofort in der Ziel-Auswahl beim
|
||||||
| Backend | Python, FastAPI, Celery |
|
Rippen und werden beim Start automatisch wieder verbunden.
|
||||||
| Database | PostgreSQL, SQLite, Redis |
|
Technik: der api-Container läuft mit `CAP_SYS_ADMIN` und einem
|
||||||
| Frontend | React, Vite, TailwindCSS |
|
rshared-Bind auf `/srv/rippy/media`, Mounts propagieren zu allen
|
||||||
| Ripping | MakeMKV, abcde, FFmpeg, HandBrake |
|
Containern. ⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen
|
||||||
| Auth | JWT, OAuth2 |
|
Postgres-DB — bewusster Heimnetz-Kompromiss; lege fürs NAS einen eigenen,
|
||||||
|
eingeschränkten Benutzer an.
|
||||||
|
|
||||||
---
|
## Benachrichtigungen
|
||||||
|
|
||||||
## Roadmap
|
**Einstellungen → Benachrichtigungen**: eine Webhook-URL eintragen, „Test
|
||||||
|
senden" drücken, speichern — fertig. Rippy meldet Job-Ende (fertig,
|
||||||
|
fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
|
||||||
|
|
||||||
- [x] Etappe 1: Container-Infrastruktur
|
| Dienst | URL-Beispiel | Format |
|
||||||
- [x] Etappe 2: Ripping-Pipeline
|
|---|---|---|
|
||||||
- [x] Etappe 3: Metadaten-Lookup + Pre-Scan
|
| Discord | `https://discord.com/api/webhooks/…` | `{"content": …}` |
|
||||||
- [x] Etappe 4: Jellyfin-Formatierung
|
| Slack | `https://hooks.slack.com/services/…` | `{"text": …}` |
|
||||||
- [x] Etappe 5: API + Auth + WebUI
|
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` | Roh-Text + Titel |
|
||||||
- [x] Etappe 6: Sicherheit + Compliance
|
| Eigenes (HA, n8n, …) | beliebige HTTPS-URL | `{"title","message","level"}` |
|
||||||
- [x] Etappe 7: API UI Modernisiert
|
|
||||||
- [x] Etappe 8: Dark Mode & Separation of Concerns
|
|
||||||
- [ ] Etappe 9: Proxmox-Integration
|
|
||||||
|
|
||||||
---
|
## System, MakeMKV-Beta-Key & Updates
|
||||||
|
|
||||||
## License
|
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
|
||||||
|
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
|
||||||
|
MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
|
||||||
|
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
|
||||||
|
Neustart; er schlägt den Key aus der `.env`.
|
||||||
|
|
||||||
GPL-v3
|
**Updates:** „Auf Updates prüfen" vergleicht mit makemkv.com und den
|
||||||
|
offiziellen HandBrake-Releases (12 h gecacht). MakeMKV-Update ohne
|
||||||
|
Code-Änderung: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
||||||
|
`docker compose build worker && docker compose up -d worker` — neue
|
||||||
|
Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||||
|
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
||||||
|
offiziellen Version hinterher); der native Windows-Worker nutzt die
|
||||||
|
aktuelle Version direkt.
|
||||||
|
|
||||||
---
|
## Verarbeitung & Hardware
|
||||||
|
|
||||||
## Credits
|
**Einstellungen → Verarbeitung** zeigt ehrlich an, welche Encoder deine
|
||||||
|
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
|
||||||
|
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
|
||||||
|
|
||||||
- **Commander**: Projekt-Idee, Anforderungen, Testing
|
### Optional: weitere Maschinen als Transcode-Worker
|
||||||
- **AI-Box**: Entwicklung, Architektur, Dokumentation
|
|
||||||
|
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode` —
|
||||||
|
JEDE Maschine im Netz kann sie übernehmen. **Einstellungen → Worker** zeigt
|
||||||
|
für beide Varianten einen Copy-Paste-Befehl:
|
||||||
|
- **Linux (Docker)**: `deploy/remote-transcode-worker.yml`
|
||||||
|
- **Windows (nativ, ohne Docker)**: braucht nur Python 3.10+ — der
|
||||||
|
Installer (`install.ps1`) kommt von der Rippy-Instanz selbst
|
||||||
|
(`/api/worker-setup/windows`), lädt Worker-Code und HandBrakeCLI
|
||||||
|
automatisch und erzeugt eine `start-worker.bat` (optional `-Autostart`).
|
||||||
|
|
||||||
|
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy
|
||||||
|
bleibt All-in-one.
|
||||||
|
|
||||||
|
## Umgebungsvariablen (.env)
|
||||||
|
|
||||||
|
| Variable | Pflicht | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
||||||
|
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
|
||||||
|
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (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_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.
|
||||||
|
|
||||||
|
## Rippy woanders bereitstellen
|
||||||
|
|
||||||
|
Rippy ist reines Docker Compose — es läuft auf **jedem Linux-Host mit
|
||||||
|
Docker**, nicht nur auf der Original-VM. Es gibt (noch) keine fertigen
|
||||||
|
Registry-Images; gebaut wird beim ersten `up` direkt aus dem Repo.
|
||||||
|
|
||||||
|
Voraussetzungen auf dem Ziel-Host:
|
||||||
|
1. Linux (x86_64) mit Docker + Compose-Plugin.
|
||||||
|
2. Ein optisches Laufwerk, das der Host sieht (`ls /dev/sr* /dev/sg*`).
|
||||||
|
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
||||||
|
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
||||||
|
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
||||||
|
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
|
||||||
|
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
|
||||||
|
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
|
||||||
|
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
|
||||||
|
```bash
|
||||||
|
sudo mount --bind /srv/rippy/media /srv/rippy/media
|
||||||
|
sudo mount --make-rshared /srv/rippy/media
|
||||||
|
```
|
||||||
|
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
|
||||||
|
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
|
||||||
|
|
||||||
|
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
||||||
|
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
||||||
|
den Rest (API-Keys, Media-Server, Verarbeitung). Updates: `git pull &&
|
||||||
|
docker compose up -d --build`.
|
||||||
|
|
||||||
|
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
||||||
|
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
||||||
|
|
||||||
|
### Härtung für fremde/exponierte Netze
|
||||||
|
|
||||||
|
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
|
||||||
|
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
|
||||||
|
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
|
||||||
|
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
|
||||||
|
|
||||||
|
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
|
||||||
|
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
|
||||||
|
nicht auf `0.0.0.0` binden.
|
||||||
|
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
|
||||||
|
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
|
||||||
|
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
|
||||||
|
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||||
|
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
|
||||||
|
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||||
|
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
|
||||||
|
den DB-Port nie offen ins unsichere Netz zu hängen.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||||
|
einen Branch, `main` (Single Source of Truth) — die Ampel prüft nur,
|
||||||
|
deployt wird direkt aus `main`. Rot heißt: nicht deployen.
|
||||||
|
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
||||||
|
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
||||||
|
|||||||
+180
-1
@@ -249,5 +249,184 @@ Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf.
|
|||||||
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
||||||
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
||||||
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
|
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
|
||||||
- [ ] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem Rip)
|
- [x] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem
|
||||||
|
Rip) — 24.07., medien.py: Ordner „Titel (Jahr)" + movie.nfo + poster.jpg,
|
||||||
|
gesteuert übers mediaServer-Setting (Jellyfin/Emby/Kodi/Plex/keins)
|
||||||
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
|
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 12: Besser als ARM (aus ARM-Analyse 23.07.2026)
|
||||||
|
|
||||||
|
**Quelle:** Vollanalyse von Automatic Ripping Machine v2.24 (Code, arm.yaml,
|
||||||
|
Wiki, Issues). Was Rippy strukturell schon besser macht: Lossless-first statt
|
||||||
|
Transcode-first (ARMs BD-Default ist ein 1080p30-Downscale!), Celery+Postgres
|
||||||
|
statt Prozess-pro-udev-Event+SQLite (ARM-UI friert beim Rippen ein, #1046),
|
||||||
|
ioctl-Watcher statt fragiler udev-Ketten (ARMs größte Support-Quelle), NFO +
|
||||||
|
Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs).
|
||||||
|
|
||||||
|
**Übernehmen (priorisiert):**
|
||||||
|
- [ ] **Disc-Fingerprint-DB (selbstlernend)** — ARMs beste Idee, besser gemacht:
|
||||||
|
Tabelle disc_fingerprints (DVD-CRC64/MusicBrainz-DiscID/BD-Label-Hash →
|
||||||
|
bestätigte Metadaten). Jede UI-Bestätigung schreibt zurück; zweite Disc
|
||||||
|
derselben Serie wird sofort erkannt. Lokal statt ARMs Crowd-Server
|
||||||
|
(Single Point of Failure auf Free-Hosting).
|
||||||
|
- [ ] **BD-Titel aus BDMV/META/DL/bdmt_eng.xml** als Identifikations-Stufe vor
|
||||||
|
den APIs (echter Disc-Titel statt Label-Raterei).
|
||||||
|
- [ ] **Progressive Suchdegradation** bei TMDB/OMDb: mit Jahr → ohne Jahr →
|
||||||
|
tokenweises Titel-Stripping; jede Stufe senkt die Confidence.
|
||||||
|
- [ ] **Duplikat-Schutz**: Fingerprint-Treffer → „schon gerippt am X — trotzdem?"
|
||||||
|
- [ ] **Track-Auswahl (Manual Mode), besser als ARM**: Job-Status
|
||||||
|
awaiting_selection nach makemkvcon-info; Track-Tabelle im UI mit
|
||||||
|
Heuristik-Vorauswahl; Timeout → Weiter mit Default (ARM bricht hart ab,
|
||||||
|
erlaubt nur EINE Auswahl).
|
||||||
|
- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht
|
||||||
|
Jellyfin nativ) statt alles flach.
|
||||||
|
- [x] **Benachrichtigungen bei Job-Ende** — 24.07., eigenes notify-Modul
|
||||||
|
(Discord/Slack/ntfy/generisches JSON, Dienst-Erkennung an der URL)
|
||||||
|
statt Apprise-Dependency; Test-Knopf im UI. Telegram/Deep-Links offen.
|
||||||
|
- [ ] **Multi-Drive-Verwaltung**: drives-Tabelle (Seriennummer, Custom-Name,
|
||||||
|
UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung.
|
||||||
|
- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs.
|
||||||
|
- [ ] **Jellyfin-Refresh** nach Ablage (POST /Library/Refresh).
|
||||||
|
|
||||||
|
**ARM schlagen (Alleinstellung):**
|
||||||
|
- [ ] **Serien-Episoden-Erkennung** — ARMs offene Wunde seit 2019 (#395):
|
||||||
|
TVDB-Episodenlaufzeiten gegen Track-Laufzeiten matchen (±5 %,
|
||||||
|
Reihenfolge-Constraint, Disc-Nr aus Label), Vorschlag
|
||||||
|
„Show/Season 02/Show S02E05.mkv" im Preview bestätigen.
|
||||||
|
- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt
|
||||||
|
ARMs „größte Datei gewinnt" (#1297-Fehlklasse).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 13 (24.07.2026): Universal-Komfort-Runde — Lücken aus dem Alltag schließen
|
||||||
|
|
||||||
|
**Quelle:** Commander-Sammelauftrag 24.07. — alles, was beim echten Benutzen
|
||||||
|
auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben.
|
||||||
|
|
||||||
|
**Gebaut:**
|
||||||
|
- [x] **Ampel entrostet**: bcrypt auf 4.0.1 gepinnt (passlib-1.7.4-Bruch) und
|
||||||
|
cache_keys-Test an das Fingerprint-Format vom 23.07. angepasst — DIE
|
||||||
|
beiden Rot-Ursachen, wegen derer `stable` 10 Commits zurückhing.
|
||||||
|
- [x] **Media-Server-Integration**: mediaServer-Setting (Wizard + Einstellungen),
|
||||||
|
Zielordner „Titel (Jahr)", movie.nfo/tvshow.nfo + poster.jpg für
|
||||||
|
Jellyfin/Emby/Kodi (medien.py im Worker, mit Tests).
|
||||||
|
- [x] **Benachrichtigungen echt gemacht**: notify.py (API+Worker), Meldung bei
|
||||||
|
fertig/fehlgeschlagen/abgebrochen, Anleitung + Test-Knopf im UI —
|
||||||
|
das Webhook-Feld war vorher ein Placebo, nichts hat je gesendet.
|
||||||
|
- [x] **SMB-Freigaben-Scan repariert**: NT_STATUS-Fehler → Klartext (Gast-
|
||||||
|
Abfrage verweigert ⇒ Benutzer/Passwort nötig), Credential-Felder im UI
|
||||||
|
VOR den „Freigaben auflisten"-Knopf gezogen.
|
||||||
|
- [x] **4K-UHD-Platzproblem**: Arbeitsverzeichnis konfigurierbar (workDir,
|
||||||
|
z. B. NAS-Freigabe), Platz-Check per Disc-Größe (ioctl) VOR dem Rip.
|
||||||
|
- [x] **MakeMKV-Key über UI** (Einstellungen → System): gilt ab dem nächsten
|
||||||
|
Rip, ohne Rebuild; Worker melden Werkzeug-Versionen + Key-Quelle.
|
||||||
|
- [x] **Job-Detail-Popup**: Klick auf Titel in „Neueste Jobs" → Poster, Jahr,
|
||||||
|
Beschreibung, Ablagepfad, Fehler (Job speichert Metadaten jetzt mit).
|
||||||
|
- [x] **UI-Feedback überall**: Toast-System (reinploppende Meldungen), drehende
|
||||||
|
Refresh-Knöpfe, Logs-Filter um Warnung/Fehler-Pills ergänzt.
|
||||||
|
- [x] **Datei-Browser zeigt Dateien**: Ordner wirkten „leer", weil nur
|
||||||
|
Unterordner gelistet wurden — Dateien erscheinen jetzt grau mit Größe.
|
||||||
|
- [x] Favicon (Disc im Logo-Verlauf) + Footer „Created with ❤️ by LucyAI,
|
||||||
|
Claude and KrBrZ".
|
||||||
|
- [x] README: Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
|
||||||
|
„Rippy woanders bereitstellen" (beliebiger Docker-Host).
|
||||||
|
- [x] **Download-Knopf für fertige Rips** (Wunsch aus der Übernahme-Session):
|
||||||
|
GET /jobs/{id}/files (+ Datei-Stream, Pfad-Validierung strikt unter
|
||||||
|
/app/media inkl. realpath-Check), Download-Knopf in der Aktion-Spalte,
|
||||||
|
Dateiliste mit Größen im Job-Detail-Popup — vorher kam man an fertige
|
||||||
|
MKVs nur per scp. nginx hatte proxy_buffering off schon (SSE).
|
||||||
|
|
||||||
|
**Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 14 (24.07.2026): Praxis-Feedback-Runde — erste echte Nutzung
|
||||||
|
|
||||||
|
**Quelle:** Commander-Feedback nach dem ersten richtigen Arbeiten mit v3.2.
|
||||||
|
|
||||||
|
- [x] SMB-Mount-Blocker: CAP_DAC_READ_SEARCH für mount.cifs (auf der VM
|
||||||
|
reproduziert + bewiesen), Compose-Fix.
|
||||||
|
- [x] TMDB-Key-Falle: v3-Schlüssel wurden still 401 (Client konnte nur
|
||||||
|
v4-Bearer) — beide Arten unterstützt, „Verbindung prüfen" im UI.
|
||||||
|
- [x] 4K UHD als eigener Disc-Typ (≥ 55 GiB) mit eigener Farbe + Klartext-
|
||||||
|
Fehler bei Nicht-LibreDrive-Laufwerken.
|
||||||
|
- [x] Vollautomatik-Setting (Disc rein → Rip startet ohne Popup).
|
||||||
|
- [x] Job-Verwaltung: can_retry (Knopf nur bei vorhandenen Rohdaten),
|
||||||
|
Einzel-Löschen, „Erledigte aufräumen", „Alle herunterladen".
|
||||||
|
- [x] Worker: WORKER_NAME-Anzeigename + IP/ID, verwaiste Einträge löschbar.
|
||||||
|
- [x] Ripping-Tab nach Medium (Video/Audio/Allgemein), Untertitel-Klartext,
|
||||||
|
CD → Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung eingeklappt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 15 (24.07.2026): Restefeger — der Ideen-Katalog wird abgearbeitet
|
||||||
|
|
||||||
|
**Commander-Auftrag:** „Arbeite alles ab, abgesehen von Auth — das ist
|
||||||
|
irrelevant und kann komplett raus."
|
||||||
|
|
||||||
|
- [x] **AUTH KOMPLETT ENTFERNT** (Commander-Entscheid): /token- und
|
||||||
|
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT,
|
||||||
|
JWT_SECRET_KEY-Pflicht — alles raus (KONZEPT §10). Der Schnellstart
|
||||||
|
braucht keine .env-Pflichtwerte mehr. Rate-Limit pro IP bleibt.
|
||||||
|
- [x] **Serien-Staffel-Flow**: Im Rip-Dialog Serienname + Staffel →
|
||||||
|
Ablage `<Serie>/Season NN`; tvshow.nfo + poster.jpg landen im
|
||||||
|
Serien-Ordner (werden bei Staffel 2 nicht überschrieben).
|
||||||
|
- [x] **Episoden-Erkennung per Laufzeitabgleich** (ARM-Wunde #395):
|
||||||
|
Datei-Laufzeiten (HandBrake-Scan) gegen TMDB-Episoden-Laufzeiten,
|
||||||
|
ordnungserhaltend; komplette Staffel auf einer Disc geht auch bei
|
||||||
|
uniformen Laufzeiten. Umbenannt wird NUR bei eindeutiger Zuordnung
|
||||||
|
(„Serie S01E02.mkv"), sonst bleiben die Namen + ehrliches Log.
|
||||||
|
- [x] **Jellyfin/Emby-Bibliotheks-Refresh** nach jedem fertigen Rip
|
||||||
|
(POST /Library/Refresh, X-Emby-Token) — URL/Key + Test-Knopf in den
|
||||||
|
Einstellungen. Die Null-Klick-Kette Disc→Bildschirm steht.
|
||||||
|
- [x] **Duplikat-Warnung**: Disc-Fingerabdruck gegen die Job-Historie —
|
||||||
|
Hinweis auf der Disc-Karte, Vollautomatik überspringt Duplikate.
|
||||||
|
- [x] **„Nur Hauptfilm" ECHT**: das Setting war wirkungslos — jetzt
|
||||||
|
Info-Lauf → längster Titel → nur der wird gerippt; pro Rip im
|
||||||
|
Dialog übersteuerbar. (Volle Track-Tabelle bleibt Ausbaustufe.)
|
||||||
|
- [x] **Deutsche Texte für OMDb-Treffer** via TMDB /find (IMDb-ID → de-DE).
|
||||||
|
- [x] **Speicherplatz in der Dashboard-Leiste** (amber unter 60 GB frei).
|
||||||
|
- [x] **CSV-Export** der Job-Historie (Semikolon+BOM, Excel-tauglich).
|
||||||
|
- [x] **Metadaten-Seite entfernt** (+ Placebo-Endpoints /metadata/lookup
|
||||||
|
und /metadata/confirm — lookup scannte ein Dummy-Device).
|
||||||
|
- [x] **Doppel-Jahr-Fix** („X (2009) (2009)" in Log/Ordnernamen).
|
||||||
|
- [x] **Remote-Worker-Blocker**: redis/postgres waren nie veröffentlicht —
|
||||||
|
kein Remote-Worker konnte sich je verbinden. Ports 6379/5432 jetzt
|
||||||
|
offen (Heimnetz-Kompromiss, dokumentiert) + API_URL für Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 16 (24.07.2026): Track-Tabelle + nativer Windows-Worker + Anleitung
|
||||||
|
|
||||||
|
- [x] **Volle Titel-Auswahl vor dem Rip**: scan_tracks-Worker-Task,
|
||||||
|
Polling-Endpoints, Tabelle (Dauer/Größe/Kapitel, Vorauswahl ≥ 5 min)
|
||||||
|
im Rip-Dialog, Multi-Titel-Rip (ein makemkvcon-Aufruf je Titel,
|
||||||
|
Fortschritt anteilig). Parser mit Tests.
|
||||||
|
- [x] **Nativer Windows-Worker ohne Docker**: install.ps1 + Worker-Code
|
||||||
|
werden von der Rippy-Instanz selbst serviert (/worker-setup/*);
|
||||||
|
HandBrakeCLI 1.9.2 vom offiziellen GitHub-Release; celery
|
||||||
|
--pool=solo -Q transcode; optional Autostart. UI bietet Linux- und
|
||||||
|
Windows-Variante. E2E auf echtem Windows-PC bewiesen.
|
||||||
|
- [x] **Anleitungs-Tab**: Rippy erklärt sich selbst (Disc-Weg, Serien,
|
||||||
|
NAS, Media-Server, Benachrichtigungen, Key, Worker, FAQ).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideen-Katalog (Rest) — bewusst offen
|
||||||
|
|
||||||
|
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
||||||
|
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
|
||||||
|
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
|
||||||
|
`theme === 'dark'`-Ternaries → Tailwind-`dark:` + UI-Primitives,
|
||||||
|
Optik-Modernisierung, ohne Funktions-/Text-Änderungen. Der neue
|
||||||
|
Anleitungs-Tab gehört mit in den Feinschliff.
|
||||||
|
2. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
|
||||||
|
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
|
||||||
|
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
|
||||||
|
3. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
|
||||||
|
Jellyfin/Emby.
|
||||||
|
4. **Windows-Worker als echter Dienst** (statt start-worker.bat/onlogon):
|
||||||
|
z. B. via NSSM oder PyInstaller-Paket — Komfort-Ausbau der jetzt
|
||||||
|
funktionierenden nativen Variante.
|
||||||
|
|||||||
+297
-1
@@ -1,6 +1,302 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.0 — Kernumbau (23.07.2026, Claude)
|
## Aktueller Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
|
||||||
|
|
||||||
|
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
|
||||||
|
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
|
||||||
|
ps2exe kompiliert: eingebettetes Rippy-Disc-Icon, KEIN Konsolenfenster
|
||||||
|
(-noConsole), WinForms (-STA). Vorgebaut auf Windows
|
||||||
|
(deploy/worker-windows/build-exe.ps1) + committet — Windows-.exe geht
|
||||||
|
nicht von Linux (Rippy-Host). API: GET /worker-setup/windows-exe.
|
||||||
|
UI (Worker → Windows): „Installer herunterladen (.exe)" als Haupt-Weg.
|
||||||
|
**E2E bewiesen**: von der VM geladen → Fenster öffnet, conhost-Zähler
|
||||||
|
unverändert (kein Konsolenfenster), Icon eingebettet.
|
||||||
|
- **Wichtig — friert nichts ein**: Die .exe holt HandBrake zur Laufzeit
|
||||||
|
(GitHub latest) und den Worker-Code live von Rippy. Nur bei Änderung der
|
||||||
|
GUI (install-gui.ps1) neu bauen (build-exe.ps1), NICHT bei Tool-Updates.
|
||||||
|
- **Update-Story dokumentiert** (Commander-Frage): MakeMKV → .env-Bump +
|
||||||
|
Rebuild (Update-Check zeigt es); HandBrake Docker = Debian-stabil,
|
||||||
|
native Worker = latest beim Installieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.8 — Grafischer Windows-Installer + Versionslage klar (24.07.2026)
|
||||||
|
|
||||||
|
- **Grafischer Windows-Installer** (statt CLI): install-gui.ps1 (WinForms,
|
||||||
|
ASCII-only) — Fenster mit Feldern (Rippy-IP, Worker-Name, Autostart) +
|
||||||
|
Install-Knopf mit Live-Log + „Worker starten". Gleiche Schritte wie der
|
||||||
|
CLI-Installer (Worker-Code, HandBrake latest, venv, Tray/Start/Uninstall).
|
||||||
|
UI (Worker → Windows): Knopf „Grafischen Installer herunterladen (.bat)" —
|
||||||
|
die .bat wird CLIENT-seitig mit der eingetragenen LAN-IP erzeugt,
|
||||||
|
Doppelklick lädt+startet die GUI von Rippy (GET /worker-setup/windows-gui).
|
||||||
|
CLI-Befehl bleibt als Profi-Alternative. Bewiesen: GUI-Fenster öffnet
|
||||||
|
sauber auf dem Commander-PC (Titel „Rippy Encoding-Worker - Installation").
|
||||||
|
- **Werkzeug-Versionslage exakt dargestellt** (Commander-Wunsch): System-Tab
|
||||||
|
erklärt jetzt klar — MakeMKV aktualisierbar (wichtig wegen Schlüssel-DB),
|
||||||
|
HandBrake im Docker-Worker bewusst Debian-stabil (1.6.1, KEIN Alarm mehr),
|
||||||
|
native Worker holen die neueste. Update-Check zeigt MakeMKV mit „✓ aktuell"
|
||||||
|
bzw. Update-Befehl, HandBrake neutral als Info.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.7 — Drei Praxis-Bugs (Mounts, HandBrake, Encoder-Wahl) (24.07.2026)
|
||||||
|
|
||||||
|
Alle drei live auf der VM verifiziert:
|
||||||
|
- **Speicher-Mounts robust**: Ein toter CIFS-Mount (NAS weg/Rebuild) war
|
||||||
|
mounted:false, verschwand aus „Verfügbare Ziele" und ließ sich nicht neu
|
||||||
|
anlegen („Name existiert"). Jetzt: `reachable`-Status (bounded `timeout 3
|
||||||
|
ls` — Endpoint lädt in 0,014 s statt 10 s), `umount -l`-Fallback,
|
||||||
|
`reparieren()` (lazy abhängen + frisch mounten), Re-Add repariert statt
|
||||||
|
409, POST /storage-mounts/{name}/repair, eigene „Netzwerk-Mounts"-Liste im
|
||||||
|
UI mit Status + Reparieren/Entfernen. Bewiesen: Reparatur des `rippy`-
|
||||||
|
Mounts → mounted:true, writable:true.
|
||||||
|
- **HandBrake-Versionen konsistent**: Windows-Installer zieht dynamisch die
|
||||||
|
neueste (GitHub latest) — bewiesen: HandBrake 1.11.2 installiert (statt
|
||||||
|
fest 1.9.2). Update-UI erklärt: Docker = Debian-stabil (bewusst älter),
|
||||||
|
Windows = neueste. Nebenbei ein latenter Installer-Bug gefixt: PowerShell
|
||||||
|
5.1 liest .ps1 als ANSI — ein Gedankenstrich zerschoss das Skript →
|
||||||
|
install.ps1 ist jetzt ASCII-only.
|
||||||
|
- **Encoder-/Worker-Wahl beim Rip**: Celery `worker_direct=True`, gezieltes
|
||||||
|
Routing (transcode_queue) mit sicherem Fallback auf die geteilte Queue.
|
||||||
|
Bewiesen mit ping_worker: Task landet exakt beim gewählten Node. Dropdown
|
||||||
|
im Rip-Dialog ab 2 Online-Workern. Node-Zuordnung disambiguiert bei
|
||||||
|
mehreren Workern pro Host. Nebenfund gefixt: DeviceDiscovery leitete die
|
||||||
|
Titel-Auswahl (titles) gar nicht weiter.
|
||||||
|
⚠️ Gezieltes Routing braucht AKTUELLEN Worker-Code (worker_direct) — ein
|
||||||
|
vor dieser Version installierter Remote-Worker pingt zwar, konsumiert aber
|
||||||
|
seine Direkt-Queue nicht → neu installieren.
|
||||||
|
- **Voller Transcode-E2E weiterhin durch die UHD-Key-Lage blockiert** (keine
|
||||||
|
entschlüsselbare Disc) — die Routing-Mechanik ist per ping_worker bewiesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.6 — Design 2.0 gelandet + Ein-Branch-Umstellung (24.07.2026)
|
||||||
|
|
||||||
|
- **Design 2.0 ist in `main`** (von Gemini umgesetzt): UI von 428
|
||||||
|
`theme === 'dark'`-Ternaries auf Tailwind-`dark:` + UI-Primitives
|
||||||
|
(`components/ui/`, `lib/design.ts`) umgebaut, „Cinematic Cinema OS"-Look.
|
||||||
|
Ampel grün (49 Tests, Ruff, Vite-Build), Live auf der VM.
|
||||||
|
- **Nur noch EIN Branch: `main`** (Commander-Entscheid). Der
|
||||||
|
`stable`-Zwischenbranch samt Grün-Gate ist abgeschafft — er war
|
||||||
|
vestigial, weil die VM ohnehin aus `main` deployt. Konsequenzen:
|
||||||
|
- ci.yml: Beförderungs-Schritt raus, die Ampel PRÜFT nur noch.
|
||||||
|
- deploy.sh: nutzt `main` statt `stable`.
|
||||||
|
- AGENTS §C / README / DESIGN-Briefing entsprechend umgeschrieben.
|
||||||
|
- Gelöschte Branches: `stable`, `design-2.0` (gemergt), `kernumbau-2026-07-23` (alt).
|
||||||
|
- **Deploy-Weg ab jetzt:** push → Ampel grün prüfen → auf der VM
|
||||||
|
`git pull` (in ~/projects/rippy) bzw. `./deploy.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
|
||||||
|
|
||||||
|
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
|
||||||
|
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
||||||
|
'tracks:<device>', UI pollt) → Tabelle mit Checkbox/Dauer/Größe/Kapiteln,
|
||||||
|
Vorauswahl ab 5 min. Gewählte Titel gehen als meta.titles in den Job;
|
||||||
|
der Worker rippt sie einzeln (makemkvcon kann pro Aufruf nur einen Titel
|
||||||
|
oder all), Fortschritt anteilig. Parser mit Tests (apdefs-Attr 8/9/11).
|
||||||
|
- **Nativer Windows-Worker (ohne Docker!)**: Einstellungen → Worker bietet
|
||||||
|
jetzt ZWEI Varianten — Linux (Docker, wie gehabt) und Windows (nativ,
|
||||||
|
braucht nur Python). Die Rippy-Instanz versorgt sich selbst:
|
||||||
|
GET /worker-setup/windows liefert install.ps1, /worker-setup/paket den
|
||||||
|
Worker-Code als Zip (beides liegt via api-Dockerfile im Image).
|
||||||
|
install.ps1: venv + Abhängigkeiten, HandBrakeCLI 1.9.2 vom offiziellen
|
||||||
|
GitHub-Release (URL verifiziert), start-worker.bat (celery -Q transcode
|
||||||
|
--pool=solo), optional -Autostart (schtasks onlogon). tasks.py lädt auf
|
||||||
|
Windows ohne fcntl (Import-Guard); rip_disc ist dort hart verriegelt.
|
||||||
|
Für echte Transcodes übersetzt RIPPY_PATH_MAP die Container-Pfade aufs
|
||||||
|
Netzlaufwerk (pfad_lokal, mit Tests) — Voraussetzung bleibt eine
|
||||||
|
Freigabe der Rippy-Ablage (derselbe offene Infra-Punkt wie beim
|
||||||
|
Linux-Remote-Worker/AI-Box).
|
||||||
|
**E2E-BEWEIS (24.07., Commander-PC):** Installer in einem Rutsch
|
||||||
|
durchgelaufen (venv, Abhängigkeiten, HandBrakeCLI 1.9.2), Worker
|
||||||
|
gestartet und in Rippy erschienen:
|
||||||
|
`test-windows-nativ | online: True | HandBrake: 1.9.2 | IP: 192.168.178.98`
|
||||||
|
— echte LAN-IP, Celery-Ping grün. Testeintrag danach wieder entfernt.
|
||||||
|
- **Tray-Symbol + rückstandsfreie Deinstallation (v3.5.1, bewiesen):**
|
||||||
|
tray.py (pystray/Pillow, nur Windows-Worker) — Disc-Symbol neben der
|
||||||
|
Uhr mit Status, Start/Stopp, „Rippy öffnen", Log, Beenden; Celery läuft
|
||||||
|
als Kind ohne Konsolenfenster. install.ps1 erzeugt start-tray.bat
|
||||||
|
(empfohlen), start-worker.bat (Debug) und uninstall.ps1. **E2E bewiesen
|
||||||
|
auf dem Commander-PC:** Frisch installiert → Tray + celery-Kind liefen →
|
||||||
|
`tray-test` online in Rippy → uninstall.ps1 -Force stoppte alle
|
||||||
|
Prozesse, meldete den Worker per DELETE /workers/{name} ab und löschte
|
||||||
|
den Ordner restlos (danach: 0 Prozesse, Ordner weg, in Rippy nur noch
|
||||||
|
rippy-hauptworker).
|
||||||
|
- **Anleitungs-Tab** im UI: kompletter Selbsterklärer (Disc-Weg, Serien,
|
||||||
|
NAS, Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).
|
||||||
|
- Design 2.0 kommt bewusst in eine FRISCHE Session (Commander-Entscheid) —
|
||||||
|
Infrastruktur steht (darkMode 'class'), reine Konvertierungsarbeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Commander-Entscheid: AUTH IST KOMPLETT RAUS** (Heimnetz-only; das UI hatte
|
||||||
|
nie einen Login, die Endpoints waren Placebo, passlib/bcrypt brach die Ampel).
|
||||||
|
/token + /api-keys + auth.py + Abhängigkeiten entfernt, JWT_SECRET_KEY ist
|
||||||
|
keine Pflicht mehr — der Schnellstart läuft ohne .env-Zwang. KONZEPT §10.
|
||||||
|
|
||||||
|
**Neu in v3.4 (Details in ROADMAP Etappe 15):**
|
||||||
|
- **Serien-Flow**: Rip-Dialog fragt Serienname + Staffel → Ablage
|
||||||
|
`<Serie>/Season NN`, tvshow.nfo/poster im Serien-Ordner, und die
|
||||||
|
**Episoden werden per Laufzeitabgleich (TMDB) erkannt und benannt**
|
||||||
|
(„Serie S01E02.mkv") — nur bei eindeutiger Zuordnung, sonst ehrliches Log.
|
||||||
|
Komplette Staffel auf einer Disc klappt auch bei uniformen Anime-Laufzeiten.
|
||||||
|
- **Jellyfin/Emby-Refresh** nach jedem fertigen Rip (URL/API-Key +
|
||||||
|
Test-Knopf in Einstellungen → Ripping) — Disc rein, Film erscheint im
|
||||||
|
Server, null Klicks dazwischen.
|
||||||
|
- **Duplikat-Warnung** per Disc-Fingerabdruck (Karte zeigt „bereits
|
||||||
|
gerippt", Vollautomatik überspringt).
|
||||||
|
- **„Nur Hauptfilm" funktioniert jetzt wirklich** (Info-Lauf → längster
|
||||||
|
Titel; pro Rip im Dialog wählbar). Vorher wirkungsloses Setting.
|
||||||
|
- **OMDb-Treffer werden eingedeutscht** (TMDB /find über die IMDb-ID).
|
||||||
|
- Dashboard: **Speicherplatz-Anzeige** (amber < 60 GB) + **CSV-Export**.
|
||||||
|
- **Metadaten-Seite entfernt** (Korrektur-Popup ist der einzige Weg) samt
|
||||||
|
Placebo-Endpoints; Doppel-Jahr „(2009) (2009)" gefixt.
|
||||||
|
- **Remote-Worker-Blocker behoben**: redis/postgres waren NIE veröffentlicht
|
||||||
|
— Ports 6379/5432 jetzt offen, API_URL für Worker gesetzt. Damit sind
|
||||||
|
AI-Box/Windows-Worker überhaupt erst anschließbar.
|
||||||
|
|
||||||
|
**Bewusst offen** (ROADMAP „Ideen-Katalog (Rest)"): volle Track-Tabelle,
|
||||||
|
nativer Windows-Worker (braucht Testlauf auf Ziel-Hardware), Design-2.0-
|
||||||
|
Konvertierung (Infrastruktur steht), AI-Box-NFS-Export, Kodi-Refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.3 — Praxis-Feedback-Runde (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Zwei echte Bugs mit Beweis gefixt:**
|
||||||
|
1. **SMB-Mount „Unable to apply new capability set"**: mount.cifs hebt
|
||||||
|
CAP_DAC_READ_SEARCH an — die fehlt in Dockers Default-Caps. Auf der VM
|
||||||
|
reproduziert (Bounding-Set a82425fb, Bit 2 fehlt) und mit
|
||||||
|
`cap_add: DAC_READ_SEARCH` bewiesen behoben (docker-compose.yml).
|
||||||
|
2. **TMDB fiel still aus**: Der Client konnte nur v4-Bearer-Tokens — der
|
||||||
|
eingetragene übliche v3-Key (32 Hex) bekam still 401, Suche lieferte nur
|
||||||
|
OMDb. Jetzt beide Key-Arten (ist_v4_token, mit Tests); Einstellungen →
|
||||||
|
APIs hat „Verbindung prüfen" mit Live-Status je Quelle (am Cache vorbei).
|
||||||
|
|
||||||
|
**Neu in v3.3:**
|
||||||
|
- **4K UHD als eigener Disc-Typ** (classify ≥ 55 GiB, beide detection.py,
|
||||||
|
Tests): eigene Badge-Farbe überall, Prescan-Label „4K UHD".
|
||||||
|
- **UHD-Kette diagnostiziert (Nachtrag, gleicher Tag): Der BU40N-Flash IST
|
||||||
|
erledigt** — makemkvcon meldet „Using LibreDrive mode (v06.3)". Der
|
||||||
|
Summer-Wars-Fehlschlag lag NICHT am Laufwerk, sondern an „The volume key
|
||||||
|
is unknown": die Disc (MKB v82) ist neuer als MakeMKVs Schlüssel-DB —
|
||||||
|
auf der VM mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen. Konsequenzen:
|
||||||
|
MakeMKV auf **1.18.4** gehoben (Scan-Hänger durch überall genutztes
|
||||||
|
--noscan umgangen, auf der BU40N sauber gelaufen; URL-Base → /download),
|
||||||
|
run_makemkv reicht kritische Meldungen (volume key/Key abgelaufen) in
|
||||||
|
den Fehlertext durch, UHD-Klartext unterscheidet jetzt „Laufwerk kann
|
||||||
|
kein UHD" von „Disc neuer als Key-DB" inkl. Forum-Dump-Hinweis
|
||||||
|
(MakeMKV speichert den AACS-Dump automatisch unter /root/.MakeMKV/).
|
||||||
|
Summer Wars bleibt bis zu einem MakeMKV-Key-Update nicht entschlüsselbar
|
||||||
|
— das ist Stand der Technik, kein Rippy-Bug.
|
||||||
|
- **Vollautomatik** (Einstellungen → Ripping): Disc erkannt → Rip startet
|
||||||
|
ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik).
|
||||||
|
- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich
|
||||||
|
daliegen (can_retry); Jobs einzeln löschbar (Papierkorb) + „Erledigte
|
||||||
|
aufräumen" mit Bestätigungs-Dialog — Dateien bleiben immer liegen.
|
||||||
|
- **„Alle herunterladen"** im Job-Detail (gestaffelte Einzel-Downloads —
|
||||||
|
bewusst kein Server-seitiges 40-GB-Zip).
|
||||||
|
- **Worker zuordenbar**: WORKER_NAME-Env als stabiler Anzeigename (compose:
|
||||||
|
rippy-hauptworker; Remote-Worker: frei wählbar) — fixt zugleich die
|
||||||
|
Offline-Leichen nach Rebuilds; IP + Container-ID werden mit angezeigt,
|
||||||
|
verwaiste Einträge sind löschbar (DELETE /workers/{name}). Online-Abgleich
|
||||||
|
läuft jetzt über info.hostname.
|
||||||
|
- **Disc-Karte**: „Quelle: TMDB/OMDb/MyAnimeList · xx % sicher" statt des
|
||||||
|
nackten „Übereinstimmung xx %"; TMDB-Metadaten sind Deutsch (language=
|
||||||
|
de-DE war schon überall dran — sie kamen nur nie an, siehe Key-Bug).
|
||||||
|
- **Ripping-Tab nach Medium** gegliedert (Video / Audio-CD / Allgemein) +
|
||||||
|
Klartext: alle Tonspuren & Untertitel bleiben erhalten (--all-audio/
|
||||||
|
--all-subtitles) — wichtig für Anime.
|
||||||
|
- **Ordner-Verwaltung** (Ex-„Dateibrowser") ist jetzt beschriftet, erklärt
|
||||||
|
und standardmäßig eingeklappt.
|
||||||
|
- **Docker-Pflicht beim Worker**: ehrlich im UI beantwortet; nativer
|
||||||
|
Windows-Dienst steht als Ausbaustufe in der ROADMAP (Ideen-Katalog).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.2 — Universal-Komfort-Runde + Ampel entrostet (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Wichtigster Befund zuerst: die Ampel war seit dem 23.07. ROT und `stable`
|
||||||
|
hing 10 Commits hinter `main`** — deshalb kam nichts Neues mehr auf die VM.
|
||||||
|
Zwei Ursachen, beide behoben:
|
||||||
|
1. bcrypt ≥ 4.1 bricht passlib 1.7.4 (`__about__` entfernt → Selbsttest wirft
|
||||||
|
„password cannot be longer than 72 bytes") → **bcrypt==4.0.1 gepinnt**.
|
||||||
|
2. Der cache_keys-Test kannte den Disc-Fingerprint im Prescan-Key (23.07.)
|
||||||
|
nicht → Test an das echte Format angepasst + Fingerprint-Testfall dazu.
|
||||||
|
|
||||||
|
**Neu in v3.2 (Commander-Sammelauftrag, alles mit Tests / Vite-Build grün):**
|
||||||
|
- **Media-Server-Integration**: Setting `mediaServer` (Wizard + Einstellungen →
|
||||||
|
Ripping): Zielordner „Titel (Jahr)" statt Job-UUID; für Jellyfin/Emby/Kodi
|
||||||
|
zusätzlich movie.nfo/tvshow.nfo + poster.jpg (Worker: medien.py). Plex = nur
|
||||||
|
Benennung. Job speichert Disc-Metadaten jetzt mit (jobs.meta, Migration).
|
||||||
|
- **Benachrichtigungen ECHT**: notify.py in API+Worker (Discord/Slack/ntfy/
|
||||||
|
generisch, Erkennung an der URL) — das Feld war vorher ein Placebo. Meldung
|
||||||
|
bei fertig/fehlgeschlagen/abgebrochen; Anleitung + „Test senden" im UI.
|
||||||
|
- **SMB-Scan-Fix**: „NT_STATUS_ACCESS_DENIED" heißt jetzt im Klartext „Gast-
|
||||||
|
Abfrage verweigert → Benutzer/Passwort eintragen"; Credential-Felder stehen
|
||||||
|
im UI VOR dem Auflisten-Knopf. NAS-Ziel-Anlage quittiert per Toast.
|
||||||
|
- **4K-UHD-Vorsorge**: Arbeitsverzeichnis `workDir` konfigurierbar (auf
|
||||||
|
NAS-Freigabe legbar), Platz-Check per Disc-Größe (ioctl) VOR dem Rip mit
|
||||||
|
Klartext-Abbruch statt voller Platte bei 40 GB.
|
||||||
|
- **Einstellungen → System**: Werkzeug-Versionen je Worker (MakeMKV/HandBrake,
|
||||||
|
ENV MAKEMKV_VERSION im Image), Key-Quelle (ui/env/keiner), freier Platz;
|
||||||
|
**MakeMKV-Beta-Key im UI pflegbar** — gilt ab dem nächsten Rip, ohne Rebuild.
|
||||||
|
- **Job-Detail-Popup** (Klick auf Titel in „Neueste Jobs"): Poster, Jahr,
|
||||||
|
Beschreibung, Genres, Ablagepfad, Fehler (GET /jobs/{id}/detail).
|
||||||
|
- **Download fertiger Rips im Browser**: „Download"-Knopf bei fertigen Jobs
|
||||||
|
(Aktion-Spalte) → Dateiliste mit Größen im Detail-Popup, Stream via
|
||||||
|
GET /jobs/{id}/files/{name} (Pfad-Validierung strikt unter /app/media,
|
||||||
|
realpath-Check gegen Symlink-Ausbrüche) — vorher nur per scp erreichbar.
|
||||||
|
- **UI-Feedback**: Toast-System (ToastContext, ploppt oben rechts), drehende
|
||||||
|
Refresh-Knöpfe, Logs-Pills um Warnung/Fehler ergänzt, Datei-Browser zeigt
|
||||||
|
jetzt auch DATEIEN (grau, mit Größe) — der „leere" Bluray-Ordner war voll.
|
||||||
|
- **Favicon** (Disc, Indigo-Verlauf) + **Footer** „Created with ❤️ by LucyAI,
|
||||||
|
Claude and KrBrZ".
|
||||||
|
- **Doku**: README (Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
|
||||||
|
„Rippy woanders bereitstellen"), ROADMAP Etappe 13 + Ideen-Katalog,
|
||||||
|
KONZEPT-Fortschreibungen (Abschnitt 10), AGENTS-Stand.
|
||||||
|
|
||||||
|
**Nächste Schritte:** unverändert die Fäden aus v3.1 (unten) + Ideen-Katalog
|
||||||
|
in der ROADMAP (Serien-Flow, Duplikat-Warnung, Jellyfin-Refresh, Auth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.1 — E2E BEWIESEN + Universal-Runde (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Der Beweis steht: Evangelion 2.22 (BD-50) komplett durch die Kette** —
|
||||||
|
erkannt → verlustfrei gerippt (43 GB, LibreDrive) → H.264-komprimiert →
|
||||||
|
**4,8 GB Endergebnis** (Hauptfilm 3,3 GB + 4 Extras), Rohdaten automatisch
|
||||||
|
gelöscht. Retry-transcode dabei live bewiesen (x265 war auf 4 vCPUs zu lahm
|
||||||
|
→ Abbruch + Neustart mit H.264 OHNE Neu-Rip).
|
||||||
|
|
||||||
|
Seit v3.0 dazugekommen (alles deployt, Ampel grün):
|
||||||
|
- **Korrektur-Flow**: „Nicht korrekt?!"-Popup an der Disc-Karte; Suche über
|
||||||
|
alle Quellen (GET /metadata/search), Wahl wird 30 Tage per
|
||||||
|
Disc-Fingerabdruck gemerkt (POST /metadata/override)
|
||||||
|
- **Metadaten-Kette**: BD-Klartext-Titel via ro-Mount (bdmt_*.xml),
|
||||||
|
Jikan/MAL (keyless, Ähnlichkeits-Score), OMDb-Qualitäts-Gate,
|
||||||
|
Wort-Strip-Degradation, ehrliches Cache-TTL (unknown nie cachen)
|
||||||
|
- **Worker-Verwaltung** (Einstellungen → Worker): Live-Erreichbarkeit
|
||||||
|
(Celery-Ping + Minuten-Herzschlag), Copy-Paste-Anbindung neuer Maschinen
|
||||||
|
- **Speicherziele**: SMB-Freigaben-Auflistung, Schreibtest beim Mount,
|
||||||
|
lokaler Ordner-Browser + mkdir; Mounts via UI (CAP_SYS_ADMIN + rshared)
|
||||||
|
- **First-Run-Wizard**, Job-Abbruch (kooperativ), Transcode als eigener
|
||||||
|
Task auf Queue `transcode` (Basis Remote-GPU-Worker), ruff.toml gepinnt
|
||||||
|
|
||||||
|
**Nächste Schritte:** AI-Box als VAAPI-Transcode-Worker (VCN4; NFS-Export
|
||||||
|
nötig, deploy/remote-transcode-worker.yml) · TMDB-Key eintragen ·
|
||||||
|
Arcane-GitSync-Binding auf `stable` (aktuell Notfall-Deploy-Weg) ·
|
||||||
|
Metadaten-Seite entfernen, wenn Popup abgenommen · Jellyfin-Postprocessing
|
||||||
|
an Worker · Auth vor schreibende Endpoints · MAKEMKV_APP_KEY erneuern
|
||||||
|
(~Ende Juli, Forum t=1053).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.0 — Kernumbau (23.07.2026, Claude)
|
||||||
|
|
||||||
**Der Zweck existiert jetzt wirklich.** Komplett-Audit ergab: „Disc rein →
|
**Der Zweck existiert jetzt wirklich.** Komplett-Audit ergab: „Disc rein →
|
||||||
gerippt raus" hatte nie einen Code-Pfad (Details in ROADMAP Etappe 11).
|
gerippt raus" hatte nie einen Code-Pfad (Details in ROADMAP Etappe 11).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz.
|
||||||
|
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
|
||||||
|
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
|
||||||
|
#
|
||||||
|
# Auf der GPU-Maschine:
|
||||||
|
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
||||||
|
# 2. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
|
||||||
|
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
|
||||||
|
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
|
||||||
|
# 3. RIPPY_HOST unten setzen und starten:
|
||||||
|
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
|
||||||
|
#
|
||||||
|
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
||||||
|
# unter Einstellungen → Verarbeitung auf. Er bedient NUR die transcode-Queue;
|
||||||
|
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||||
|
|
||||||
|
services:
|
||||||
|
transcode-worker:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/worker/Dockerfile
|
||||||
|
command: ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "transcode", "-n", "gpu-worker@%h"]
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://${RIPPY_HOST:?RIPPY_HOST setzen}:6379/0
|
||||||
|
- DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy
|
||||||
|
- RIP_OUTPUT_DIR=/app/media
|
||||||
|
- RAW_DIR=/app/temp/raw
|
||||||
|
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
|
||||||
|
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
|
||||||
|
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||||
|
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||||
|
- API_URL=http://${RIPPY_HOST}:8000
|
||||||
|
volumes:
|
||||||
|
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
||||||
|
- /mnt/rippy/media:/app/media
|
||||||
|
- /mnt/rippy/temp:/app/temp
|
||||||
|
devices:
|
||||||
|
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
restart: unless-stopped
|
||||||
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()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
||||||
|
#
|
||||||
|
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
||||||
|
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
||||||
|
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
|
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
|
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||||
|
#
|
||||||
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||||
|
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
||||||
|
#
|
||||||
|
# Aufruf (PowerShell):
|
||||||
|
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||||
|
#
|
||||||
|
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||||
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||||
|
[switch]$Autostart
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||||
|
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
|
||||||
|
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
|
||||||
|
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist.
|
||||||
|
$HandBrakeVersion = "1.11.2"
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||||
|
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||||
|
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||||
|
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
|
||||||
|
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Python finden (py-Launcher oder python im PATH)
|
||||||
|
$python = $null
|
||||||
|
foreach ($kandidat in @("py -3", "python")) {
|
||||||
|
try {
|
||||||
|
$version = Invoke-Expression "$kandidat --version" 2>&1
|
||||||
|
if ("$version" -match "Python 3\.(\d+)") {
|
||||||
|
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $python) {
|
||||||
|
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
||||||
|
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausfuehren)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Python gefunden: $python"
|
||||||
|
|
||||||
|
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
|
||||||
|
$ziel = Join-Path (Get-Location) "rippy-worker"
|
||||||
|
New-Item -ItemType Directory -Force $ziel | Out-Null
|
||||||
|
Set-Location $ziel
|
||||||
|
|
||||||
|
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
||||||
|
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||||
|
Expand-Archive worker.zip -DestinationPath . -Force
|
||||||
|
Remove-Item worker.zip
|
||||||
|
|
||||||
|
# 3. venv + Abhaengigkeiten
|
||||||
|
if (-not (Test-Path "venv")) {
|
||||||
|
Write-Host "Lege Python-Umgebung an ..."
|
||||||
|
Invoke-Expression "$python -m venv venv"
|
||||||
|
}
|
||||||
|
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||||
|
# Nur fuer den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||||
|
Write-Host "Python-Abhaengigkeiten installiert."
|
||||||
|
|
||||||
|
# 4. HandBrakeCLI besorgen (falls noetig)
|
||||||
|
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
||||||
|
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
||||||
|
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
||||||
|
Expand-Archive hb.zip -DestinationPath . -Force
|
||||||
|
Remove-Item hb.zip
|
||||||
|
}
|
||||||
|
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
||||||
|
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "HandBrakeCLI bereit."
|
||||||
|
|
||||||
|
# 5. Start-Skript erzeugen
|
||||||
|
$bat = @"
|
||||||
|
@echo off
|
||||||
|
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
|
set API_URL=http://${RippyHost}:8000
|
||||||
|
set WORKER_NAME=$WorkerName
|
||||||
|
set PATH=%~dp0;%PATH%
|
||||||
|
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
||||||
|
"@
|
||||||
|
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||||
|
|
||||||
|
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||||
|
$trayBat = @"
|
||||||
|
@echo off
|
||||||
|
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
|
set API_URL=http://${RippyHost}:8000
|
||||||
|
set WORKER_NAME=$WorkerName
|
||||||
|
set RIPPY_TRAY_HOST=$RippyHost
|
||||||
|
set PATH=%~dp0;%PATH%
|
||||||
|
start "" venv\Scripts\pythonw.exe tray.py
|
||||||
|
"@
|
||||||
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
|
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||||
|
|
||||||
|
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
||||||
|
$uninstall = @"
|
||||||
|
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||||
|
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
||||||
|
param([switch]`$Force)
|
||||||
|
if (-not `$Force) {
|
||||||
|
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
|
||||||
|
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
|
||||||
|
}
|
||||||
|
Get-CimInstance Win32_Process | Where-Object {
|
||||||
|
`$_.ExecutablePath -like "`$PSScriptRoot*"
|
||||||
|
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
|
Start-Sleep 2
|
||||||
|
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
||||||
|
Write-Host "Worker-Eintrag in Rippy entfernt."
|
||||||
|
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand loeschen (Einstellungen -> Worker)." }
|
||||||
|
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||||
|
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||||
|
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
||||||
|
"@
|
||||||
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
|
Write-Host "uninstall.ps1 erzeugt."
|
||||||
|
|
||||||
|
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
||||||
|
if ($Autostart) {
|
||||||
|
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-tray.bat`"" /sc onlogon | Out-Null
|
||||||
|
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (Tray bei Anmeldung)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FERTIG." -ForegroundColor Green
|
||||||
|
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
|
||||||
|
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
|
||||||
|
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
|
||||||
|
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+65
-11
@@ -11,11 +11,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/api/Dockerfile
|
dockerfile: docker/api/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||||
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
- OMDB_API_KEY=${OMDB_API_KEY:-}
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||||
@@ -25,11 +25,30 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
# SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein
|
||||||
|
# (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker
|
||||||
|
# jeden Mount sofort.
|
||||||
|
# DAC_READ_SEARCH: mount.cifs hebt diese Capability an (toggle_dac_
|
||||||
|
# capability) — sie fehlt in Dockers Default-Set, ohne sie stirbt JEDER
|
||||||
|
# SMB-Mount mit "Unable to apply new capability set" (Befund 24.07.,
|
||||||
|
# auf der VM reproduziert und mit dieser Capability bewiesen behoben).
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
- DAC_READ_SEARCH
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
# /srv/rippy/media auf der VM statt anonymem Volume: rshared lässt
|
||||||
|
# Mounts aus DIESEM Container zum Host und in andere Container
|
||||||
|
# propagieren (Commander-Anforderung 23.07.: Ziele frei wählbar).
|
||||||
|
- type: bind
|
||||||
|
source: /srv/rippy/media
|
||||||
|
target: /app/media
|
||||||
|
bind:
|
||||||
|
propagation: rshared
|
||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
devices:
|
devices:
|
||||||
- /dev/sr0:/dev/sr0
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -44,17 +63,44 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/worker/Dockerfile
|
dockerfile: docker/worker/Dockerfile
|
||||||
|
args:
|
||||||
|
# Übersteuerbar via .env — makemkv.com ist zeitweise down (Cloudflare
|
||||||
|
# 525); dann Wayback-Snapshot als MAKEMKV_URL_BASE eintragen.
|
||||||
|
# /download = aktuelle Version, /download/old = ältere Versionen.
|
||||||
|
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
|
||||||
|
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
|
||||||
|
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
|
||||||
|
# dann docker compose build worker && docker compose up -d worker.
|
||||||
|
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||||
|
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- RIP_OUTPUT_DIR=/app/media
|
- RIP_OUTPUT_DIR=/app/media
|
||||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||||
|
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
||||||
|
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
||||||
|
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
||||||
|
# Für Serien-Episoden-Matching: der Worker fragt die API nach den
|
||||||
|
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||||
|
- API_URL=http://api:8000
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- type: bind
|
||||||
|
source: /srv/rippy/media
|
||||||
|
target: /app/media
|
||||||
|
bind:
|
||||||
|
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
|
||||||
|
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||||
|
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||||
|
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||||
|
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||||
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
|
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -87,8 +133,14 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=rippy
|
- POSTGRES_USER=rippy
|
||||||
- POSTGRES_PASSWORD=rippy
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||||
- POSTGRES_DB=rippy
|
- POSTGRES_DB=rippy
|
||||||
|
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||||
|
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||||
|
# ports: konnte sich NIE ein Remote-Worker anbinden. Heimnetz-Kompromiss,
|
||||||
|
# wie die Klartext-Credentials (README).
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@@ -102,6 +154,9 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
# Siehe postgres: Pflicht für Remote-Worker (Celery-Broker).
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -119,7 +174,6 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media:
|
|
||||||
temp:
|
temp:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|||||||
+18
-1
@@ -3,11 +3,28 @@ FROM python:3.12-slim-bookworm
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) —
|
# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) —
|
||||||
# die Geräte-Erkennung läuft jetzt über /sys + ioctls, ganz ohne Systempakete.
|
# die Geräte-Erkennung läuft jetzt über /sys + ioctls.
|
||||||
|
# nfs-common/cifs-utils: Netzwerk-Speicherziele werden aus dem UI heraus
|
||||||
|
# eingehängt (mounts.py, braucht CAP_SYS_ADMIN aus dem Compose).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
nfs-common \
|
||||||
|
cifs-utils \
|
||||||
|
smbclient \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY docker/api/requirements.txt .
|
COPY docker/api/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY docker/api/ .
|
COPY docker/api/ .
|
||||||
|
|
||||||
|
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
|
||||||
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
|
COPY deploy/worker-windows/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"]
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"""JWT-Auth-Module für Rippy API."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
# Passwort-Hashing-Kontext
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
# Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
|
|
||||||
# Review-Fund 22.07.: der frühere Zufalls-Fallback erzeugte PRO PROZESS einen
|
|
||||||
# neuen Schlüssel → jeder Neustart/zweite Worker invalidierte alle Tokens.
|
|
||||||
# Lieber laut scheitern als still kaputt sein.
|
|
||||||
if not settings.jwt_secret_key:
|
|
||||||
raise RuntimeError(
|
|
||||||
"JWT_SECRET_KEY ist nicht gesetzt (.env). Ohne festen Schlüssel wären "
|
|
||||||
"alle Tokens nach jedem Neustart ungültig — Start verweigert."
|
|
||||||
)
|
|
||||||
SECRET_KEY = settings.jwt_secret_key
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
# Token-Lifetimes
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verifiziere Passwort."""
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
"""Hash Passwort."""
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
|
|
||||||
"""Erstelle Access Token (Default 15 min)."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
expire = datetime.now(timezone.utc) + delta
|
|
||||||
to_encode.update({"exp": expire, "type": "access"})
|
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: Dict) -> str:
|
|
||||||
"""Erstelle Refresh Token (7 Tage)."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
||||||
to_encode.update({"exp": expire, "type": "refresh"})
|
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Optional[Dict]:
|
|
||||||
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
|
|
||||||
try:
|
|
||||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_access_token(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token ein gültiges Access Token ist."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
return bool(payload and payload.get("type") == "access")
|
|
||||||
|
|
||||||
|
|
||||||
def is_refresh_token(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token ein gültiges Refresh Token ist."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
return bool(payload and payload.get("type") == "refresh")
|
|
||||||
|
|
||||||
|
|
||||||
# Token-Blacklist für Logout: Token → Ablauf-Zeitstempel (exp).
|
|
||||||
# Bewusste MVP-Grenze: in-memory = pro Prozess (siehe SAVEPOINT.md).
|
|
||||||
# Review-Fund 22.07.: das frühere cleanup löschte die GESAMTE Blacklist —
|
|
||||||
# Logout war ein Placebo. Jetzt fliegen nur abgelaufene Tokens raus
|
|
||||||
# (die sind eh ungültig, decode_token lehnt sie ab).
|
|
||||||
token_blacklist: Dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_blacklist(token: str) -> None:
|
|
||||||
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
if payload:
|
|
||||||
token_blacklist[token] = float(payload.get("exp", time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
def is_blacklisted(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token auf der Blacklist steht."""
|
|
||||||
return token in token_blacklist
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_blacklist() -> None:
|
|
||||||
"""Entferne NUR abgelaufene Tokens von der Blacklist."""
|
|
||||||
now = time.time()
|
|
||||||
for token in [t for t, exp in token_blacklist.items() if exp <= now]:
|
|
||||||
del token_blacklist[token]
|
|
||||||
Vendored
+8
-3
@@ -30,10 +30,15 @@ FALLBACK_YEAR: Final[int] = 2000
|
|||||||
FALLBACK_TITLE: Final[str] = "Unbekannter Titel"
|
FALLBACK_TITLE: Final[str] = "Unbekannter Titel"
|
||||||
|
|
||||||
|
|
||||||
def generate_prescan_key(device_path: str, is_audio: bool = False) -> str:
|
def generate_prescan_key(device_path: str, is_audio: bool = False, fingerprint: str = "") -> str:
|
||||||
"""Generiere Cache-Key für Pre-Scan."""
|
"""Generiere Cache-Key für Pre-Scan.
|
||||||
|
|
||||||
|
fingerprint (Label+Größe der DISC) ist Pflichtbestandteil seit 23.07.:
|
||||||
|
Ein Key nur aus dem Gerätepfad servierte der NÄCHSTEN Disc im selben
|
||||||
|
Laufwerk die Metadaten der vorherigen.
|
||||||
|
"""
|
||||||
prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX
|
prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX
|
||||||
return f"{prefix}{device_path}"
|
return f"{prefix}{device_path}:{fingerprint}"
|
||||||
|
|
||||||
|
|
||||||
def generate_confirmed_key(title: str, year: Optional[int] = None) -> str:
|
def generate_confirmed_key(title: str, year: Optional[int] = None) -> str:
|
||||||
|
|||||||
@@ -7,14 +7,35 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||||
|
|
||||||
|
|
||||||
def start_rip(device_path: str, job_id: str):
|
def transcode_queue(node: str = None):
|
||||||
|
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
|
||||||
|
wenn er gerade online ist, sonst die geteilte transcode-Queue.
|
||||||
|
|
||||||
|
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
|
||||||
|
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
|
||||||
|
landet bei irgendeinem freien Worker.
|
||||||
|
"""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||||
return celery_client.send_task(
|
return celery_client.send_task(
|
||||||
"worker.tasks.rip_disc", args=[device_path, job_id]
|
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key.
|
||||||
|
|
||||||
|
Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die
|
||||||
|
präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und
|
||||||
|
Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant).
|
||||||
|
Doku: https://docs.api.jikan.moe/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from cache import get, set as cache_set
|
||||||
|
|
||||||
|
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
|
||||||
|
|
||||||
|
|
||||||
|
def titel_aehnlichkeit(a: str, b: str) -> float:
|
||||||
|
"""Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar)."""
|
||||||
|
def norm(t: str) -> str:
|
||||||
|
return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip()
|
||||||
|
|
||||||
|
a_n, b_n = norm(a), norm(b)
|
||||||
|
if not a_n or not b_n:
|
||||||
|
return 0.0
|
||||||
|
return SequenceMatcher(None, a_n, b_n).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
class JikanClient:
|
||||||
|
MINDEST_AEHNLICHKEIT = 0.55
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
def suche(self, title: str) -> list:
|
||||||
|
"""Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate)."""
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{JIKAN_BASE_URL}/anime",
|
||||||
|
params={"q": title, "limit": 5, "sfw": "true"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
eintraege = response.json().get("data") or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
ergebnisse = []
|
||||||
|
for e in eintraege:
|
||||||
|
bilder = (e.get("images") or {}).get("jpg") or {}
|
||||||
|
ergebnisse.append({
|
||||||
|
"title": e.get("title_english") or e.get("title") or "",
|
||||||
|
"year": e.get("year"),
|
||||||
|
"poster": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
||||||
|
"overview": (e.get("synopsis") or "")[:200],
|
||||||
|
"type": "movie" if e.get("type") == "Movie" else "tv",
|
||||||
|
"source": "jikan",
|
||||||
|
"id": f"mal-{e.get('mal_id', '')}",
|
||||||
|
})
|
||||||
|
return ergebnisse
|
||||||
|
|
||||||
|
def lookup(self, title: str) -> Optional[Dict]:
|
||||||
|
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
||||||
|
cache_key = f"jikan:{title.lower()}"
|
||||||
|
cached = get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Jikan wirft sporadisch 504 (Gratis-Dienst) — ein Retry rettet die
|
||||||
|
# meisten Fälle, mehr wäre Belästigung.
|
||||||
|
eintraege = None
|
||||||
|
for versuch in range(2):
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{JIKAN_BASE_URL}/anime",
|
||||||
|
params={"q": title, "limit": 5, "sfw": "true"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
eintraege = response.json().get("data") or []
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Jikan API Error (Versuch {versuch + 1}): {e}")
|
||||||
|
if versuch == 0:
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
if eintraege is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bester, bester_score = None, 0.0
|
||||||
|
for eintrag in eintraege:
|
||||||
|
varianten = [eintrag.get("title") or ""]
|
||||||
|
if eintrag.get("title_english"):
|
||||||
|
varianten.append(eintrag["title_english"])
|
||||||
|
score = max(titel_aehnlichkeit(title, v) for v in varianten)
|
||||||
|
if score > bester_score:
|
||||||
|
bester, bester_score = eintrag, score
|
||||||
|
|
||||||
|
if not bester or bester_score < self.MINDEST_AEHNLICHKEIT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bilder = (bester.get("images") or {}).get("jpg") or {}
|
||||||
|
ergebnis = {
|
||||||
|
"type": "movie" if bester.get("type") == "Movie" else "tv",
|
||||||
|
"id": f"mal-{bester.get('mal_id', '')}",
|
||||||
|
"title": bester.get("title_english") or bester.get("title") or title,
|
||||||
|
"year": bester.get("year"),
|
||||||
|
"overview": (bester.get("synopsis") or "")[:600],
|
||||||
|
"poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
||||||
|
"backdrop_path": "",
|
||||||
|
"runtime": 0,
|
||||||
|
"genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")],
|
||||||
|
"source": "jikan",
|
||||||
|
"match_score": round(bester_score, 2),
|
||||||
|
}
|
||||||
|
cache_set(cache_key, ergebnis)
|
||||||
|
return ergebnis
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""OMDb API Client — Fallback-Quelle für die Metadaten-Erkennung.
|
||||||
|
|
||||||
|
Kommt zum Zug, wenn TMDB nichts (Sicheres) findet. Kostenloser Key von
|
||||||
|
https://www.omdbapi.com/apikey.aspx (1000 Anfragen/Tag reichen fürs Heimlab).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from cache import get, set as cache_set
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
OMDB_BASE_URL = "https://www.omdbapi.com/"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_runtime_minutes(runtime: str) -> int:
|
||||||
|
"""OMDb liefert '142 min' als Text → Minuten als Zahl (pure Funktion, testbar)."""
|
||||||
|
if not runtime:
|
||||||
|
return 0
|
||||||
|
teil = runtime.strip().split(" ")[0]
|
||||||
|
return int(teil) if teil.isdigit() else 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_year(year: str) -> Optional[int]:
|
||||||
|
"""OMDb-Jahr kann '1999' oder '2005–2012' (Serie) sein → erstes Jahr."""
|
||||||
|
if not year:
|
||||||
|
return None
|
||||||
|
erste = year.strip()[:4]
|
||||||
|
return int(erste) if erste.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
class OMDbClient:
|
||||||
|
def __init__(self):
|
||||||
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||||
|
from db import get_settings
|
||||||
|
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
def suche(self, title: str) -> list:
|
||||||
|
"""Roh-Kandidaten für die manuelle Korrektur (unscharfe s=-Suche)."""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
antwort = self.session.get(
|
||||||
|
OMDB_BASE_URL,
|
||||||
|
params={"apikey": self.api_key, "s": title, "r": "json"},
|
||||||
|
timeout=10,
|
||||||
|
).json()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if antwort.get("Response") != "True":
|
||||||
|
return []
|
||||||
|
ergebnisse = []
|
||||||
|
for e in (antwort.get("Search") or [])[:5]:
|
||||||
|
poster = e.get("Poster", "")
|
||||||
|
ergebnisse.append({
|
||||||
|
"title": e.get("Title", ""),
|
||||||
|
"year": parse_year(e.get("Year", "")),
|
||||||
|
"poster": poster if poster != "N/A" else "",
|
||||||
|
"overview": "",
|
||||||
|
"type": "tv" if e.get("Type") == "series" else "movie",
|
||||||
|
"source": "omdb",
|
||||||
|
"id": e.get("imdbID", ""),
|
||||||
|
})
|
||||||
|
return ergebnisse
|
||||||
|
|
||||||
|
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
|
||||||
|
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
|
||||||
|
if not self.api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = f"omdb:{title.lower()}:{year or ''}"
|
||||||
|
cached = get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
params = {"apikey": self.api_key, "t": title, "r": "json"}
|
||||||
|
if year:
|
||||||
|
params["y"] = str(year)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(OMDB_BASE_URL, params=params, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# t= ist exakt — wenn leer, unscharfe Suche (s=) und den besten
|
||||||
|
# Treffer per imdbID nachladen
|
||||||
|
if data.get("Response") != "True":
|
||||||
|
suche = self.session.get(
|
||||||
|
OMDB_BASE_URL,
|
||||||
|
params={"apikey": self.api_key, "s": title, "r": "json"},
|
||||||
|
timeout=10,
|
||||||
|
).json()
|
||||||
|
treffer = (suche.get("Search") or []) if suche.get("Response") == "True" else []
|
||||||
|
if treffer:
|
||||||
|
# Bester Kandidat statt blind Treffer #1: echte Filme vor
|
||||||
|
# Episoden/Reviews, mit Poster vor ohne, kurzer Titel vor
|
||||||
|
# langem (Zusatz-Geschwafel wie "Episode 110 - …" verliert).
|
||||||
|
def rang(t: Dict):
|
||||||
|
return (
|
||||||
|
0 if t.get("Type") == "movie" else (1 if t.get("Type") == "series" else 2),
|
||||||
|
0 if t.get("Poster") not in (None, "", "N/A") else 1,
|
||||||
|
len(t.get("Title") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
bester = sorted(treffer, key=rang)[0]
|
||||||
|
data = self.session.get(
|
||||||
|
OMDB_BASE_URL,
|
||||||
|
params={"apikey": self.api_key, "i": bester["imdbID"], "r": "json"},
|
||||||
|
timeout=10,
|
||||||
|
).json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OMDb API Error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.get("Response") != "True":
|
||||||
|
return None
|
||||||
|
|
||||||
|
plot = data.get("Plot", "")
|
||||||
|
if plot == "N/A":
|
||||||
|
plot = ""
|
||||||
|
poster = data.get("Poster", "")
|
||||||
|
if poster == "N/A":
|
||||||
|
poster = ""
|
||||||
|
|
||||||
|
# Qualitäts-Gate: Einträge ohne Poster UND ohne Beschreibung sind
|
||||||
|
# fast immer Datenmüll (Review-Episoden etc.) — als Nicht-Treffer
|
||||||
|
# werten, damit die Kandidaten-Kette weiterprobiert.
|
||||||
|
if not poster and not plot:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ergebnis = {
|
||||||
|
"type": "tv" if data.get("Type") == "series" else "movie",
|
||||||
|
"id": data.get("imdbID", ""),
|
||||||
|
"title": data.get("Title", title),
|
||||||
|
"year": parse_year(data.get("Year", "")),
|
||||||
|
"overview": plot,
|
||||||
|
"poster_path": poster,
|
||||||
|
"backdrop_path": "",
|
||||||
|
"runtime": parse_runtime_minutes(data.get("Runtime", "")),
|
||||||
|
"genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip() and g.strip() != "N/A"],
|
||||||
|
"source": "omdb",
|
||||||
|
}
|
||||||
|
cache_set(cache_key, ergebnis)
|
||||||
|
return ergebnis
|
||||||
@@ -12,7 +12,9 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
|
|||||||
|
|
||||||
class TheTVDBClient:
|
class TheTVDBClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_key = settings.thetvdb_api_key
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||||
|
from db import get_settings
|
||||||
|
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
|
||||||
self.base_url = THETVDB_BASE_URL
|
self.base_url = THETVDB_BASE_URL
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
|
|||||||
@@ -11,18 +11,35 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
|||||||
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||||
|
|
||||||
|
|
||||||
|
def ist_v4_token(key: str) -> bool:
|
||||||
|
"""Pure Funktion (testbar): TMDB hat ZWEI Key-Arten — der v4 Read Access
|
||||||
|
Token ist ein langes JWT ('eyJ…', Bearer-Header), der klassische v3-Key
|
||||||
|
32 Hex-Zeichen (api_key-Query-Parameter). Befund 24.07.: der Client konnte
|
||||||
|
NUR v4 — mit dem üblichen v3-Key aus dem TMDB-Konto war jede Anfrage 401
|
||||||
|
und die Suche lieferte still nur OMDb-Treffer."""
|
||||||
|
return bool(key) and key.startswith("eyJ")
|
||||||
|
|
||||||
|
|
||||||
class TMDBClient:
|
class TMDBClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_key = settings.tmdb_api_key
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
||||||
|
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
||||||
|
from db import get_settings
|
||||||
|
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({"Content-Type": "application/json"})
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
||||||
"Content-Type": "application/json"
|
# api_key-Parameter, v4-Token als Bearer-Header)
|
||||||
})
|
self._key_params = {}
|
||||||
|
if ist_v4_token(self.api_key):
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
||||||
|
elif self.api_key:
|
||||||
|
self._key_params = {"api_key": self.api_key}
|
||||||
|
|
||||||
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||||
"""Mache API-Request mit Caching."""
|
"""Mache API-Request mit Caching."""
|
||||||
from cache.keys import generate_tmdb_key
|
from cache.keys import generate_tmdb_key
|
||||||
|
# Cache-Key OHNE den api_key — der gehört nicht in den Cache
|
||||||
cache_key = generate_tmdb_key(endpoint, params)
|
cache_key = generate_tmdb_key(endpoint, params)
|
||||||
|
|
||||||
cached = get(cache_key)
|
cached = get(cache_key)
|
||||||
@@ -32,7 +49,7 @@ class TMDBClient:
|
|||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
f"{TMDB_BASE_URL}/{endpoint}",
|
f"{TMDB_BASE_URL}/{endpoint}",
|
||||||
params=params,
|
params={**(params or {}), **self._key_params},
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -79,6 +96,42 @@ class TMDBClient:
|
|||||||
params = {"language": "de-DE"}
|
params = {"language": "de-DE"}
|
||||||
return self._request(f"tv/{tv_id}", params)
|
return self._request(f"tv/{tv_id}", params)
|
||||||
|
|
||||||
|
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
|
||||||
|
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
|
||||||
|
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
|
||||||
|
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
|
||||||
|
|
||||||
|
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
|
||||||
|
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
|
||||||
|
developer.themoviedb.org/reference/find-by-id).
|
||||||
|
|
||||||
|
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
|
||||||
|
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
|
||||||
|
"""
|
||||||
|
result = self._request(
|
||||||
|
f"find/{imdb_id}",
|
||||||
|
{"external_source": "imdb_id", "language": "de-DE"},
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
for schluessel, typ, titel_feld, datum_feld in (
|
||||||
|
("movie_results", "movie", "title", "release_date"),
|
||||||
|
("tv_results", "tv", "name", "first_air_date"),
|
||||||
|
):
|
||||||
|
eintraege = result.get(schluessel) or []
|
||||||
|
if eintraege:
|
||||||
|
e = eintraege[0]
|
||||||
|
datum = e.get(datum_feld) or ""
|
||||||
|
return {
|
||||||
|
"type": typ,
|
||||||
|
"tmdb_id": e.get("id"),
|
||||||
|
"title": e.get(titel_feld) or "",
|
||||||
|
"overview": e.get("overview") or "",
|
||||||
|
"poster_path": e.get("poster_path") or "",
|
||||||
|
"year": int(datum[:4]) if datum[:4].isdigit() else None,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
||||||
"""Hole Poster/Fanart URLs."""
|
"""Hole Poster/Fanart URLs."""
|
||||||
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
|
|||||||
# API Keys
|
# API Keys
|
||||||
tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)")
|
tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)")
|
||||||
thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)")
|
thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)")
|
||||||
|
omdb_api_key: Optional[str] = Field(default=None, description="OMDb API Key (Fallback-Quelle, optional)")
|
||||||
musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
|
musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
@@ -23,12 +24,8 @@ class Settings(BaseSettings):
|
|||||||
# Logging
|
# Logging
|
||||||
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
||||||
|
|
||||||
# JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr)
|
# JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
|
||||||
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)")
|
# Heimnetz-only, es gab nie einen Login-Flow im UI — siehe KONZEPT §10.
|
||||||
|
|
||||||
# Admin-Login (MVP — vorher hartkodiert admin/rippy123 in main.py)
|
|
||||||
admin_username: str = Field(default="admin", description="Admin-Benutzername")
|
|
||||||
admin_password: str = Field(default="rippy123", description="Admin-Passwort (per .env ÄNDERN!)")
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
|||||||
try:
|
try:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
|
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
|
||||||
|
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
|
||||||
|
# sich jederzeit im UI (Einstellungen → System) nachtragen.
|
||||||
if not settings.tmdb_api_key:
|
if not settings.tmdb_api_key:
|
||||||
raise ConfigValidationError(
|
raise ConfigValidationError(
|
||||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
@@ -48,10 +50,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
|
|||||||
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional: JWT Secret (wird generiert, wenn nicht gesetzt)
|
|
||||||
if not settings.jwt_secret_key:
|
|
||||||
warnings.append(
|
|
||||||
"JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet."
|
|
||||||
)
|
|
||||||
|
|
||||||
return settings, warnings
|
return settings, warnings
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
|
"""Pytest-Setup für API-Tests: flache Modul-Imports."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
|
|
||||||
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
|
|
||||||
|
|||||||
+128
-2
@@ -41,7 +41,9 @@ jobs = Table(
|
|||||||
Column("status", String(16), nullable=False, server_default="pending"),
|
Column("status", String(16), nullable=False, server_default="pending"),
|
||||||
Column("progress", Integer, nullable=False, server_default="0"),
|
Column("progress", Integer, nullable=False, server_default="0"),
|
||||||
Column("output_path", Text),
|
Column("output_path", Text),
|
||||||
|
Column("target_dir", String(255)),
|
||||||
Column("error", Text),
|
Column("error", Text),
|
||||||
|
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
|
||||||
Column("created_at", DateTime(timezone=True)),
|
Column("created_at", DateTime(timezone=True)),
|
||||||
Column("finished_at", DateTime(timezone=True)),
|
Column("finished_at", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
@@ -63,17 +65,93 @@ settings_table = Table(
|
|||||||
Column("value", Text),
|
Column("value", Text),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workers = Table(
|
||||||
|
"workers",
|
||||||
|
metadata,
|
||||||
|
Column("name", String(128), primary_key=True),
|
||||||
|
Column("encoders", Text),
|
||||||
|
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||||
|
Column("last_seen", DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage_mounts = Table(
|
||||||
|
"storage_mounts",
|
||||||
|
metadata,
|
||||||
|
Column("name", String(64), primary_key=True),
|
||||||
|
Column("typ", String(8)), # nfs | cifs
|
||||||
|
Column("quelle", String(255)), # host:/export bzw. //host/share
|
||||||
|
Column("optionen", String(255)),
|
||||||
|
Column("username", String(128)),
|
||||||
|
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_workers() -> list:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeilen = conn.execute(select(workers)).mappings().all()
|
||||||
|
ergebnis = []
|
||||||
|
for z in zeilen:
|
||||||
|
eintrag = dict(z)
|
||||||
|
try:
|
||||||
|
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
|
||||||
|
except ValueError:
|
||||||
|
eintrag["encoders"] = []
|
||||||
|
try:
|
||||||
|
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
eintrag["info"] = {}
|
||||||
|
if eintrag.get("last_seen"):
|
||||||
|
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
|
||||||
|
ergebnis.append(eintrag)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def list_mounts() -> list:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
storage_mounts.insert().values(
|
||||||
|
name=name, typ=typ, quelle=quelle,
|
||||||
|
optionen=optionen, username=username, passwort=passwort,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_mount(name: str) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""Legt fehlende Tabellen an (idempotent)."""
|
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
|
||||||
metadata.create_all(engine)
|
metadata.create_all(engine)
|
||||||
|
# create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None:
|
def insert_job(
|
||||||
|
job_id: str, device: str, disc_type: str = None, title: str = None,
|
||||||
|
target_dir: str = None, meta: str = None,
|
||||||
|
) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
jobs.insert().values(
|
jobs.insert().values(
|
||||||
@@ -81,6 +159,8 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
|
|||||||
device=device,
|
device=device,
|
||||||
disc_type=disc_type,
|
disc_type=disc_type,
|
||||||
title=title,
|
title=title,
|
||||||
|
target_dir=target_dir,
|
||||||
|
meta=meta,
|
||||||
status="pending",
|
status="pending",
|
||||||
progress=0,
|
progress=0,
|
||||||
created_at=utcnow(),
|
created_at=utcnow(),
|
||||||
@@ -88,6 +168,19 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(job_id: str, **fields) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs).where(jobs.c.id == job_id)
|
||||||
|
).mappings().first()
|
||||||
|
return dict(zeile) if zeile else None
|
||||||
|
|
||||||
|
|
||||||
def list_jobs(limit: int = 100) -> list:
|
def list_jobs(limit: int = 100) -> list:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
zeilen = conn.execute(
|
zeilen = conn.execute(
|
||||||
@@ -96,6 +189,39 @@ def list_jobs(limit: int = 100) -> list:
|
|||||||
return [dict(z) for z in zeilen]
|
return [dict(z) for z in zeilen]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_job(job_id: str) -> None:
|
||||||
|
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(jobs.delete().where(jobs.c.id == job_id))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_finished_jobs() -> int:
|
||||||
|
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ergebnis = conn.execute(
|
||||||
|
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
|
||||||
|
)
|
||||||
|
return ergebnis.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_worker(name: str) -> None:
|
||||||
|
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(workers.delete().where(workers.c.name == name))
|
||||||
|
|
||||||
|
|
||||||
|
def has_active_job(device: str) -> bool:
|
||||||
|
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs.c.id)
|
||||||
|
.where(jobs.c.device == device)
|
||||||
|
.where(jobs.c.status.in_(("pending", "running")))
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return zeile is not None
|
||||||
|
|
||||||
|
|
||||||
def add_log(level: str, source: str, message: str) -> None:
|
def add_log(level: str, source: str, message: str) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
|
|||||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||||
|
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
|
||||||
|
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
|
||||||
|
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
|
||||||
|
UHD_MIN_BYTES = 55 * 1024**3
|
||||||
|
|
||||||
|
|
||||||
def _open_nonblock(device_path: str) -> int:
|
def _open_nonblock(device_path: str) -> int:
|
||||||
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
|
||||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||||
return "cd"
|
return "cd"
|
||||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||||
|
if size_bytes >= UHD_MIN_BYTES:
|
||||||
|
return "uhd"
|
||||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall.
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
from fcntl import ioctl
|
||||||
|
|
||||||
from detection import (
|
from detection import (
|
||||||
CDS_DISC_OK,
|
CDS_DISC_OK,
|
||||||
@@ -17,6 +18,18 @@ from detection import (
|
|||||||
drive_status,
|
drive_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# include/uapi/linux/cdrom.h
|
||||||
|
CDROMEJECT = 0x5309
|
||||||
|
|
||||||
|
|
||||||
|
def eject(device_path: str) -> None:
|
||||||
|
"""Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
|
||||||
|
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
try:
|
||||||
|
ioctl(fd, CDROMEJECT, 0)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def list_optical_devices() -> list:
|
def list_optical_devices() -> list:
|
||||||
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
||||||
|
|||||||
+1157
-145
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
|
||||||
|
|
||||||
|
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
|
||||||
|
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
|
||||||
|
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
|
||||||
|
alle anderen Container (Worker sieht das Ziel sofort).
|
||||||
|
|
||||||
|
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
|
||||||
|
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
|
||||||
|
sondern über eine temporäre credentials-Datei).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
MEDIA_ROOT = "/app/media"
|
||||||
|
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _mountpoint(name: str) -> str:
|
||||||
|
return os.path.join(MEDIA_ROOT, name)
|
||||||
|
|
||||||
|
|
||||||
|
def validiere_name(name: str) -> bool:
|
||||||
|
return bool(NAME_MUSTER.match(name))
|
||||||
|
|
||||||
|
|
||||||
|
def ist_gemountet(name: str) -> bool:
|
||||||
|
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
|
||||||
|
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
|
||||||
|
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
|
||||||
|
try:
|
||||||
|
return os.path.ismount(_mountpoint(name))
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ist_erreichbar(name: str) -> bool:
|
||||||
|
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
|
||||||
|
|
||||||
|
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
|
||||||
|
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
|
||||||
|
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
|
||||||
|
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
return ergebnis.returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def schreibtest(pfad: str) -> bool:
|
||||||
|
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
||||||
|
|
||||||
|
Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob
|
||||||
|
die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf.
|
||||||
|
"""
|
||||||
|
testdatei = os.path.join(pfad, ".rippy-schreibtest")
|
||||||
|
try:
|
||||||
|
with open(testdatei, "w") as f:
|
||||||
|
f.write("ok")
|
||||||
|
os.unlink(testdatei)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_mounts_loesen(ziel: str) -> int:
|
||||||
|
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||||
|
|
||||||
|
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||||
|
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||||
|
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||||
|
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||||
|
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS.
|
||||||
|
Rueckgabe: Zahl der geloesten Schichten.
|
||||||
|
"""
|
||||||
|
geloest = 0
|
||||||
|
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["umount", "-l", ziel], capture_output=True, timeout=10
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
break
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
break # nichts (mehr) gemountet
|
||||||
|
geloest += 1
|
||||||
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
|
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
||||||
|
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||||
|
|
||||||
|
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
|
||||||
|
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
|
||||||
|
rät niemand, dass oben Benutzer/Passwort fehlen.
|
||||||
|
"""
|
||||||
|
if "NT_STATUS_ACCESS_DENIED" in fehler:
|
||||||
|
if not mit_credentials:
|
||||||
|
return (
|
||||||
|
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
|
||||||
|
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
|
||||||
|
"Zugriff auf die Freigabe), dann erneut auflisten."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
|
||||||
|
"Konto darf die Freigaben nicht auflisten."
|
||||||
|
)
|
||||||
|
if "NT_STATUS_LOGON_FAILURE" in fehler:
|
||||||
|
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
|
||||||
|
if (
|
||||||
|
"NT_STATUS_HOST_UNREACHABLE" in fehler
|
||||||
|
or "NT_STATUS_IO_TIMEOUT" in fehler
|
||||||
|
or "NT_STATUS_UNSUCCESSFUL" in fehler
|
||||||
|
or "NT_STATUS_CONNECTION_REFUSED" in fehler
|
||||||
|
or "Connection to" in fehler
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
|
||||||
|
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
|
||||||
|
)
|
||||||
|
return fehler[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
|
||||||
|
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
|
||||||
|
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
|
||||||
|
cmd = ["smbclient", "-L", f"//{host}", "-g"]
|
||||||
|
if username:
|
||||||
|
cmd += ["-U", f"{username}%{passwort or ''}"]
|
||||||
|
else:
|
||||||
|
cmd += ["-N"]
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
|
||||||
|
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
|
||||||
|
)
|
||||||
|
freigaben = []
|
||||||
|
for zeile in (ergebnis.stdout or "").splitlines():
|
||||||
|
# -g-Format: Disk|Freigabename|Kommentar
|
||||||
|
teile = zeile.split("|")
|
||||||
|
if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"):
|
||||||
|
freigaben.append(teile[1])
|
||||||
|
if not freigaben and ergebnis.returncode != 0:
|
||||||
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
"Freigaben-Abfrage fehlgeschlagen: "
|
||||||
|
+ uebersetze_smb_fehler(fehler, bool(username))
|
||||||
|
)
|
||||||
|
return freigaben
|
||||||
|
|
||||||
|
|
||||||
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
|
username: str = "", passwort: str = "") -> bool:
|
||||||
|
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
||||||
|
|
||||||
|
Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei
|
||||||
|
Mount-Fehlern.
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||||
|
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||||
|
try:
|
||||||
|
os.makedirs(ziel, exist_ok=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if os.path.ismount(ziel):
|
||||||
|
return schreibtest(ziel)
|
||||||
|
except OSError:
|
||||||
|
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||||
|
|
||||||
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||||
|
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||||
|
_stale_mounts_loesen(ziel)
|
||||||
|
|
||||||
|
creds_datei = None
|
||||||
|
try:
|
||||||
|
if typ == "nfs":
|
||||||
|
opts = optionen or "vers=4,soft,timeo=100"
|
||||||
|
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
|
||||||
|
elif typ == "cifs":
|
||||||
|
teile = [optionen] if optionen else []
|
||||||
|
if username:
|
||||||
|
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
|
||||||
|
creds = tempfile.NamedTemporaryFile(
|
||||||
|
"w", delete=False, prefix="cifs-", suffix=".cred"
|
||||||
|
)
|
||||||
|
creds.write(f"username={username}\npassword={passwort or ''}\n")
|
||||||
|
creds.close()
|
||||||
|
os.chmod(creds.name, 0o600)
|
||||||
|
creds_datei = creds.name
|
||||||
|
teile.append(f"credentials={creds.name}")
|
||||||
|
else:
|
||||||
|
teile.append("guest")
|
||||||
|
teile.append("iocharset=utf8")
|
||||||
|
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||||
|
|
||||||
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||||
|
hinweis = ""
|
||||||
|
if "error(13)" in fehler or "Permission denied" in fehler:
|
||||||
|
hinweis = (
|
||||||
|
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
||||||
|
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
||||||
|
"NAS meist ablehnen."
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
||||||
|
return schreibtest(ziel)
|
||||||
|
finally:
|
||||||
|
if creds_datei:
|
||||||
|
try:
|
||||||
|
os.unlink(creds_datei)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _lazy_umount(ziel: str) -> None:
|
||||||
|
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
|
||||||
|
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
|
||||||
|
gar nicht mehr entfernen (Befund 24.07.)."""
|
||||||
|
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
def aushaengen(name: str) -> None:
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
noch_mount = os.path.ismount(ziel)
|
||||||
|
except OSError:
|
||||||
|
noch_mount = True # stale
|
||||||
|
if noch_mount:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
try:
|
||||||
|
os.rmdir(ziel)
|
||||||
|
except OSError:
|
||||||
|
pass # nicht leer oder weg — egal
|
||||||
|
|
||||||
|
|
||||||
|
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
|
username: str = "", passwort: str = "") -> bool:
|
||||||
|
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
||||||
|
|
||||||
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||||
|
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
if os.path.ismount(ziel):
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
except OSError:
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||||
|
|
||||||
|
|
||||||
|
def alle_remounten() -> list:
|
||||||
|
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
||||||
|
meldungen = []
|
||||||
|
for eintrag in db.list_mounts():
|
||||||
|
try:
|
||||||
|
mounten(
|
||||||
|
eintrag["name"], eintrag["typ"], eintrag["quelle"],
|
||||||
|
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
||||||
|
eintrag.get("passwort") or "",
|
||||||
|
)
|
||||||
|
meldungen.append(f"{eintrag['name']}: eingehängt")
|
||||||
|
except Exception as e:
|
||||||
|
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
|
||||||
|
return meldungen
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
|
||||||
|
|
||||||
|
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
|
||||||
|
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
|
||||||
|
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
||||||
|
|
||||||
|
Das Modul existiert bewusst identisch in API und Worker
|
||||||
|
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||||
|
Wer es ändert, ändert BEIDE Dateien.
|
||||||
|
|
||||||
|
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
||||||
|
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
||||||
|
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
|
||||||
|
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
|
||||||
|
docs.ntfy.sh/publish
|
||||||
|
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
|
||||||
|
(Home Assistant, n8n, eigene Skripte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TIMEOUT_SEKUNDEN = 10
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_webhook_typ(url: str) -> str:
|
||||||
|
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
|
||||||
|
u = url.lower()
|
||||||
|
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
|
||||||
|
return "discord"
|
||||||
|
if "hooks.slack.com" in u:
|
||||||
|
return "slack"
|
||||||
|
if "ntfy" in u:
|
||||||
|
return "ntfy"
|
||||||
|
return "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
|
||||||
|
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
|
||||||
|
typ = erkenne_webhook_typ(url)
|
||||||
|
if typ == "discord":
|
||||||
|
return typ, {"content": f"**{titel}**\n{text}"}
|
||||||
|
if typ == "slack":
|
||||||
|
return typ, {"text": f"*{titel}*\n{text}"}
|
||||||
|
if typ == "ntfy":
|
||||||
|
return typ, None
|
||||||
|
return typ, {"title": titel, "message": text, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
|
||||||
|
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
|
||||||
|
typ, payload = baue_payload(url, titel, text, level)
|
||||||
|
try:
|
||||||
|
if typ == "ntfy":
|
||||||
|
antwort = requests.post(
|
||||||
|
url, data=text.encode("utf-8"),
|
||||||
|
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 300:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Webhook antwortete mit HTTP {antwort.status_code}: "
|
||||||
|
f"{(antwort.text or '')[:200]}"
|
||||||
|
)
|
||||||
+316
-45
@@ -5,16 +5,196 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru
|
|||||||
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import detection
|
||||||
from clients.tmdb import TMDBClient
|
from clients.tmdb import TMDBClient
|
||||||
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
|
from clients.omdb import OMDbClient
|
||||||
from clients.thetvdb import TheTVDBClient
|
from clients.thetvdb import TheTVDBClient
|
||||||
from cache import get, set as cache_set
|
from cache import get, set as cache_set
|
||||||
from cache.keys import generate_prescan_key
|
from cache.keys import generate_prescan_key
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_disc_label(label: str) -> str:
|
||||||
|
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||||
|
|
||||||
|
Volume-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN — so findet keine
|
||||||
|
Metadaten-API etwas. Unterstriche zu Leerzeichen, Titel-Schreibung.
|
||||||
|
"""
|
||||||
|
if not label:
|
||||||
|
return ""
|
||||||
|
bereinigt = " ".join(label.replace("_", " ").replace(".", " ").split())
|
||||||
|
if bereinigt.isupper():
|
||||||
|
bereinigt = bereinigt.title()
|
||||||
|
return bereinigt
|
||||||
|
|
||||||
|
|
||||||
|
def read_iso_volume_label(device_path: str) -> Optional[str]:
|
||||||
|
"""Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor.
|
||||||
|
|
||||||
|
Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für
|
||||||
|
DVDs — Blu-rays sind oft reines UDF ohne ISO-Bridge (dann greift
|
||||||
|
read_udf_volume_label).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(device_path, "rb") as f:
|
||||||
|
f.seek(32768)
|
||||||
|
pvd = f.read(2048)
|
||||||
|
if len(pvd) < 72 or pvd[1:6] != b"CD001":
|
||||||
|
return None
|
||||||
|
label = pvd[40:72].decode("ascii", errors="replace").strip()
|
||||||
|
return label or None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_udf_dstring(data: bytes) -> str:
|
||||||
|
"""Dekodiert einen UDF d-string (ECMA-167 1/7.2.12; pure Funktion, testbar).
|
||||||
|
|
||||||
|
Letztes Byte = genutzte Länge, erstes Byte = Kompressions-ID
|
||||||
|
(8 = Latin-1, 16 = UTF-16BE), dazwischen der Text.
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return ""
|
||||||
|
laenge = data[-1]
|
||||||
|
if laenge < 2 or laenge > len(data) - 1:
|
||||||
|
return ""
|
||||||
|
comp_id, text = data[0], data[1:laenge]
|
||||||
|
try:
|
||||||
|
if comp_id == 8:
|
||||||
|
return text.decode("latin-1").strip()
|
||||||
|
if comp_id == 16:
|
||||||
|
return text.decode("utf-16-be").strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
||||||
|
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
|
||||||
|
|
||||||
|
Das Volume-Label ist oft kryptisch (BD_EVG_D2) — der echte Titel
|
||||||
|
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
|
||||||
|
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
|
||||||
|
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
mountpoint = "/mnt/rippy-disc"
|
||||||
|
os.makedirs(mountpoint, exist_ok=True)
|
||||||
|
gemountet = False
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
return None
|
||||||
|
gemountet = True
|
||||||
|
|
||||||
|
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
|
||||||
|
if not os.path.isdir(meta_dir):
|
||||||
|
return None
|
||||||
|
kandidaten = sorted(os.listdir(meta_dir))
|
||||||
|
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
|
||||||
|
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
|
||||||
|
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
||||||
|
)
|
||||||
|
if not bdmt:
|
||||||
|
return None
|
||||||
|
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||||
|
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||||
|
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
|
||||||
|
# (Namespaces variieren je Authoring-Werkzeug)
|
||||||
|
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
||||||
|
if treffer:
|
||||||
|
return treffer.group(1).strip()
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if gemountet:
|
||||||
|
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
||||||
|
|
||||||
|
|
||||||
|
def titel_kandidaten(titel: str) -> List[str]:
|
||||||
|
"""Suchvarianten für die Metadaten-APIs (progressive Degradation, ARM-Lehre).
|
||||||
|
|
||||||
|
'Evangelion: 2.22 You Can (Not) Advance.' → auch 'Evangelion: 2.22 …' ohne
|
||||||
|
Zusatz, ohne Doppelpunkt-Teil usw. — Disc-Titel sind selten API-freundlich.
|
||||||
|
"""
|
||||||
|
kandidaten = []
|
||||||
|
|
||||||
|
def merke(t: str):
|
||||||
|
t = " ".join(t.split()).strip(" .")
|
||||||
|
if t and t not in kandidaten:
|
||||||
|
kandidaten.append(t)
|
||||||
|
|
||||||
|
merke(titel)
|
||||||
|
if ":" in titel:
|
||||||
|
merke(titel.split(":", 1)[0]) # Haupttitel vor dem Doppelpunkt
|
||||||
|
merke(titel.replace(":", ""))
|
||||||
|
import re as _re
|
||||||
|
merke(_re.sub(r"\([^)]*\)", "", titel)) # Klammer-Zusätze raus
|
||||||
|
# Iterativ hintere Worte strippen (ARM-Lehre): "Evangelion 2.22" muss
|
||||||
|
# auch als "Evangelion" gesucht werden — Versions-/Zusatz-Tokens stehen
|
||||||
|
# fast immer hinten. Begrenzt, damit die API-Anfragen überschaubar bleiben.
|
||||||
|
worte = titel.split()
|
||||||
|
while len(worte) > 1 and len(kandidaten) < 6:
|
||||||
|
worte = worte[:-1]
|
||||||
|
merke(" ".join(worte))
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
def disc_fingerprint(device_path: str) -> str:
|
||||||
|
"""Billiger, stabiler Disc-Fingerabdruck (Volume-Label + Größe).
|
||||||
|
|
||||||
|
Für Cache-Keys und das Merken manueller Korrekturen — bewusst OHNE den
|
||||||
|
teuren Mount-Titel, damit ihn jeder Endpunkt schnell berechnen kann.
|
||||||
|
"""
|
||||||
|
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) or "unbekannt"
|
||||||
|
try:
|
||||||
|
groesse = detection.disc_size_bytes(device_path)
|
||||||
|
except OSError:
|
||||||
|
groesse = 0
|
||||||
|
return f"{label}|{groesse}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_udf_volume_label(device_path: str) -> Optional[str]:
|
||||||
|
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
|
||||||
|
|
||||||
|
Weg laut ECMA-167: Anchor Volume Descriptor Pointer bei Sektor 256 →
|
||||||
|
zeigt auf die Main Volume Descriptor Sequence → darin der PVD (Tag-ID 1)
|
||||||
|
mit dem Volume Identifier (d-string, 32 Bytes ab Offset 24).
|
||||||
|
"""
|
||||||
|
sektor = 2048
|
||||||
|
try:
|
||||||
|
with open(device_path, "rb") as f:
|
||||||
|
f.seek(256 * sektor)
|
||||||
|
avdp = f.read(sektor)
|
||||||
|
if len(avdp) < 24 or int.from_bytes(avdp[0:2], "little") != 2:
|
||||||
|
return None
|
||||||
|
vds_ort = int.from_bytes(avdp[20:24], "little")
|
||||||
|
f.seek(vds_ort * sektor)
|
||||||
|
# VDS-Deskriptoren durchgehen, bis der PVD (Tag-ID 1) kommt
|
||||||
|
for _ in range(32):
|
||||||
|
block = f.read(sektor)
|
||||||
|
if len(block) < 56:
|
||||||
|
return None
|
||||||
|
tag_id = int.from_bytes(block[0:2], "little")
|
||||||
|
if tag_id == 1:
|
||||||
|
return parse_udf_dstring(block[24:56]) or None
|
||||||
|
if tag_id == 8: # Terminating Descriptor — Sequenz zu Ende
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class PreScanResult:
|
class PreScanResult:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -23,7 +203,8 @@ class PreScanResult:
|
|||||||
year: int = None,
|
year: int = None,
|
||||||
confidence: float = 0.0,
|
confidence: float = 0.0,
|
||||||
metadata: Dict = None,
|
metadata: Dict = None,
|
||||||
tracks: List[Dict] = None
|
tracks: List[Dict] = None,
|
||||||
|
fingerprint: str = ""
|
||||||
):
|
):
|
||||||
self.disc_type = disc_type
|
self.disc_type = disc_type
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -31,6 +212,9 @@ class PreScanResult:
|
|||||||
self.confidence = confidence
|
self.confidence = confidence
|
||||||
self.metadata = metadata or {}
|
self.metadata = metadata or {}
|
||||||
self.tracks = tracks or []
|
self.tracks = tracks or []
|
||||||
|
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
|
||||||
|
# Duplikat-Warnung („diese Disc wurde schon gerippt")
|
||||||
|
self.fingerprint = fingerprint
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
return {
|
return {
|
||||||
@@ -39,7 +223,8 @@ class PreScanResult:
|
|||||||
"year": self.year,
|
"year": self.year,
|
||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
"tracks": self.tracks
|
"tracks": self.tracks,
|
||||||
|
"fingerprint": self.fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,36 +233,32 @@ class PreScan:
|
|||||||
self.tmdb = TMDBClient()
|
self.tmdb = TMDBClient()
|
||||||
self.musicbrainz = MusicBrainzClient()
|
self.musicbrainz = MusicBrainzClient()
|
||||||
self.thetvdb = TheTVDBClient()
|
self.thetvdb = TheTVDBClient()
|
||||||
|
self.omdb = OMDbClient()
|
||||||
|
self.jikan = JikanClient()
|
||||||
|
|
||||||
def scan(self, device_path: str) -> PreScanResult:
|
def scan(self, device_path: str) -> PreScanResult:
|
||||||
"""Führe Pre-Scan durch."""
|
"""Führe Pre-Scan durch."""
|
||||||
disc_type = self._detect_disc_type(device_path)
|
disc_type = self._detect_disc_type(device_path)
|
||||||
toc = self._read_toc(device_path, disc_type)
|
toc = self._read_toc(device_path, disc_type)
|
||||||
|
# Typ + Disc-Fingerabdruck (Label|Größe) an die Zweige durchreichen —
|
||||||
|
# der Typ ging vorher verloren (BDs hießen immer "DVD"), und der
|
||||||
|
# Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc
|
||||||
|
# die Metadaten der vorherigen).
|
||||||
|
toc["disc_type"] = disc_type
|
||||||
|
toc["fingerprint"] = disc_fingerprint(device_path)
|
||||||
|
|
||||||
if disc_type == "CD":
|
if disc_type == "CD":
|
||||||
return self._scan_audio(device_path, toc)
|
return self._scan_audio(device_path, toc)
|
||||||
return self._scan_video(device_path, toc)
|
return self._scan_video(device_path, toc)
|
||||||
|
|
||||||
def _detect_disc_type(self, device_path: str) -> str:
|
def _detect_disc_type(self, device_path: str) -> str:
|
||||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe."""
|
"""Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
Vorher lief hier `isosize` — das Werkzeug war im Container nicht
|
||||||
["isosize", "-x", device_path],
|
installiert, die Erkennung fiel still immer auf "DVD" zurück.
|
||||||
capture_output=True,
|
"""
|
||||||
text=True,
|
typ = detection.detect_disc_type(device_path)
|
||||||
timeout=5
|
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray", "uhd": "4K UHD"}.get(typ, "DVD")
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
size = int(result.stdout.strip())
|
|
||||||
if size < 700 * 1024 * 1024:
|
|
||||||
return "CD"
|
|
||||||
elif size < 15 * 1024 * 1024 * 1024:
|
|
||||||
return "DVD"
|
|
||||||
else:
|
|
||||||
return "Blu-ray"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "DVD"
|
|
||||||
|
|
||||||
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
||||||
"""Lese TOC (Table of Contents)."""
|
"""Lese TOC (Table of Contents)."""
|
||||||
@@ -105,19 +286,28 @@ class PreScan:
|
|||||||
"duration": 0
|
"duration": 0
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# makemkvcon liest nur den TOC — KEIN Ripping
|
# Titel-Quelle 1 (beste): Klartext-Titel aus den BD-Metadaten
|
||||||
|
# (BDMV/META — Labels wie BD_EVG_D2 taugen nicht für APIs)
|
||||||
|
klartext = read_disc_title_via_mount(device_path)
|
||||||
|
if klartext:
|
||||||
|
toc["title"] = klartext
|
||||||
|
else:
|
||||||
|
# Titel-Quelle 2: Volume-Label direkt vom Medium.
|
||||||
|
# ISO-9660 für DVDs, UDF für Blu-rays (keine ISO-Bridge).
|
||||||
|
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path)
|
||||||
|
if label:
|
||||||
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
|
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||||
|
if shutil.which("makemkvcon"):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
|
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30
|
timeout=120
|
||||||
)
|
)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if line.startswith('DRV:'):
|
if line.startswith('TINFO:'):
|
||||||
parts = line.split(',')
|
|
||||||
if len(parts) >= 4:
|
|
||||||
toc["title"] = parts[3].strip().strip('"')
|
|
||||||
elif line.startswith('TINFO:'):
|
|
||||||
parts = line.split(',')
|
parts = line.split(',')
|
||||||
if len(parts) >= 5:
|
if len(parts) >= 5:
|
||||||
toc["tracks"].append({
|
toc["tracks"].append({
|
||||||
@@ -130,7 +320,7 @@ class PreScan:
|
|||||||
|
|
||||||
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||||
"""Pre-Scan für Audio-CD."""
|
"""Pre-Scan für Audio-CD."""
|
||||||
cache_key = generate_prescan_key(device_path, is_audio=True)
|
cache_key = generate_prescan_key(device_path, is_audio=True, fingerprint=toc.get("fingerprint", ""))
|
||||||
cached = get(cache_key)
|
cached = get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return PreScanResult(**cached)
|
return PreScanResult(**cached)
|
||||||
@@ -176,27 +366,37 @@ class PreScan:
|
|||||||
disc_type="CD",
|
disc_type="CD",
|
||||||
title=album,
|
title=album,
|
||||||
tracks=toc["tracks"],
|
tracks=toc["tracks"],
|
||||||
confidence=confidence
|
confidence=confidence,
|
||||||
|
fingerprint=toc.get("fingerprint", "")
|
||||||
)
|
)
|
||||||
cache_set(cache_key, result.to_dict())
|
cache_set(cache_key, result.to_dict())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||||
"""Pre-Scan für DVD/Blu-ray."""
|
"""Pre-Scan für DVD/Blu-ray."""
|
||||||
cache_key = generate_prescan_key(device_path, is_audio=False)
|
cache_key = generate_prescan_key(device_path, is_audio=False, fingerprint=toc.get("fingerprint", ""))
|
||||||
cached = get(cache_key)
|
cached = get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return PreScanResult(**cached)
|
return PreScanResult(**cached)
|
||||||
|
|
||||||
title = toc["title"] or "Unknown Title"
|
# Titel NICHT nochmal normalisieren: aus bdmt_*.xml kommt er sauber
|
||||||
|
# („2.22" würde die Label-Normalisierung zu „2 22" zerlegen)
|
||||||
|
title = (toc.get("title") or "").strip() or "Unknown Title"
|
||||||
confidence = 0.0
|
confidence = 0.0
|
||||||
metadata = {}
|
metadata = {}
|
||||||
matched = False
|
matched = False
|
||||||
|
|
||||||
movies = self.tmdb.search_movie(title)
|
# Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten
|
||||||
if movies:
|
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||||
for movie in movies:
|
kandidaten = titel_kandidaten(title) or [title]
|
||||||
if movie.get("title", "").lower() == title.lower():
|
movies = []
|
||||||
|
|
||||||
|
for kandidat in kandidaten:
|
||||||
|
treffer = self.tmdb.search_movie(kandidat)
|
||||||
|
if treffer and not movies:
|
||||||
|
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||||
|
for movie in treffer:
|
||||||
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
movie_details = self.tmdb.get_movie_details(movie["id"])
|
movie_details = self.tmdb.get_movie_details(movie["id"])
|
||||||
if movie_details:
|
if movie_details:
|
||||||
@@ -209,16 +409,19 @@ class PreScan:
|
|||||||
"poster_path": movie_details.get("poster_path", ""),
|
"poster_path": movie_details.get("poster_path", ""),
|
||||||
"backdrop_path": movie_details.get("backdrop_path", ""),
|
"backdrop_path": movie_details.get("backdrop_path", ""),
|
||||||
"runtime": movie_details.get("runtime", 0),
|
"runtime": movie_details.get("runtime", 0),
|
||||||
"genres": [g["name"] for g in movie_details.get("genres", [])]
|
"genres": [g["name"] for g in movie_details.get("genres", [])],
|
||||||
|
"source": "tmdb",
|
||||||
}
|
}
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
if not matched:
|
if not matched:
|
||||||
tv_shows = self.tmdb.search_tv(title)
|
for kandidat in kandidaten:
|
||||||
if tv_shows:
|
tv_shows = self.tmdb.search_tv(kandidat)
|
||||||
for show in tv_shows:
|
for show in tv_shows:
|
||||||
if show.get("name", "").lower() == title.lower():
|
if show.get("name", "").lower() == kandidat.lower():
|
||||||
confidence = 0.9
|
confidence = 0.9
|
||||||
tv_details = self.tmdb.get_tv_details(show["id"])
|
tv_details = self.tmdb.get_tv_details(show["id"])
|
||||||
if tv_details:
|
if tv_details:
|
||||||
@@ -230,10 +433,56 @@ class PreScan:
|
|||||||
"overview": tv_details.get("overview", ""),
|
"overview": tv_details.get("overview", ""),
|
||||||
"poster_path": tv_details.get("poster_path", ""),
|
"poster_path": tv_details.get("poster_path", ""),
|
||||||
"backdrop_path": tv_details.get("backdrop_path", ""),
|
"backdrop_path": tv_details.get("backdrop_path", ""),
|
||||||
"genres": [g["name"] for g in tv_details.get("genres", [])]
|
"genres": [g["name"] for g in tv_details.get("genres", [])],
|
||||||
|
"source": "tmdb",
|
||||||
}
|
}
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1
|
||||||
|
if not matched:
|
||||||
|
for kandidat in kandidaten:
|
||||||
|
jikan_treffer = self.jikan.lookup(kandidat)
|
||||||
|
if jikan_treffer:
|
||||||
|
confidence = 0.85
|
||||||
|
metadata = jikan_treffer
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||||
|
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||||
|
if not matched:
|
||||||
|
for kandidat in kandidaten:
|
||||||
|
omdb_treffer = self.omdb.lookup(kandidat)
|
||||||
|
if omdb_treffer:
|
||||||
|
confidence = 0.8
|
||||||
|
metadata = omdb_treffer
|
||||||
|
title = omdb_treffer.get("title", title)
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
||||||
|
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
||||||
|
if not matched and movies:
|
||||||
|
vorschlag = self.tmdb.get_movie_details(movies[0]["id"])
|
||||||
|
if vorschlag:
|
||||||
|
confidence = 0.6
|
||||||
|
metadata = {
|
||||||
|
"type": "movie",
|
||||||
|
"id": movies[0]["id"],
|
||||||
|
"title": vorschlag.get("title", title),
|
||||||
|
"year": int(vorschlag.get("release_date", "0")[:4]) if vorschlag.get("release_date") else None,
|
||||||
|
"overview": vorschlag.get("overview", ""),
|
||||||
|
"poster_path": vorschlag.get("poster_path", ""),
|
||||||
|
"backdrop_path": vorschlag.get("backdrop_path", ""),
|
||||||
|
"runtime": vorschlag.get("runtime", 0),
|
||||||
|
"genres": [g["name"] for g in vorschlag.get("genres", [])],
|
||||||
|
"source": "tmdb",
|
||||||
|
}
|
||||||
|
matched = True
|
||||||
|
|
||||||
if not matched:
|
if not matched:
|
||||||
confidence = 0.3
|
confidence = 0.3
|
||||||
@@ -243,13 +492,35 @@ class PreScan:
|
|||||||
"year": None
|
"year": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
|
||||||
|
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
|
||||||
|
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
|
||||||
|
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
|
||||||
|
deutsch = self.tmdb.find_by_imdb(metadata["id"])
|
||||||
|
if deutsch:
|
||||||
|
for feld in ("title", "overview", "poster_path", "year", "type"):
|
||||||
|
if deutsch.get(feld):
|
||||||
|
metadata[feld] = deutsch[feld]
|
||||||
|
|
||||||
|
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
|
||||||
|
if matched and metadata.get("title"):
|
||||||
|
title = metadata["title"]
|
||||||
|
|
||||||
result = PreScanResult(
|
result = PreScanResult(
|
||||||
disc_type=toc.get("disc_type", "DVD"),
|
disc_type=toc.get("disc_type", "DVD"),
|
||||||
title=title,
|
title=title,
|
||||||
year=metadata.get("year"),
|
year=metadata.get("year"),
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
tracks=toc.get("tracks", [])
|
tracks=toc.get("tracks", []),
|
||||||
|
fingerprint=toc.get("fingerprint", "")
|
||||||
)
|
)
|
||||||
cache_set(cache_key, result.to_dict())
|
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
|
||||||
|
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
|
||||||
|
# Mittelgute Treffer verfallen nach einem Tag — falls eine bessere
|
||||||
|
# Quelle (Jikan-504 vorbei, TMDB-Key neu) später mehr weiß.
|
||||||
|
if confidence >= 0.9:
|
||||||
|
cache_set(cache_key, result.to_dict(), expire=7 * 86400)
|
||||||
|
elif confidence >= 0.6:
|
||||||
|
cache_set(cache_key, result.to_dict(), expire=86400)
|
||||||
return result
|
return result
|
||||||
|
|||||||
+6
-41
@@ -1,9 +1,12 @@
|
|||||||
"""Rate-Limiting-Modul für Rippy API."""
|
"""Rate-Limiting-Modul für Rippy API (pro Client-IP).
|
||||||
|
|
||||||
|
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||||
|
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import secrets
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict, Optional
|
from typing import Dict
|
||||||
|
|
||||||
# Default Rate Limit
|
# Default Rate Limit
|
||||||
MAX_REQUESTS_PER_MINUTE = 100
|
MAX_REQUESTS_PER_MINUTE = 100
|
||||||
@@ -48,41 +51,3 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
|||||||
def reset_rate_limit(client_id: str) -> None:
|
def reset_rate_limit(client_id: str) -> None:
|
||||||
"""Setze Rate Limit für Client zurück."""
|
"""Setze Rate Limit für Client zurück."""
|
||||||
rate_limit_store[client_id] = []
|
rate_limit_store[client_id] = []
|
||||||
|
|
||||||
|
|
||||||
# API-Key Store (in Produktion mit Datenbank)
|
|
||||||
api_keys: Dict[str, Dict] = {
|
|
||||||
"example_key": {
|
|
||||||
"key": "example_key",
|
|
||||||
"name": "Beispiel API Key",
|
|
||||||
"created_at": time.time(),
|
|
||||||
"rate_limit": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_api_key(api_key: str) -> Optional[Dict]:
|
|
||||||
"""Validiere API Key."""
|
|
||||||
if api_key in api_keys:
|
|
||||||
return api_keys[api_key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def create_api_key(name: str) -> Dict:
|
|
||||||
"""Erstelle neuer API Key."""
|
|
||||||
key = secrets.token_urlsafe(32)
|
|
||||||
api_keys[key] = {
|
|
||||||
"key": key,
|
|
||||||
"name": name,
|
|
||||||
"created_at": time.time(),
|
|
||||||
"rate_limit": MAX_REQUESTS_PER_MINUTE
|
|
||||||
}
|
|
||||||
return api_keys[key]
|
|
||||||
|
|
||||||
|
|
||||||
def delete_api_key(key: str) -> bool:
|
|
||||||
"""Lösche API Key."""
|
|
||||||
if key in api_keys:
|
|
||||||
del api_keys[key]
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ redis==5.0.4
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
pydantic==2.9.0
|
pydantic==2.9.0
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
passlib==1.7.4
|
# passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
|
||||||
PyJWT==2.9.0
|
# Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
|
||||||
python-multipart==0.0.9
|
# die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ def test_main_importierbar_und_routen_verdrahtet():
|
|||||||
assert pfad in routen, f"Route {pfad} fehlt"
|
assert pfad in routen, f"Route {pfad} fehlt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateiname_validierung_blockt_pfad_tricks():
|
||||||
|
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
|
||||||
|
from main import _sicherer_dateiname
|
||||||
|
|
||||||
|
assert _sicherer_dateiname("film.mkv") is True
|
||||||
|
assert _sicherer_dateiname("../../etc/passwd") is False
|
||||||
|
assert _sicherer_dateiname("a/b.mkv") is False
|
||||||
|
assert _sicherer_dateiname("a\\b.mkv") is False
|
||||||
|
assert _sicherer_dateiname(".versteckt") is False
|
||||||
|
assert _sicherer_dateiname("") is False
|
||||||
|
|
||||||
|
|
||||||
def test_worker_task_name_passt_zum_celery_client():
|
def test_worker_task_name_passt_zum_celery_client():
|
||||||
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
||||||
import inspect
|
import inspect
|
||||||
@@ -30,3 +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)"
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from auth import (
|
|
||||||
add_to_blacklist,
|
|
||||||
cleanup_blacklist,
|
|
||||||
create_access_token,
|
|
||||||
create_refresh_token,
|
|
||||||
decode_token,
|
|
||||||
get_password_hash,
|
|
||||||
is_access_token,
|
|
||||||
is_blacklisted,
|
|
||||||
is_refresh_token,
|
|
||||||
token_blacklist,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_passwort_hash_roundtrip():
|
|
||||||
hashed = get_password_hash("geheim123")
|
|
||||||
assert hashed != "geheim123"
|
|
||||||
assert verify_password("geheim123", hashed) is True
|
|
||||||
assert verify_password("falsch", hashed) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_access_token_roundtrip():
|
|
||||||
token = create_access_token({"sub": "commander"})
|
|
||||||
payload = decode_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["sub"] == "commander"
|
|
||||||
assert payload["type"] == "access"
|
|
||||||
assert is_access_token(token) is True
|
|
||||||
assert is_refresh_token(token) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_token_roundtrip():
|
|
||||||
token = create_refresh_token({"sub": "commander"})
|
|
||||||
payload = decode_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["type"] == "refresh"
|
|
||||||
assert is_refresh_token(token) is True
|
|
||||||
assert is_access_token(token) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_muell_token_gibt_none_und_false():
|
|
||||||
assert decode_token("kein.echter.token") is None
|
|
||||||
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
|
|
||||||
assert is_access_token("kein.echter.token") is False
|
|
||||||
assert is_refresh_token("kein.echter.token") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_blacklist_logout_wirkt():
|
|
||||||
token = create_access_token({"sub": "commander"})
|
|
||||||
assert is_blacklisted(token) is False
|
|
||||||
add_to_blacklist(token)
|
|
||||||
assert is_blacklisted(token) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_entfernt_nur_abgelaufene():
|
|
||||||
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
|
|
||||||
frisch = create_access_token({"sub": "commander"})
|
|
||||||
add_to_blacklist(frisch)
|
|
||||||
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
|
|
||||||
|
|
||||||
cleanup_blacklist()
|
|
||||||
|
|
||||||
assert "laengst-abgelaufener-token" not in token_blacklist
|
|
||||||
assert is_blacklisted(frisch) is True
|
|
||||||
@@ -15,7 +15,17 @@ def test_prescan_key_audio_vs_video():
|
|||||||
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
||||||
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
||||||
assert audio != video
|
assert audio != video
|
||||||
assert audio.endswith("/dev/sr0")
|
assert "/dev/sr0" in audio
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescan_key_unterscheidet_discs_im_selben_laufwerk():
|
||||||
|
"""Seit 23.07. ist der Disc-Fingerabdruck Teil des Keys — sonst bekam die
|
||||||
|
NÄCHSTE Disc im selben Laufwerk die Metadaten der vorherigen serviert.
|
||||||
|
(Der alte Test kannte den Fingerprint nicht und hielt die Ampel rot.)"""
|
||||||
|
a = generate_prescan_key("/dev/sr0", False, "EVANGELION_2_22:43GB")
|
||||||
|
b = generate_prescan_key("/dev/sr0", False, "MATRIX:31GB")
|
||||||
|
assert a != b
|
||||||
|
assert a.endswith("EVANGELION_2_22:43GB")
|
||||||
|
|
||||||
|
|
||||||
def test_confirmed_key_mit_und_ohne_jahr():
|
def test_confirmed_key_mit_und_ohne_jahr():
|
||||||
|
|||||||
@@ -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-")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||||
|
|
||||||
|
from mounts import uebersetze_smb_fehler, validiere_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
|
||||||
|
)
|
||||||
|
assert "Gast" in meldung
|
||||||
|
assert "Benutzername + Passwort" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_denied_mit_credentials_verweist_auf_konto():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
|
||||||
|
)
|
||||||
|
assert "stimmen nicht" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_logon_failure_wird_uebersetzt():
|
||||||
|
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
|
||||||
|
assert "Passwort falsch" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_unerreichbar_wird_uebersetzt():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
|
||||||
|
mit_credentials=False,
|
||||||
|
)
|
||||||
|
assert "nicht erreichbar" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
||||||
|
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
|
||||||
|
assert meldung == "X" * 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_validiere_name_bleibt_streng():
|
||||||
|
assert validiere_name("nas-filme")
|
||||||
|
assert not validiere_name("NAS Filme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||||
|
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||||
|
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
aufrufe.append(cmd)
|
||||||
|
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||||
|
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||||
|
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||||
|
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounten_loest_stale_vor_dem_mount():
|
||||||
|
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||||
|
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
|
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing)."""
|
||||||
|
|
||||||
|
from clients.omdb import parse_runtime_minutes, parse_year
|
||||||
|
from prescan.prescan import normalize_disc_label, parse_udf_dstring
|
||||||
|
|
||||||
|
|
||||||
|
def test_udf_dstring_latin1():
|
||||||
|
# d-string: [comp_id=8]["MEIN_FILM"][padding][len=10]
|
||||||
|
daten = bytes([8]) + b"MEIN_FILM" + bytes(21) + bytes([10])
|
||||||
|
assert parse_udf_dstring(daten) == "MEIN_FILM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_udf_dstring_utf16():
|
||||||
|
text = "FILM".encode("utf-16-be")
|
||||||
|
daten = bytes([16]) + text + bytes(32 - 2 - len(text)) + bytes([1 + len(text)])
|
||||||
|
assert parse_udf_dstring(daten) == "FILM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_udf_dstring_kaputt_gibt_leer():
|
||||||
|
assert parse_udf_dstring(b"") == ""
|
||||||
|
assert parse_udf_dstring(bytes(32)) == "" # Länge 0
|
||||||
|
daten = bytes([8]) + b"X" + bytes(29) + bytes([99]) # Länge > Puffer
|
||||||
|
assert parse_udf_dstring(daten) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_normalisierung():
|
||||||
|
# Disc-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN
|
||||||
|
assert normalize_disc_label("PULP_FICTION") == "Pulp Fiction"
|
||||||
|
assert normalize_disc_label("DIE_HARD_4.0_DE") == "Die Hard 4 0 De"
|
||||||
|
assert normalize_disc_label(" doppel raum ") == "doppel raum"
|
||||||
|
assert normalize_disc_label("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_mit_gemischter_schreibung_bleibt():
|
||||||
|
# Wer schon sauber benannt hat, wird nicht plattgewalzt
|
||||||
|
assert normalize_disc_label("Inception") == "Inception"
|
||||||
|
|
||||||
|
|
||||||
|
def test_omdb_runtime_parsing():
|
||||||
|
assert parse_runtime_minutes("142 min") == 142
|
||||||
|
assert parse_runtime_minutes("N/A") == 0
|
||||||
|
assert parse_runtime_minutes("") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_omdb_jahr_parsing():
|
||||||
|
assert parse_year("1999") == 1999
|
||||||
|
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
||||||
|
assert parse_year("N/A") is None
|
||||||
|
assert parse_year("") is None
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Tests für die TMDB-Key-Erkennung (v3-Schlüssel vs. v4-Token).
|
||||||
|
|
||||||
|
Befund 24.07.: Der Client konnte nur v4-Bearer — mit dem üblichen 32-Hex-
|
||||||
|
v3-Key war jede Anfrage 401 und TMDB fiel still aus (nur OMDb-Treffer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clients.tmdb import ist_v4_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_token_wird_erkannt():
|
||||||
|
assert ist_v4_token("eyJhbGciOiJIUzI1NiJ9.irgendwas.signatur") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_hex_key_ist_kein_v4_token():
|
||||||
|
assert ist_v4_token("842abc0123456789842abc0123456789") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_leer_ist_kein_token():
|
||||||
|
assert ist_v4_token("") is False
|
||||||
|
assert ist_v4_token(None) is False
|
||||||
@@ -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
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Rippy — Automatisches Ripping-System</title>
|
<title>Rippy — Automatisches Ripping-System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Rippy-Favicon: Disc im Indigo-Violett-Verlauf des UI-Logos -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6366f1"/>
|
||||||
|
<stop offset="1" stop-color="#9333ea"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#ffffff" stroke-width="4"/>
|
||||||
|
<circle cx="32" cy="32" r="6" fill="#ffffff"/>
|
||||||
|
<path d="M32 13 A19 19 0 0 1 51 32" fill="none" stroke="#c7d2fe" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 634 B |
+63
-61
@@ -1,86 +1,88 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon, Terminal } from 'lucide-react'
|
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||||
import { useDarkMode } from './context/ThemeContext'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MetadataPreview from './pages/MetadataPreview'
|
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
|
import AnleitungPage from './pages/Anleitung'
|
||||||
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
|
import { api } from './lib/api'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'metadata' | 'settings' | 'logs'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
const { theme, toggleTheme } = useDarkMode()
|
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||||
|
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||||
|
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||||
|
|
||||||
const navItems = [
|
useEffect(() => {
|
||||||
|
api.get('/setup')
|
||||||
|
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||||
|
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'metadata', label: 'Metadaten', icon: Disc },
|
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||||
|
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300">
|
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||||
<div className="flex h-screen overflow-hidden">
|
{/* Top Header Navigation Bar */}
|
||||||
{/* Sidebar */}
|
<header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
|
||||||
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Logo */}
|
||||||
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
|
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
|
||||||
<Disc size={28} className="text-white" />
|
<span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
|
||||||
</div>
|
RIPPY
|
||||||
<div>
|
</span>
|
||||||
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
|
<span className="px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p>
|
CINEMA OS
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Central Nav Tabs */}
|
||||||
onClick={toggleTheme}
|
<nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
|
||||||
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200"
|
{navItems.map((item) => {
|
||||||
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'}
|
const Icon = item.icon
|
||||||
>
|
const isActive = currentPage === item.id
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
return (
|
||||||
</button>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setPage(item.id as Page)}
|
onClick={() => setCurrentPage(item.id)}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||||
page === item.id
|
isActive
|
||||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
|
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={20} />
|
<Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
|
||||||
<span className="font-medium">{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-slate-800 dark:border-slate-700">
|
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||||
<button className="w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-slate-400 hover:text-white hover:bg-slate-800 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-700">
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
<LogOut size={20} />
|
<span>ONLINE</span>
|
||||||
<span className="text-sm">Abmelden</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content Viewport */}
|
||||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
{page === 'dashboard' && <Dashboard />}
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
{page === 'metadata' && <MetadataPreview />}
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
{page === 'logs' && <LogsPage />}
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
{page === 'settings' && <SettingsPage />}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -22,62 +23,51 @@ export default function ConfirmDialog({
|
|||||||
cancelText = 'Abbrechen',
|
cancelText = 'Abbrechen',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning': return <span className="text-4xl">⚠️</span>
|
case 'warning':
|
||||||
case 'success': return <span className="text-4xl">✅</span>
|
return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||||
default: return <span className="text-4xl">🤔</span>
|
case 'success':
|
||||||
|
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
|
||||||
|
default:
|
||||||
|
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConfirmColor = () => {
|
const getConfirmVariant = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30'
|
case 'warning':
|
||||||
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30'
|
return 'amber'
|
||||||
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30'
|
case 'success':
|
||||||
|
return 'primary'
|
||||||
|
default:
|
||||||
|
return 'primary'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
<Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
|
||||||
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
<div className="flex items-start gap-4">
|
||||||
{/* Header */}
|
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="space-y-2 flex-1">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
|
||||||
>
|
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant={getConfirmVariant()} onClick={onConfirm}>
|
||||||
onClick={onConfirm}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
|
|
||||||
>
|
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,121 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { TypeBadge } from './ui/Badge'
|
||||||
|
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||||
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
|
||||||
|
interface DiscInfo {
|
||||||
|
title: string
|
||||||
|
year?: number
|
||||||
|
disc_type: string
|
||||||
|
confidence: number
|
||||||
|
metadata?: {
|
||||||
|
poster_path?: string
|
||||||
|
overview?: string
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
bereits_gerippt?: {
|
||||||
|
job_id: string
|
||||||
|
title?: string
|
||||||
|
finished_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'cd' | 'dvd' | 'bluray' | 'unknown'
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||||
path: string
|
path: string
|
||||||
status: 'empty' | 'ready' | 'ripping'
|
status: 'empty' | 'ready' | 'ripping'
|
||||||
serial?: string
|
serial?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
disc?: DiscInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(disc?: DiscInfo): string | null {
|
||||||
|
const p = disc?.metadata?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function typLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'cd': return 'Audio CD'
|
||||||
|
case 'dvd': return 'DVD'
|
||||||
|
case 'bluray': return 'Blu-ray'
|
||||||
|
case 'uhd': return '4K UHD'
|
||||||
|
default: return 'Unbekannt'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { theme } = useDarkMode()
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
||||||
|
const [actionBusy, setActionBusy] = useState(false)
|
||||||
|
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||||
|
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshDevices = async () => {
|
const refreshDevices = async (manuell = false) => {
|
||||||
|
if (manuell) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/devices')
|
const response = await api.get('/devices')
|
||||||
setDevices(response.data)
|
setDevices(response.data)
|
||||||
|
if (manuell) toast('success', 'Laufwerke aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Geräte:', error)
|
console.error('Fehler beim Laden der Geräte:', error)
|
||||||
|
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setTimeout(() => setRefreshing(false), 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
|
||||||
|
setActionBusy(true)
|
||||||
|
setActionFeedback(null)
|
||||||
|
try {
|
||||||
|
const response = await api.post('/jobs', {
|
||||||
|
device_path: device.path,
|
||||||
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
|
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||||
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
|
})
|
||||||
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
|
} catch (error: any) {
|
||||||
|
const detail = error?.response?.data?.detail || 'Job konnte nicht angelegt werden'
|
||||||
|
setActionFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ejectDisc = async (device: Device) => {
|
||||||
|
setActionBusy(true)
|
||||||
|
setActionFeedback(null)
|
||||||
|
try {
|
||||||
|
await api.post(`/devices/${device.id}/eject`)
|
||||||
|
setActionFeedback('✓ Disc ausgeworfen')
|
||||||
|
toast('success', 'Disc ausgeworfen')
|
||||||
|
refreshDevices()
|
||||||
|
} catch (error: any) {
|
||||||
|
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
|
||||||
|
setActionFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,126 +125,225 @@ export default function DeviceDiscovery() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const QUELLEN_LABEL: Record<string, string> = {
|
||||||
switch (type) {
|
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||||
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
|
|
||||||
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
|
|
||||||
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
|
|
||||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
case 'empty':
|
||||||
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Keine Disc</span>
|
||||||
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
case 'ready':
|
||||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"><CheckCircle size={13} /> Bereit</span>
|
||||||
}
|
case 'ripping':
|
||||||
}
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-sky-500/30 animate-pulse"><RefreshCw size={13} className="animate-spin" /> Rippt</span>
|
||||||
|
default:
|
||||||
const getStatusIcon = (status: string) => {
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Unbekannt</span>
|
||||||
switch (status) {
|
|
||||||
case 'empty': return <AlertCircle size={16} />
|
|
||||||
case 'ready': return <CheckCircle size={16} />
|
|
||||||
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
|
|
||||||
default: return <AlertCircle size={16} />
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<RefreshCw className="animate-spin text-indigo-600" size={24} />
|
<RefreshCw className="animate-spin text-amber-500" size={24} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card className="overflow-hidden">
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
|
<CardHeader>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2>
|
<CardTitle>Laufwerke</CardTitle>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={refreshDevices}
|
|
||||||
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
|
|
||||||
title="Geräte neu laden"
|
|
||||||
>
|
|
||||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
|
||||||
|
<RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<div className="p-6 transition-colors duration-300">
|
<CardContent>
|
||||||
{devices.length === 0 ? (
|
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||||
<div className="text-center py-12 transition-colors duration-300">
|
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
|
<div
|
||||||
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
key={`disc-${device.id}`}
|
||||||
|
className="mb-6 rounded-2xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 flex shadow-xl shadow-amber-500/5 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{posterUrl(device.disc) && (
|
||||||
|
<img
|
||||||
|
src={posterUrl(device.disc)!}
|
||||||
|
alt={device.disc!.title}
|
||||||
|
className="w-32 object-cover flex-shrink-0 border-r border-slate-800"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
|
||||||
|
Im Laufwerk erkannt
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
|
||||||
|
{device.disc!.title}
|
||||||
|
{device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<TypeBadge type={device.type} />
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
||||||
|
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p>
|
{device.disc!.metadata?.overview && (
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
|
||||||
|
{device.disc!.metadata.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{device.disc!.bereits_gerippt && (
|
||||||
|
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
|
||||||
|
<AlertCircle size={13} className="flex-shrink-0" />
|
||||||
|
Diese Disc wurde bereits gerippt
|
||||||
|
{device.disc!.bereits_gerippt.finished_at
|
||||||
|
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
||||||
|
: ''} — nochmal rippen ist möglich.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-4">
|
||||||
|
<Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
|
||||||
|
Rippen starten
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
|
||||||
|
Nicht korrekt?
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-4 bg-slate-100 dark:bg-slate-800 text-slate-400">
|
||||||
|
<HardDrive size={32} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
|
||||||
|
<p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
|
||||||
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="space-y-3">
|
||||||
{devices.map(device => (
|
{devices.map(device => (
|
||||||
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
<div key={device.id} className="rounded-xl p-4 border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}>
|
<div className="p-3 rounded-xl bg-slate-200/60 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
<Disc size={24} />
|
<Disc size={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||||
{device.name || 'Unbekanntes Laufwerk'}
|
{device.name || 'Unbekanntes Laufwerk'}
|
||||||
</h3>
|
</h3>
|
||||||
{device.status === 'ripping' && (
|
<TypeBadge type={device.type} />
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
|
|
||||||
<RefreshCw size={12} className="animate-spin" />
|
|
||||||
Rippt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.path}
|
{device.path}
|
||||||
</span>
|
</span>
|
||||||
{device.serial && (
|
{device.serial && (
|
||||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.serial}
|
{device.serial}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{device.model && (
|
{device.model && (
|
||||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.model}
|
{device.model}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="mt-3">
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}>
|
{getStatusBadge(device.status)}
|
||||||
{getStatusIcon(device.status)}
|
</div>
|
||||||
{device.status === 'empty' ? 'Keine Disc' :
|
</div>
|
||||||
device.status === 'ready' ? 'Bereit' :
|
</div>
|
||||||
'Rippt'}
|
<Button
|
||||||
</span>
|
variant="ghost"
|
||||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
size="sm"
|
||||||
Typ: {device.type.toUpperCase()}
|
onClick={() => {
|
||||||
|
setExpandedId(expandedId === device.id ? null : device.id)
|
||||||
|
setActionFeedback(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedId === device.id ? 'Schließen' : 'Details'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedId === device.id && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-200/80 dark:border-slate-800 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Gerätepfad</span>
|
||||||
|
<span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Modell</span>
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
|
||||||
|
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Disc</span>
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">
|
||||||
|
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setModalDevice(device)}
|
||||||
|
disabled={actionBusy || device.status !== 'ready'}
|
||||||
|
>
|
||||||
|
Rippen starten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => ejectDisc(device)}
|
||||||
|
disabled={actionBusy || device.status !== 'ready'}
|
||||||
|
>
|
||||||
|
Auswerfen
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionFeedback && (
|
||||||
|
<p className={`text-sm font-medium ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||||
|
{actionFeedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}>
|
)}
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
|
||||||
|
<RipTargetModal
|
||||||
|
isOpen={modalDevice !== null}
|
||||||
|
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||||
|
discTitle={modalDevice?.disc?.title}
|
||||||
|
deviceId={modalDevice?.id}
|
||||||
|
onClose={() => setModalDevice(null)}
|
||||||
|
onSave={(target, optionen) => {
|
||||||
|
if (modalDevice) {
|
||||||
|
startRip(modalDevice, target.path, optionen)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetadataKorrektur
|
||||||
|
devicePath={korrekturDevice?.path || ''}
|
||||||
|
vorschlag={korrekturDevice?.disc?.title}
|
||||||
|
isOpen={korrekturDevice !== null}
|
||||||
|
onClose={() => setKorrekturDevice(null)}
|
||||||
|
onUebernommen={refreshDevices}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
|
interface WorkerInfo {
|
||||||
|
name: string
|
||||||
|
encoders: string[]
|
||||||
|
last_seen?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
|
'cpu-x264': 'CPU · H.264 (x264)',
|
||||||
|
'cpu-x265': 'CPU · H.265 (x265)',
|
||||||
|
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
||||||
|
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||||
|
const [tmdbKey, setTmdbKey] = useState('')
|
||||||
|
const [omdbKey, setOmdbKey] = useState('')
|
||||||
|
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||||
|
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||||
|
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
||||||
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/capabilities')
|
||||||
|
.then(r => setWorkers(r.data.workers || []))
|
||||||
|
.catch(() => setWorkers([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const abschliessen = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const bestehende = (await api.get('/settings')).data || {}
|
||||||
|
await api.post('/settings', {
|
||||||
|
...bestehende,
|
||||||
|
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
||||||
|
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
||||||
|
mediaServer,
|
||||||
|
transcodeEnabled,
|
||||||
|
transcodePreset: preset,
|
||||||
|
})
|
||||||
|
await api.post('/setup/complete')
|
||||||
|
onDone()
|
||||||
|
} catch {
|
||||||
|
setError('Speichern fehlgeschlagen — läuft die API?')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Disc size={32} className="animate-disc-spin" />
|
||||||
|
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
||||||
|
</div>
|
||||||
|
<p className="opacity-90">Einmalige Einrichtung — dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
{/* Metadaten-APIs */}
|
||||||
|
<section>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
|
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="TMDB API-Key (empfohlen)"
|
||||||
|
value={tmdbKey}
|
||||||
|
onChange={e => setTmdbKey(e.target.value)}
|
||||||
|
placeholder="Kostenlos auf themoviedb.org"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="OMDb API-Key (optional, zweite Quelle)"
|
||||||
|
value={omdbKey}
|
||||||
|
onChange={e => setOmdbKey(e.target.value)}
|
||||||
|
placeholder="Kostenlos auf omdbapi.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Media-Server */}
|
||||||
|
<section>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
|
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
|
||||||
|
Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
|
||||||
|
zusätzlich movie.nfo + poster.jpg dazu.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMediaServer(o.wert)}
|
||||||
|
title={o.hinweis}
|
||||||
|
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
|
mediaServer === o.wert
|
||||||
|
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||||
|
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Verarbeitung + erkannte Hardware */}
|
||||||
|
<section>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
|
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||||
|
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
|
||||||
|
) : (
|
||||||
|
workers.map(w => (
|
||||||
|
<div key={w.name} className="mb-1">
|
||||||
|
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||||||
|
{w.encoders.map(e => (
|
||||||
|
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||||
|
}`}>
|
||||||
|
{ENCODER_LABELS[e] || e}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
|
||||||
|
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{transcodeEnabled && (
|
||||||
|
<Select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||||
|
<option value="H.265 MKV 1080p30">H.265 — klein & modern (Standard)</option>
|
||||||
|
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="lg"
|
||||||
|
onClick={abschliessen}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
|
||||||
|
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { StatusBadge } from './ui/Badge'
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobDetail {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
device: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
progress: number
|
||||||
|
title?: string
|
||||||
|
error?: string
|
||||||
|
target_dir?: string
|
||||||
|
output_path?: string
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobDatei {
|
||||||
|
name: string
|
||||||
|
size_mb: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
|
||||||
|
const [detail, setDetail] = useState<JobDetail | null>(null)
|
||||||
|
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) {
|
||||||
|
setDetail(null)
|
||||||
|
setDateien(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLaedt(true)
|
||||||
|
api.get(`/jobs/${jobId}/detail`)
|
||||||
|
.then(r => {
|
||||||
|
setDetail(r.data)
|
||||||
|
if (r.data?.status === 'completed') {
|
||||||
|
api.get(`/jobs/${jobId}/files`)
|
||||||
|
.then(f => setDateien(f.data.files))
|
||||||
|
.catch(() => setDateien(null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setDetail(null))
|
||||||
|
.finally(() => setLaedt(false))
|
||||||
|
}, [jobId])
|
||||||
|
|
||||||
|
if (!jobId) return null
|
||||||
|
|
||||||
|
const meta = detail?.meta
|
||||||
|
const poster = posterUrl(meta)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={!!jobId}
|
||||||
|
onClose={onClose}
|
||||||
|
title={detail?.title || 'Job-Details'}
|
||||||
|
maxWidth="2xl"
|
||||||
|
>
|
||||||
|
{laedt ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-amber-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : !detail ? (
|
||||||
|
<p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
|
||||||
|
Job nicht gefunden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
|
||||||
|
) : (
|
||||||
|
<div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
|
||||||
|
<Film size={36} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
{detail.title || 'Unbekannt'}
|
||||||
|
{meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={detail.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
||||||
|
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
||||||
|
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
||||||
|
</p>
|
||||||
|
{meta?.genres && meta.genres.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{meta.genres.map(g => (
|
||||||
|
<span key={g} className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{meta?.overview ? (
|
||||||
|
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
|
||||||
|
{meta.overview}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
|
||||||
|
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job-Fakten Grid */}
|
||||||
|
<div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
|
||||||
|
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
{(detail.output_path || detail.target_dir) && (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
|
||||||
|
<span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.output_path || detail.target_dir}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dateiliste & Download */}
|
||||||
|
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||||
|
<div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
|
||||||
|
<Download size={15} className="text-amber-500" /> Dateien herunterladen
|
||||||
|
</p>
|
||||||
|
{dateien.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dateien.forEach((f, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
|
||||||
|
a.download = f.name
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
}, i * 400)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alle herunterladen ({dateien.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{dateien.map(f => (
|
||||||
|
<a
|
||||||
|
key={f.name}
|
||||||
|
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Download size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate min-w-0">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
|
||||||
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.error && (
|
||||||
|
<div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="break-words min-w-0">{detail.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Card, CardHeader, CardTitle } from './ui/Card'
|
||||||
|
|
||||||
interface LiveLogEntry {
|
interface LiveLogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,14 +14,13 @@ interface LiveLogEntry {
|
|||||||
export default function LiveLogSection() {
|
export default function LiveLogSection() {
|
||||||
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
||||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/jobs')
|
const response = await api.get('/jobs')
|
||||||
const jobs = response.data
|
const jobs = response.data
|
||||||
const current = jobs.find((j: any) => j.status === 'processing')
|
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
|
||||||
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Jobs:', error)
|
console.error('Fehler beim Laden der Jobs:', error)
|
||||||
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
|
|||||||
|
|
||||||
const getStatusColor = (level: string) => {
|
const getStatusColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return 'text-rose-500'
|
case 'error': return 'text-rose-500 dark:text-rose-400'
|
||||||
case 'warning': return 'text-amber-500'
|
case 'warning': return 'text-amber-500 dark:text-amber-400'
|
||||||
case 'success': return 'text-emerald-500'
|
case 'success': return 'text-emerald-500 dark:text-emerald-400'
|
||||||
default: return 'text-blue-500'
|
default: return 'text-sky-500 dark:text-sky-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (level: string) => {
|
const getStatusIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return <AlertCircle size={16} />
|
case 'error': return <AlertCircle size={15} />
|
||||||
case 'warning': return <AlertCircle size={16} />
|
case 'warning': return <AlertCircle size={15} />
|
||||||
case 'success': return <CheckCircle size={16} />
|
case 'success': return <CheckCircle size={15} />
|
||||||
default: return <Terminal size={16} />
|
default: return <Terminal size={15} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card className="overflow-hidden">
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
|
<CardHeader>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{currentJob
|
||||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
||||||
: 'Kein aktiver Job'}
|
: 'Kein aktiver Job'}
|
||||||
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
{currentJob && (
|
{currentJob && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${
|
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
|
||||||
currentJob.progress === 100
|
currentJob.progress === 100
|
||||||
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||||
}`}>
|
}`}>
|
||||||
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
|
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
|
||||||
{currentJob.progress}%
|
{currentJob.progress}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
|
|
||||||
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${
|
<div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
|
||||||
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
|
|
||||||
}`}>
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-500">
|
<div className="text-center py-8 text-slate-500">
|
||||||
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
|
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<div key={log.id} className="flex items-start gap-2 text-slate-300">
|
||||||
<span className="text-slate-500 whitespace-nowrap">
|
<span className="text-slate-500 whitespace-nowrap">
|
||||||
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
|
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
|
||||||
</span>
|
</span>
|
||||||
<span className={`font-bold ${getStatusColor(log.level)}`}>
|
<span className={`font-bold ${getStatusColor(log.level)}`}>
|
||||||
[{log.source}]
|
[{log.source}]
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">{log.message}</span>
|
<span className="flex-1 text-slate-200">{log.message}</span>
|
||||||
<span className={getStatusColor(log.level)}>
|
<span className={getStatusColor(log.level)}>
|
||||||
{getStatusIcon(log.level)}
|
{getStatusIcon(log.level)}
|
||||||
</span>
|
</span>
|
||||||
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
|
|
||||||
{currentJob && (
|
{currentJob && (
|
||||||
<>
|
<div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
|
||||||
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
{currentJob.progress}%
|
{currentJob.progress}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{currentJob.device || 'Unbekannt'}
|
{currentJob.device || 'Unbekannt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Search, Loader2, Film } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input } from './ui/Input'
|
||||||
|
|
||||||
|
interface SuchKandidat {
|
||||||
|
title: string
|
||||||
|
year?: number
|
||||||
|
poster?: string
|
||||||
|
overview?: string
|
||||||
|
type: string
|
||||||
|
source: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
devicePath: string
|
||||||
|
vorschlag?: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onUebernommen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClose, onUebernommen }: Props) {
|
||||||
|
const [suchbegriff, setSuchbegriff] = useState('')
|
||||||
|
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSuchbegriff(vorschlag || '')
|
||||||
|
setKandidaten([])
|
||||||
|
setFehler(null)
|
||||||
|
}
|
||||||
|
}, [isOpen, vorschlag])
|
||||||
|
|
||||||
|
const suchen = async () => {
|
||||||
|
if (!suchbegriff) return
|
||||||
|
setLaeuft(true)
|
||||||
|
setFehler(null)
|
||||||
|
try {
|
||||||
|
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
|
||||||
|
setKandidaten(r.data)
|
||||||
|
if (r.data.length === 0) setFehler('Nichts gefunden — anderen Suchbegriff probieren.')
|
||||||
|
} catch {
|
||||||
|
setFehler('Suche fehlgeschlagen.')
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const waehlen = async (k: SuchKandidat) => {
|
||||||
|
try {
|
||||||
|
await api.post('/metadata/override', {
|
||||||
|
device_path: devicePath,
|
||||||
|
title: k.title,
|
||||||
|
year: k.year,
|
||||||
|
poster: k.poster,
|
||||||
|
overview: k.overview,
|
||||||
|
type: k.type,
|
||||||
|
source: k.source,
|
||||||
|
id: k.id,
|
||||||
|
})
|
||||||
|
onUebernommen()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
setFehler('Übernehmen fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Richtigen Titel wählen"
|
||||||
|
maxWidth="2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={suchbegriff}
|
||||||
|
onChange={e => setSuchbegriff(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && suchen()}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Titel eingeben…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
onClick={suchen}
|
||||||
|
disabled={!suchbegriff || laeuft}
|
||||||
|
>
|
||||||
|
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
|
||||||
|
|
||||||
|
{kandidaten.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
|
||||||
|
{kandidaten.map((k, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => waehlen(k)}
|
||||||
|
className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
|
||||||
|
>
|
||||||
|
{k.poster ? (
|
||||||
|
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
|
||||||
|
<Film size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-2.5">
|
||||||
|
<p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
|
||||||
|
{k.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -9,130 +13,377 @@ interface TargetConfig {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RipTargetModalProps {
|
export interface RipOptionen {
|
||||||
isOpen: boolean
|
series?: string
|
||||||
onClose: () => void
|
season?: number
|
||||||
onSave: (target: TargetConfig) => void
|
mainFeatureOnly?: boolean
|
||||||
|
titles?: number[]
|
||||||
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) {
|
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 {
|
||||||
|
nr: number
|
||||||
|
dauer_s: number
|
||||||
|
groesse_bytes: number
|
||||||
|
kapitel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RipTargetModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
initialType?: TargetConfig['type']
|
||||||
|
discTitle?: string
|
||||||
|
deviceId?: string
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function dauerText(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowseDir {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
|
||||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||||
|
const [serienName, setSerienName] = useState('')
|
||||||
|
const [staffel, setStaffel] = useState(1)
|
||||||
|
const [nurHauptfilm, setNurHauptfilm] = useState(false)
|
||||||
|
const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||||
|
const [scanFehler, setScanFehler] = useState('')
|
||||||
|
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
|
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||||
|
|
||||||
|
const scanStarten = async () => {
|
||||||
|
if (!deviceId) return
|
||||||
|
setScanStatus('running')
|
||||||
|
setScanFehler('')
|
||||||
|
try {
|
||||||
|
await api.post(`/devices/${deviceId}/scan-tracks`)
|
||||||
|
} catch (e: any) {
|
||||||
|
setScanStatus('error')
|
||||||
|
setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scanStatus !== 'running' || !deviceId) return
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.get(`/devices/${deviceId}/tracks`)
|
||||||
|
if (r.data.status === 'done') {
|
||||||
|
const tracks: TitelInfo[] = r.data.tracks || []
|
||||||
|
setTitelListe(tracks)
|
||||||
|
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||||
|
setScanStatus('done')
|
||||||
|
} else if (r.data.status === 'error') {
|
||||||
|
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||||
|
setScanStatus('error')
|
||||||
|
}
|
||||||
|
} catch { /* API kurz weg — weiter pollen */ }
|
||||||
|
}, 3000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [scanStatus, deviceId])
|
||||||
|
|
||||||
|
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||||
|
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||||
|
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||||
|
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
||||||
|
])
|
||||||
|
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||||
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
const defaultTargets: TargetConfig[] = [
|
useEffect(() => {
|
||||||
{ id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true },
|
if (!isOpen) return
|
||||||
{ id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true },
|
setSelectedType(initialType || 'movies')
|
||||||
{ id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true },
|
setCustomPath('')
|
||||||
]
|
setSerienName(discTitle || '')
|
||||||
|
setStaffel(1)
|
||||||
|
setScanStatus('idle')
|
||||||
|
setScanFehler('')
|
||||||
|
setTitelListe([])
|
||||||
|
setGewaehlt(new Set())
|
||||||
|
setEncoderNode('')
|
||||||
|
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||||
|
api.get('/capabilities').then(r => {
|
||||||
|
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||||
|
}).catch(() => setWorkers([]))
|
||||||
|
api.get('/settings').then(r => {
|
||||||
|
const s = r.data || {}
|
||||||
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
|
const basis = s.outputDir || '/app/media'
|
||||||
|
setTargets([
|
||||||
|
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||||
|
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
|
||||||
|
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
||||||
|
])
|
||||||
|
}).catch(() => {})
|
||||||
|
laden('/app/media')
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
const [targets, setTargets] = useState(defaultTargets)
|
const laden = async (pfad: string) => {
|
||||||
|
try {
|
||||||
|
const r = await api.get('/browse', { params: { path: pfad } })
|
||||||
|
setBrowsePath(r.data.path)
|
||||||
|
setBrowseParent(r.data.parent)
|
||||||
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
|
} catch {
|
||||||
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||||
|
if (selectedType === 'series' && serienName.trim()) {
|
||||||
|
optionen.series = serienName.trim()
|
||||||
|
optionen.season = Math.max(1, staffel || 1)
|
||||||
|
}
|
||||||
|
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||||
|
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
const target = targets.find(t => t.type === selectedType)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (target) {
|
if (customPath) {
|
||||||
onSave(target)
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
|
} else if (target) {
|
||||||
|
onSave(target, optionen)
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
<Modal
|
||||||
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
isOpen={isOpen}
|
||||||
{/* Header */}
|
onClose={onClose}
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
title="Rip-Ziel auswählen"
|
||||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
|
maxWidth="lg"
|
||||||
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
>
|
||||||
Wohin sollen die gerippten Dateien gespeichert werden?
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
{/* Schnellwahl */}
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* Type Selection */}
|
|
||||||
<div>
|
|
||||||
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Dateityp
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{['movies', 'series', 'music'].map((type) => (
|
{targets.map((t) => {
|
||||||
|
const isSelected = selectedType === t.type && !customPath
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={t.type}
|
||||||
onClick={() => setSelectedType(type as TargetConfig['type'])}
|
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
||||||
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
|
className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
|
||||||
selectedType === type
|
isSelected
|
||||||
? theme === 'dark'
|
? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
|
||||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
|
||||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
||||||
: theme === 'dark'
|
|
||||||
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
|
|
||||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-2xl">
|
{t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{type === 'movies' && '🎥'}
|
{t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{type === 'series' && '📺'}
|
{t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{type === 'music' && '🎵'}
|
<span className="text-sm">{t.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Serien-Flow */}
|
||||||
|
{selectedType === 'series' && (
|
||||||
|
<div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="grid grid-cols-[1fr_90px] gap-3">
|
||||||
|
<Input
|
||||||
|
label="Serienname"
|
||||||
|
value={serienName}
|
||||||
|
onChange={e => setSerienName(e.target.value)}
|
||||||
|
placeholder="z. B. Neon Genesis Evangelion"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Staffel"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={staffel}
|
||||||
|
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hauptfilm-Wahl */}
|
||||||
|
{selectedType === 'movies' && scanStatus !== 'done' && (
|
||||||
|
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={nurHauptfilm}
|
||||||
|
onChange={e => setNurHauptfilm(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-amber-500 rounded"
|
||||||
|
/>
|
||||||
|
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||||
|
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||||
|
{selectedType !== 'music' && workers.length >= 2 && (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
label="Encoder / Worker für die Kompression"
|
||||||
|
value={encoderNode}
|
||||||
|
onChange={e => setEncoderNode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Automatisch (erster freier Worker)</option>
|
||||||
|
{workers.map(w => (
|
||||||
|
<option key={w.node!} value={w.node!}>
|
||||||
|
{w.name} — {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
|
{deviceId && selectedType !== 'music' && (
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
|
||||||
|
{scanStatus === 'idle' && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Disc scannen und einzelne Titel an-/abwählen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{scanStatus === 'running' ? (
|
||||||
|
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
|
||||||
|
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
|
||||||
|
Disc wird gelesen (20–120 s)…
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium capitalize">{type}</span>
|
) : (
|
||||||
|
<Button variant="secondary" size="sm" onClick={scanStarten}>
|
||||||
|
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanStatus === 'error' && (
|
||||||
|
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanStatus === 'done' && titelListe.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="max-h-44 overflow-y-auto">
|
||||||
|
{titelListe.map(t => (
|
||||||
|
<label
|
||||||
|
key={t.nr}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={gewaehlt.has(t.nr)}
|
||||||
|
onChange={e => {
|
||||||
|
const neu = new Set(gewaehlt)
|
||||||
|
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
|
||||||
|
setGewaehlt(neu)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 accent-amber-500 rounded"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
|
||||||
|
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
|
||||||
|
{t.kapitel > 0 && (
|
||||||
|
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs font-mono text-slate-400">
|
||||||
|
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
|
||||||
|
{gewaehlt.size} von {titelListe.length} Titeln gewählt
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordner-Browser */}
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
|
<button
|
||||||
|
onClick={() => browseParent && laden(browseParent)}
|
||||||
|
disabled={!browseParent}
|
||||||
|
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||||
|
title="Ordner hoch"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
|
<Button
|
||||||
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCustomPath(browsePath)}
|
||||||
|
>
|
||||||
|
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-36 overflow-y-auto p-1">
|
||||||
|
{browseDirs.map(d => (
|
||||||
|
<button
|
||||||
|
key={d.path}
|
||||||
|
onClick={() => laden(d.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Folder size={14} className="text-slate-400" />
|
||||||
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Path Display */}
|
{/* Gewähltes Ziel Display */}
|
||||||
<div>
|
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||||
Zielverzeichnis
|
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||||
</label>
|
{customPath || targets.find(t => t.type === selectedType)?.path}
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
</span>
|
||||||
<span className="text-xl">📁</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customPath || targets.find(t => t.type === selectedType)?.path || ''}
|
|
||||||
onChange={(e) => setCustomPath(e.target.value)}
|
|
||||||
className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}
|
|
||||||
placeholder="/path/to/target"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const target = targets.find(t => t.type === selectedType)
|
|
||||||
if (target) {
|
|
||||||
setCustomPath(target.path)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
|
||||||
>
|
|
||||||
Standard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
|
||||||
{customPath
|
|
||||||
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
|
|
||||||
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
|
||||||
<button
|
<Button variant="secondary" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
|
||||||
>
|
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="amber" onClick={handleSave}>
|
||||||
onClick={handleSave}
|
Rippen starten
|
||||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
|
</Button>
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
|
interface Mount {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
source: string
|
||||||
|
mounted: boolean
|
||||||
|
reachable?: boolean
|
||||||
|
has_credentials: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_mount: boolean
|
||||||
|
free_gb: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorageMounts() {
|
||||||
|
const [mounts, setMounts] = useState<Mount[]>([])
|
||||||
|
const [targets, setTargets] = useState<Target[]>([])
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
|
||||||
|
const [source, setSource] = useState('')
|
||||||
|
const [host, setHost] = useState('')
|
||||||
|
const [shares, setShares] = useState<string[]>([])
|
||||||
|
const [selectedShare, setSelectedShare] = useState('')
|
||||||
|
const [sharesBusy, setSharesBusy] = useState(false)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null)
|
||||||
|
const [browsePath, setBrowsePath] = useState('/app/media')
|
||||||
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
|
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||||
|
const [browserOffen, setBrowserOffen] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const browsen = async (pfad: string) => {
|
||||||
|
try {
|
||||||
|
const r = await api.get('/browse', { params: { path: pfad } })
|
||||||
|
setBrowsePath(r.data.path)
|
||||||
|
setBrowseParent(r.data.parent)
|
||||||
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
|
} catch {
|
||||||
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordnerAnlegen = async () => {
|
||||||
|
if (!neuerOrdner) return
|
||||||
|
try {
|
||||||
|
await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner })
|
||||||
|
setNeuerOrdner('')
|
||||||
|
browsen(browsePath)
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
setFeedback(`✗ ${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const laden = async (manuell = false) => {
|
||||||
|
try {
|
||||||
|
const [m, t] = await Promise.allSettled([
|
||||||
|
api.get('/storage-mounts'),
|
||||||
|
api.get('/storage-targets'),
|
||||||
|
])
|
||||||
|
if (m.status === 'fulfilled') setMounts(m.value.data || [])
|
||||||
|
if (t.status === 'fulfilled') setTargets(t.value.data || [])
|
||||||
|
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||||
|
} catch {
|
||||||
|
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { laden(); browsen('/app/media') }, [])
|
||||||
|
|
||||||
|
const freigabenAuflisten = async () => {
|
||||||
|
if (!host) return
|
||||||
|
setSharesBusy(true)
|
||||||
|
setFeedback(null)
|
||||||
|
try {
|
||||||
|
const r = await api.get('/storage-mounts/shares', {
|
||||||
|
params: { host, ...(username ? { username, password } : {}) },
|
||||||
|
})
|
||||||
|
setShares(r.data.shares)
|
||||||
|
if (r.data.shares.length === 0) {
|
||||||
|
setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?')
|
||||||
|
} else {
|
||||||
|
setSelectedShare(r.data.shares[0])
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setFeedback(`✗ ${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`)
|
||||||
|
setShares([])
|
||||||
|
} finally {
|
||||||
|
setSharesBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hinzufuegen = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setFeedback(null)
|
||||||
|
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
||||||
|
const zielName = name
|
||||||
|
try {
|
||||||
|
const r = await api.post('/storage-mounts', {
|
||||||
|
name, type: typ, source: quelle,
|
||||||
|
...(username ? { username, password } : {}),
|
||||||
|
})
|
||||||
|
if (r.data.writable) {
|
||||||
|
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
|
||||||
|
toast('success', `„${zielName}" eingehängt — Schreibtest OK`)
|
||||||
|
} else {
|
||||||
|
setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`)
|
||||||
|
toast('error', `„${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`)
|
||||||
|
}
|
||||||
|
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
|
||||||
|
setFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', `„${zielName}": ${detail}`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entfernen = async (mountName: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.delete(`/storage-mounts/${mountName}`)
|
||||||
|
toast('success', `„${mountName}" ausgehängt und entfernt`)
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
|
||||||
|
setFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reparieren = async (mountName: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.post(`/storage-mounts/${mountName}/repair`)
|
||||||
|
toast(r.data.writable ? 'success' : 'error',
|
||||||
|
r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`)
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Vorhandene Ziele */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
|
<span>Verfügbare Ziele</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{targets.map(t => (
|
||||||
|
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
|
||||||
|
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
|
||||||
|
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mounts.some(m => m.name === t.name) && (
|
||||||
|
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
|
||||||
|
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
|
||||||
|
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
|
||||||
|
{mounts.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
|
<span>Netzwerk-Mounts</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{mounts.map(m => {
|
||||||
|
const aktiv = m.mounted && m.reachable !== false
|
||||||
|
const tot = m.mounted && m.reachable === false
|
||||||
|
return (
|
||||||
|
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{aktiv
|
||||||
|
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
: tot
|
||||||
|
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
||||||
|
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
||||||
|
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
||||||
|
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{!aktiv && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
||||||
|
<Wrench size={14} /> Reparieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordner-Verwaltung */}
|
||||||
|
<Card>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrowserOffen(!browserOffen)}
|
||||||
|
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Ordner-Verwaltung (optional)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
|
||||||
|
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder „doku".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{browserOffen && (
|
||||||
|
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => browseParent && browsen(browseParent)}
|
||||||
|
disabled={!browseParent}
|
||||||
|
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
|
||||||
|
title="Ordner hoch"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
|
<input
|
||||||
|
value={neuerOrdner}
|
||||||
|
onChange={e => setNeuerOrdner(e.target.value)}
|
||||||
|
placeholder="neuer-ordner"
|
||||||
|
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={ordnerAnlegen}
|
||||||
|
disabled={!neuerOrdner}
|
||||||
|
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||||
|
title="Ordner anlegen"
|
||||||
|
>
|
||||||
|
<FolderPlus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 overflow-y-auto p-2">
|
||||||
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
|
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{browseDirs.map(d => (
|
||||||
|
<button
|
||||||
|
key={d.path}
|
||||||
|
onClick={() => browsen(d.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
|
||||||
|
{d.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{browseFiles.map(f => (
|
||||||
|
<div
|
||||||
|
key={f.name}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
|
||||||
|
>
|
||||||
|
<File size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<span className="ml-auto text-xs flex-shrink-0 font-mono">
|
||||||
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Neues Netzwerk-Ziel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Plus size={20} className="text-amber-500" />
|
||||||
|
<span>Netzwerk-Ziel hinzufügen</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name (klein, ohne Leerzeichen)"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value.toLowerCase())}
|
||||||
|
placeholder="nas-filme"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Typ"
|
||||||
|
value={typ}
|
||||||
|
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
|
||||||
|
>
|
||||||
|
<option value="nfs">NFS (NAS/Linux)</option>
|
||||||
|
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{typ === 'nfs' ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Input
|
||||||
|
label="Quelle (host:/export/pfad)"
|
||||||
|
value={source}
|
||||||
|
onChange={e => setSource(e.target.value)}
|
||||||
|
placeholder="192.168.1.10:/volume1/filme"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Input
|
||||||
|
label="Rechner (dein PC oder NAS — Name oder IP)"
|
||||||
|
value={host}
|
||||||
|
onChange={e => setHost(e.target.value)}
|
||||||
|
placeholder="192.168.178.20 oder TOBIS-PC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Benutzer"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Freigabe-Benutzer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben versucht Rippy einen Gast-Zugriff.
|
||||||
|
</p>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={freigabenAuflisten}
|
||||||
|
disabled={!host || sharesBusy}
|
||||||
|
>
|
||||||
|
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Select
|
||||||
|
label="Freigabe wählen"
|
||||||
|
value={selectedShare}
|
||||||
|
onChange={e => setSelectedShare(e.target.value)}
|
||||||
|
>
|
||||||
|
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
onClick={hinzufuegen}
|
||||||
|
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
||||||
|
>
|
||||||
|
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||||
|
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
|
{feedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input } from './ui/Input'
|
||||||
|
|
||||||
|
interface WorkerInfo {
|
||||||
|
name: string
|
||||||
|
encoders: string[]
|
||||||
|
last_seen?: string
|
||||||
|
online?: boolean
|
||||||
|
info?: {
|
||||||
|
hostname?: string
|
||||||
|
ip?: string
|
||||||
|
makemkv?: string
|
||||||
|
handbrake?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
|
'cpu-x264': 'CPU · H.264',
|
||||||
|
'cpu-x265': 'CPU · H.265',
|
||||||
|
'vaapi': 'GPU · VAAPI (AMD/Intel)',
|
||||||
|
'nvenc': 'GPU · NVENC (NVIDIA)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeZeit(iso?: string): string {
|
||||||
|
if (!iso) return 'nie'
|
||||||
|
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (sekunden < 90) return 'gerade eben'
|
||||||
|
if (sekunden < 3600) return `vor ${Math.floor(sekunden / 60)} min`
|
||||||
|
return `vor ${Math.floor(sekunden / 3600)} h`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkerVerwaltung() {
|
||||||
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [kopiert, setKopiert] = useState(false)
|
||||||
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
|
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||||
|
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
|
||||||
|
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
|
||||||
|
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
|
||||||
|
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||||
|
const [rippyHost, setRippyHost] = useState('')
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||||
|
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||||
|
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
|
||||||
|
|
||||||
|
const siehtOeffentlichAus =
|
||||||
|
rippyHost !== '' &&
|
||||||
|
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
||||||
|
!rippyHost.endsWith('.local') &&
|
||||||
|
rippyHost !== 'localhost' &&
|
||||||
|
rippyHost.includes('.')
|
||||||
|
|
||||||
|
const laden = (manuell = false) => {
|
||||||
|
api.get('/capabilities')
|
||||||
|
.then(r => {
|
||||||
|
setWorkers(r.data.workers || [])
|
||||||
|
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||||
|
})
|
||||||
|
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden()
|
||||||
|
const interval = setInterval(laden, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const installBefehl = variante === 'linux'
|
||||||
|
? [
|
||||||
|
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||||
|
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
|
||||||
|
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||||
|
].join('\n')
|
||||||
|
: [
|
||||||
|
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||||
|
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||||
|
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const kopieren = () => {
|
||||||
|
navigator.clipboard.writeText(installBefehl).then(() => {
|
||||||
|
setKopiert(true)
|
||||||
|
setTimeout(() => setKopiert(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fertige Installer-.exe (Rippy-Icon, kein Konsolenfenster) direkt laden.
|
||||||
|
// Generisch — die GUI fragt die Rippy-Adresse selbst ab.
|
||||||
|
const exeHerunterladen = () => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = '/api/worker-setup/windows-exe'
|
||||||
|
a.download = 'RippyWorkerSetup.exe'
|
||||||
|
document.body.appendChild(a); a.click(); a.remove()
|
||||||
|
toast('success', 'RippyWorkerSetup.exe wird heruntergeladen — doppelklicken zum Installieren')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Verbundene Worker */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server size={20} className="text-amber-500" />
|
||||||
|
<span>Verbundene Worker</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{workers.map(w => (
|
||||||
|
<div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
|
||||||
|
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
||||||
|
/>
|
||||||
|
<Server size={18} className="text-slate-400 dark:text-slate-500" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||||
|
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||||
|
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
|
{w.encoders.map(e => (
|
||||||
|
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
}`}>
|
||||||
|
{ENCODER_LABELS[e] || e}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!w.online && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
api.delete(`/workers/${encodeURIComponent(w.name)}`)
|
||||||
|
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
||||||
|
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
||||||
|
}}
|
||||||
|
title="Verwaisten Eintrag entfernen"
|
||||||
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Neue Maschine anbinden */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Input
|
||||||
|
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||||
|
value={rippyHost}
|
||||||
|
onChange={e => setRippyHost(e.target.value.trim())}
|
||||||
|
placeholder="z. B. 192.168.178.162"
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
|
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||||
|
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
|
||||||
|
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
|
||||||
|
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
|
||||||
|
an (Ports 6379/5432) — das klappt nur mit der echten LAN-IP im Heimnetz.
|
||||||
|
</p>
|
||||||
|
{siehtOeffentlichAus && (
|
||||||
|
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||||
|
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||||
|
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
||||||
|
<Button
|
||||||
|
key={wert}
|
||||||
|
variant={variante === wert ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVariante(wert)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
|
||||||
|
{variante === 'windows' && (
|
||||||
|
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
|
||||||
|
im Fenster Adresse + Name eintragen, „Installieren". Kein Konsolenfenster.
|
||||||
|
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
|
||||||
|
</p>
|
||||||
|
<Button variant="amber" onClick={exeHerunterladen}>
|
||||||
|
<Download size={16} /> Installer herunterladen (.exe)
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
|
||||||
|
→ „Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
|
||||||
|
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{variante === 'linux'
|
||||||
|
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||||
|
: 'Alternativ (für Profis) per PowerShell-Befehl:'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
|
||||||
|
{installBefehl}
|
||||||
|
<button
|
||||||
|
onClick={kopieren}
|
||||||
|
className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
||||||
|
title="Kopieren"
|
||||||
|
>
|
||||||
|
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier, wo das Laufwerk hängt.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Disc } from 'lucide-react'
|
||||||
|
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
|
||||||
|
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
|
||||||
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
|
label: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeBadgeProps {
|
||||||
|
type: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||||
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
|
label: type.toUpperCase(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
|
||||||
|
<Disc size={13} />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
|
||||||
|
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
|
||||||
|
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
|
||||||
|
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
|
||||||
|
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
|
||||||
|
md: 'px-4 py-2 text-sm gap-2',
|
||||||
|
lg: 'px-5 py-2.5 text-base gap-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
glow?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||||
|
glow
|
||||||
|
? 'glass-panel-amber'
|
||||||
|
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||||
|
} text-slate-900 dark:text-slate-100 ${
|
||||||
|
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{/* Top subtle highlight line for 3D glass depth */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
|
||||||
|
error ? 'border-rose-500 ring-rose-500/20' : ''
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-rose-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, children, className = '', ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
|
||||||
|
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
|
||||||
|
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const maxWidening = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
'4xl': 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
{/* Glass Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Dialog */}
|
||||||
|
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
action?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
interface RadialProgressProps {
|
||||||
|
progress: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
label?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||||
|
|
||||||
|
const isComplete = progress === 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
{/* Background track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-slate-800 dark:text-slate-800/80 fill-none"
|
||||||
|
/>
|
||||||
|
{/* Progress track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="url(#progressGradient)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="fill-none transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
|
||||||
|
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
|
||||||
|
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,25 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark'
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: Theme
|
theme: 'dark'
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
|
|
||||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
|
||||||
setTheme('dark')
|
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
localStorage.setItem('theme', 'dark')
|
||||||
setTheme('light')
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
// 100% Dark Mode locked per Commander directive
|
||||||
setTheme(newTheme)
|
|
||||||
localStorage.setItem('theme', newTheme)
|
|
||||||
if (newTheme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, ReactNode } from 'react'
|
||||||
|
import { CheckCircle, AlertCircle, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
// Globales Feedback-System (Commander-Wunsch 24.07.): JEDE Aktualisierung im
|
||||||
|
// UI quittiert sichtbar — „Erfolgreich aktualisiert" ploppt oben rechts rein,
|
||||||
|
// statt dass man raten muss, ob der Klick etwas getan hat.
|
||||||
|
|
||||||
|
type ToastTyp = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number
|
||||||
|
typ: ToastTyp
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<{ toast: (typ: ToastTyp, text: string) => void }>({
|
||||||
|
toast: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STIL: Record<ToastTyp, string> = {
|
||||||
|
success: 'bg-emerald-600 text-white',
|
||||||
|
error: 'bg-rose-600 text-white',
|
||||||
|
info: 'bg-slate-800 text-slate-100 dark:bg-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON: Record<ToastTyp, ReactNode> = {
|
||||||
|
success: <CheckCircle size={18} className="flex-shrink-0" />,
|
||||||
|
error: <AlertCircle size={18} className="flex-shrink-0" />,
|
||||||
|
info: <Info size={18} className="flex-shrink-0" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
const naechsteId = useRef(1)
|
||||||
|
|
||||||
|
const toast = useCallback((typ: ToastTyp, text: string) => {
|
||||||
|
const id = naechsteId.current++
|
||||||
|
setToasts(t => [...t.slice(-3), { id, typ, text }])
|
||||||
|
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-80 pointer-events-none">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`toast-rein flex items-center gap-2.5 px-4 py-3 rounded-lg shadow-lg text-sm font-medium ${STIL[t.typ]}`}
|
||||||
|
>
|
||||||
|
{ICON[t.typ]}
|
||||||
|
<span className="min-w-0">{t.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
-45
@@ -2,52 +2,82 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--bg-color: #f8fafc;
|
|
||||||
--bg-secondary: #f1f5f9;
|
|
||||||
--text-primary: #0f172a;
|
|
||||||
--text-secondary: #64748b;
|
|
||||||
--accent-color: #6366f1;
|
|
||||||
--accent-hover: #4f46e5;
|
|
||||||
--border-color: #e2e8f0;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--input-bg: #ffffff;
|
|
||||||
--input-border: #e2e8f0;
|
|
||||||
--success-color: #22c55e;
|
|
||||||
--error-color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg-color: #0f172a;
|
|
||||||
--bg-secondary: #1e293b;
|
|
||||||
--text-primary: #f1f5f9;
|
|
||||||
--text-secondary: #94a3b8;
|
|
||||||
--accent-color: #818cf8;
|
|
||||||
--accent-hover: #6366f1;
|
|
||||||
--border-color: #334155;
|
|
||||||
--card-bg: #1e293b;
|
|
||||||
--input-bg: #0f172a;
|
|
||||||
--input-border: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
@apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
background-image:
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||||
sans-serif;
|
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
-webkit-font-smoothing: antialiased;
|
background-attachment: fixed;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
}
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
/* Toast Einflug */
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
@keyframes toast-rein {
|
||||||
monospace;
|
from {
|
||||||
background-color: var(--bg-secondary);
|
opacity: 0;
|
||||||
color: var(--text-primary);
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-rein {
|
||||||
|
animation: toast-rein 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtile Disc-Rotation für Cinematic Spotlight */
|
||||||
|
@keyframes disc-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-disc-spin {
|
||||||
|
animation: disc-spin 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Glass & Shimmer Effects */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-amber {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metallic Disc Texture Gradient */
|
||||||
|
.disc-texture {
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
#1e293b 0deg,
|
||||||
|
#475569 45deg,
|
||||||
|
#1e293b 90deg,
|
||||||
|
#64748b 135deg,
|
||||||
|
#1e293b 180deg,
|
||||||
|
#475569 225deg,
|
||||||
|
#1e293b 270deg,
|
||||||
|
#64748b 315deg,
|
||||||
|
#1e293b 360deg
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// Alle API-Aufrufe laufen relativ über /api — nginx im UI-Container leitet an
|
||||||
|
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||||
|
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||||
|
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
||||||
|
export const api = axios.create({ baseURL: '/api' })
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Centralized Design System & Style Tokens for Rippy (Cinematic Cinema OS)
|
||||||
|
|
||||||
|
export const STATUS_STYLES = {
|
||||||
|
pending: {
|
||||||
|
badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||||
|
label: 'Wartend',
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30 animate-pulse',
|
||||||
|
label: 'Rippen',
|
||||||
|
},
|
||||||
|
transcoding: {
|
||||||
|
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||||
|
label: 'Komprimieren',
|
||||||
|
},
|
||||||
|
canceling: {
|
||||||
|
badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||||
|
label: 'Wird abgebrochen…',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
|
||||||
|
label: 'Fertig',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30',
|
||||||
|
label: 'Fehler',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const DISC_TYPE_STYLES = {
|
||||||
|
cd: {
|
||||||
|
badge: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-indigo-500/30',
|
||||||
|
aura: 'from-indigo-500/20 via-indigo-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(99,102,241,0.2)]',
|
||||||
|
label: 'CD',
|
||||||
|
},
|
||||||
|
dvd: {
|
||||||
|
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||||
|
aura: 'from-purple-500/20 via-purple-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(168,85,247,0.2)]',
|
||||||
|
label: 'DVD',
|
||||||
|
},
|
||||||
|
bluray: {
|
||||||
|
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
||||||
|
aura: 'from-sky-500/20 via-sky-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(14,165,233,0.2)]',
|
||||||
|
label: 'BLU-RAY',
|
||||||
|
},
|
||||||
|
uhd: {
|
||||||
|
badge: 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40 font-semibold shadow-sm shadow-amber-500/20',
|
||||||
|
aura: 'from-amber-500/25 via-amber-900/15 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_25px_-5px_rgba(245,158,11,0.25)]',
|
||||||
|
label: '4K UHD',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const LOG_LEVEL_STYLES = {
|
||||||
|
INFO: {
|
||||||
|
color: 'text-sky-600 dark:text-sky-400',
|
||||||
|
bg: 'bg-sky-500/10',
|
||||||
|
border: 'border-sky-500/20',
|
||||||
|
},
|
||||||
|
WARNING: {
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
border: 'border-amber-500/20',
|
||||||
|
},
|
||||||
|
ERROR: {
|
||||||
|
color: 'text-rose-600 dark:text-rose-400',
|
||||||
|
bg: 'bg-rose-500/10',
|
||||||
|
border: 'border-rose-500/20',
|
||||||
|
},
|
||||||
|
DEBUG: {
|
||||||
|
color: 'text-slate-500 dark:text-slate-400',
|
||||||
|
bg: 'bg-slate-500/10',
|
||||||
|
border: 'border-slate-500/20',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
||||||
|
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||||
|
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
||||||
|
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||||
|
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die
|
||||||
|
// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der
|
||||||
|
// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung).
|
||||||
|
export interface MediaServerOption {
|
||||||
|
wert: string
|
||||||
|
label: string
|
||||||
|
hinweis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [
|
||||||
|
{ wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' },
|
||||||
|
{ wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' },
|
||||||
|
]
|
||||||
@@ -2,12 +2,15 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { ThemeProvider } from './context/ThemeContext'
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import { ToastProvider } from './context/ToastContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
|
|
||||||
|
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
|
<Icon size={20} className="text-amber-500" />
|
||||||
|
<span>{titel}</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnleitungPage() {
|
||||||
|
const fett = "font-semibold text-slate-100"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
|
<PageHeader
|
||||||
|
title="Anleitung & Hilfe"
|
||||||
|
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
|
||||||
|
<p>
|
||||||
|
<span className={fett}>1. Disc einlegen.</span> Rippy erkennt sie automatisch (Titel, Poster,
|
||||||
|
Beschreibung) und zeigt sie oben im Dashboard. Stimmt der Titel nicht → „Nicht korrekt?"
|
||||||
|
klicken und den richtigen wählen — Rippy merkt sich das für diese Disc.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>2. „Rippen starten"</span> und das Ziel wählen: 🎥 Filme, 📺 Serien,
|
||||||
|
🎵 Musik oder ein eigener Ordner (auch NAS-Freigaben). Optional: „Nur Hauptfilm" (längster
|
||||||
|
Titel) oder per <span className={fett}>„Disc scannen"</span> einzelne Titel an-/abwählen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>3. Warten.</span> Rippy sichert verlustfrei (MakeMKV) und komprimiert
|
||||||
|
danach auf Arbeitsgröße (HandBrake) — alle Tonspuren und Untertitel bleiben erhalten.
|
||||||
|
Fertige Jobs haben einen Download-Knopf; Klick auf den Titel zeigt alle Details.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Vollautomatik:</span> Unter Einstellungen → Ripping einschaltbar —
|
||||||
|
dann startet der Rip ohne Nachfrage, sobald eine Disc erkannt wurde. Bereits gerippte
|
||||||
|
Discs (Fingerabdruck) werden übersprungen.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Tv} titel="Serien rippen">
|
||||||
|
<p>
|
||||||
|
Im „Rippen starten"-Dialog <span className={fett}>„Serien"</span> wählen, Serienname und
|
||||||
|
Staffel angeben. Die Ablage wird automatisch <code>Serie/Season 02/…</code>, und nach dem
|
||||||
|
Rip ordnet Rippy die Episoden über einen Laufzeit-Abgleich mit TMDB zu und benennt sie
|
||||||
|
(„Serie S02E03.mkv"). Das passiert nur bei <span className={fett}>eindeutiger</span> Zuordnung —
|
||||||
|
im Zweifel bleiben die Original-Namen und das Log sagt warum.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Eine komplette Staffel auf einer Disc klappt auch, wenn alle Episoden gleich lang sind
|
||||||
|
(Anime-Boxen). Bei Teil-Discs mit lauter gleich langen Episoden ist eine automatische
|
||||||
|
Zuordnung prinzipiell nicht möglich — dann von Hand umbenennen.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={HardDrive} titel="NAS & Speicherziele">
|
||||||
|
<p>
|
||||||
|
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||||
|
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||||
|
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||||
|
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
||||||
|
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
||||||
|
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
||||||
|
jedem Rip und bricht sonst mit Klartext ab.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Tv} titel="Jellyfin, Emby, Kodi, Plex">
|
||||||
|
<p>
|
||||||
|
Einstellungen → Ripping → Media-Server: Rippy benennt fertige Rips als „Titel (Jahr)" und
|
||||||
|
legt für Jellyfin/Emby/Kodi zusätzlich <code>movie.nfo</code> + <code>poster.jpg</code> dazu —
|
||||||
|
dein Server erkennt alles ohne Nacharbeit (Plex nutzt nur die Benennung).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Null-Klick-Kette:</span> Bei Jellyfin/Emby zusätzlich Server-URL und
|
||||||
|
API-Key eintragen (Administration → API-Schlüssel) — dann stößt Rippy nach jedem fertigen
|
||||||
|
Rip sofort einen Bibliotheks-Scan an. Disc rein → Film erscheint im Server.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Bell} titel="Benachrichtigungen">
|
||||||
|
<p>
|
||||||
|
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen (Discord-Webhook, ntfy-Thema
|
||||||
|
für Handy-Push, Slack oder eigenes JSON), „Test senden", speichern. Rippy meldet sich, wenn
|
||||||
|
ein Job fertig ist, fehlschlägt oder abgebrochen wird. Die Schritt-für-Schritt-Anleitung je
|
||||||
|
Dienst steht direkt auf der Einstellungs-Seite.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||||
|
<p>
|
||||||
|
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||||
|
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
|
||||||
|
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
|
||||||
|
„Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
|
||||||
|
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
|
||||||
|
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||||
|
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
||||||
|
(Status, Start/Stopp, „Rippy öffnen", Log) — gestartet über <code>start-tray.bat</code>.
|
||||||
|
Deinstallieren: <code>uninstall.ps1</code> im Worker-Ordner stoppt alles, entfernt den
|
||||||
|
Autostart, meldet den Worker in Rippy ab und löscht den Ordner rückstandsfrei.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Wichtig:</span> Worker verbinden sich direkt im Heimnetz — in den
|
||||||
|
Befehlen immer die <span className={fett}>LAN-Adresse</span> der Rippy-Maschine verwenden,
|
||||||
|
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||||
|
<p>
|
||||||
|
Blu-ray-Ripping braucht den MakeMKV-Beta-Key, der etwa <span className={fett}>monatlich
|
||||||
|
wechselt</span> (MakeMKV-Forum, Thread „MakeMKV is free while in beta"). Neuen Key unter
|
||||||
|
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
|
||||||
|
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen →
|
||||||
|
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
|
||||||
|
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
|
||||||
|
Update-Befehl an — wichtig, weil neue Versionen auch die neueste
|
||||||
|
Disc-Schlüssel-Datenbank mitbringen.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={Download} titel="Fertige Dateien herunterladen">
|
||||||
|
<p>
|
||||||
|
Bei fertigen Jobs: „Download" in der Aktion-Spalte (oder Klick auf den Titel) → Dateiliste
|
||||||
|
mit „Alle herunterladen". Die Job-Liste lässt sich per Papierkorb bzw. „Erledigte aufräumen"
|
||||||
|
leeren — <span className={fett}>die gerippten Dateien bleiben dabei immer liegen</span>.
|
||||||
|
„CSV" exportiert die komplette Historie für Excel.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<Abschnitt icon={HelpCircle} titel="Häufige Fragen">
|
||||||
|
<p>
|
||||||
|
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
||||||
|
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>4K-UHD schlägt fehl mit „volume key is unknown"?</span> Das Laufwerk
|
||||||
|
liest die Disc (LibreDrive), aber MakeMKV kennt den Schlüssel dieser (zu neuen) Pressung
|
||||||
|
noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
|
||||||
|
der nächsten Updates ist die Disc rippbar.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
||||||
|
erscheint nur, wenn die Rohdaten noch daliegen — bei einem Job, der nie gerippt hat, gäbe
|
||||||
|
es nichts zu komprimieren.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Kein Laufwerk sichtbar?</span> In einer VM muss das Laufwerk per
|
||||||
|
USB-Passthrough durchgereicht sein (nicht als emuliertes CD-ROM) — Details in der README.
|
||||||
|
</p>
|
||||||
|
</Abschnitt>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+459
-326
@@ -1,27 +1,48 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } 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 { useDarkMode } from '../context/ThemeContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
|
||||||
|
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
|
||||||
type: 'cd' | 'dvd' | 'bluray'
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
|
||||||
device: string
|
device: string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Device {
|
interface SystemInfo {
|
||||||
id: string
|
api_version: string
|
||||||
name: string
|
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
||||||
type: 'cd' | 'dvd' | 'bluray'
|
workers: { name: string; encoders: string[] }[]
|
||||||
path: string
|
}
|
||||||
status: 'empty' | 'ready' | 'ripping'
|
|
||||||
|
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,388 +54,500 @@ async function fetchJobs(): Promise<Job[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDevices(): Promise<Device[]> {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/devices')
|
|
||||||
return response.data
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const styles = theme === 'dark'
|
|
||||||
? {
|
|
||||||
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
|
||||||
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
|
|
||||||
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
|
|
||||||
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
|
|
||||||
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
|
||||||
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
|
|
||||||
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
pending: 'Wartend',
|
|
||||||
processing: 'Bearbeitung',
|
|
||||||
completed: 'Fertig',
|
|
||||||
failed: 'Fehler',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ring-1 ring-inset ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-700 ring-gray-600/10'}`}>
|
|
||||||
{labels[status as keyof typeof labels] || status}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const colors = theme === 'dark'
|
|
||||||
? 'bg-slate-800 text-slate-300'
|
|
||||||
: 'bg-slate-100 text-slate-700'
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
cd: Disc,
|
|
||||||
dvd: Disc,
|
|
||||||
bluray: Disc,
|
|
||||||
}
|
|
||||||
const Icon = icons[type as keyof typeof icons] || Disc
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors}`}>
|
|
||||||
<Icon size={14} />
|
|
||||||
{type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const bgColors = theme === 'dark'
|
|
||||||
? {
|
|
||||||
empty: 'bg-slate-700',
|
|
||||||
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
|
|
||||||
done: 'bg-emerald-500',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
empty: 'bg-slate-200',
|
|
||||||
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
|
|
||||||
done: 'bg-emerald-500',
|
|
||||||
}
|
|
||||||
|
|
||||||
const textColors = theme === 'dark'
|
|
||||||
? {
|
|
||||||
label: 'text-slate-400',
|
|
||||||
value: 'text-slate-200',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
label: 'text-slate-500',
|
|
||||||
value: 'text-slate-900',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-32">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className={`text-xs font-medium ${textColors.label}`}>Fortschritt</span>
|
|
||||||
<span className={`text-xs font-medium ${textColors.value}`}>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={`w-full ${bgColors.empty} rounded-full h-2 overflow-hidden`}>
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-500 ease-out ${
|
|
||||||
progress === 100 ? bgColors.done : bgColors.indicator
|
|
||||||
}`}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { theme } = useDarkMode()
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
|
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const jobLoeschen = async (job: Job) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/jobs/${job.id}`)
|
||||||
|
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
||||||
|
setJobs(await fetchJobs())
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const erledigteAufraeumen = async () => {
|
||||||
|
setAufraeumenOffen(false)
|
||||||
|
try {
|
||||||
|
const r = await api.delete('/jobs')
|
||||||
|
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||||
|
setJobs(await fetchJobs())
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Aufräumen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, devicesData] = await Promise.all([
|
const [jobsData, sysData] = await Promise.all([
|
||||||
fetchJobs(),
|
fetchJobs(),
|
||||||
fetchDevices()
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
])
|
])
|
||||||
setJobs(jobsData)
|
setJobs(jobsData)
|
||||||
setDevices(devicesData)
|
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()
|
||||||
const interval = setInterval(loadData, 5000)
|
const interval = setInterval(loadData, 4000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stats = {
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
pending: jobs.filter(j => j.status === 'pending').length,
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
processing: jobs.filter(j => j.status === 'processing').length,
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
completed: jobs.filter(j => j.status === 'completed').length,
|
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||||
failed: jobs.filter(j => j.status === 'failed').length,
|
|
||||||
}
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
|
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||||
|
const workerCount = systemInfo?.workers?.length || 1
|
||||||
|
|
||||||
|
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||||
|
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||||
|
const y = 50 - (val / 100) * 40
|
||||||
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter(j => {
|
||||||
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
|
if (activeTab === 'queue') return j.status === 'pending'
|
||||||
|
if (activeTab === 'completed') return j.status === 'completed'
|
||||||
|
if (activeTab === 'failed') return j.status === 'failed'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 transition-colors duration-300">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
|
||||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Dashboard</h1>
|
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device Discovery Section */}
|
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
|
||||||
<DeviceDiscovery />
|
<DeviceDiscovery />
|
||||||
|
|
||||||
{/* Live Log Section */}
|
{/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
|
||||||
<LiveLogSection />
|
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
|
||||||
|
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300">
|
|
||||||
<StatCard
|
|
||||||
icon={Clock}
|
|
||||||
label="Warteschlange"
|
|
||||||
value={stats.pending.toString()}
|
|
||||||
color="amber"
|
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={Activity}
|
|
||||||
label="Bearbeitung"
|
|
||||||
value={stats.processing.toString()}
|
|
||||||
color="blue"
|
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={CheckCircle}
|
|
||||||
label="Fertig"
|
|
||||||
value={stats.completed.toString()}
|
|
||||||
color="emerald"
|
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={AlertCircle}
|
|
||||||
label="Fehler"
|
|
||||||
value={stats.failed.toString()}
|
|
||||||
color="rose"
|
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className={`rounded-xl shadow-sm border p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<h2 className={`text-lg font-semibold mb-4 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Schnellaktionen</h2>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
|
|
||||||
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-indigo-500 transition-colors duration-300">
|
|
||||||
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'} group-hover:bg-indigo-200`}>
|
|
||||||
<Disc size={24} />
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Neuer Job</span>
|
|
||||||
</button>
|
|
||||||
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-emerald-500 transition-colors duration-300">
|
|
||||||
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'} group-hover:bg-emerald-200`}>
|
|
||||||
<Play size={24} />
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Alle starten</span>
|
|
||||||
</button>
|
|
||||||
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-amber-500 transition-colors duration-300">
|
|
||||||
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-600'} group-hover:bg-amber-200`}>
|
|
||||||
<Pause size={24} />
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Pausieren</span>
|
|
||||||
</button>
|
|
||||||
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-rose-500 transition-colors duration-300">
|
|
||||||
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-rose-900/30 text-rose-400' : 'bg-rose-100 text-rose-600'} group-hover:bg-rose-200`}>
|
|
||||||
<SkipForward size={24} />
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Überspringen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Devices */}
|
|
||||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Geräte</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 transition-colors duration-300">
|
|
||||||
{devices.length === 0 ? (
|
|
||||||
<div className="text-center py-12 transition-colors duration-300">
|
|
||||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
|
|
||||||
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
|
||||||
</div>
|
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Geräte gefunden</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-colors duration-300">
|
|
||||||
{devices.map(device => (
|
|
||||||
<div key={device.id} className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
|
||||||
<div className="flex items-start justify-between mb-3 transition-colors duration-300">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${
|
<CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
|
||||||
device.type === 'cd' ? theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600' :
|
<Layers size={22} className="text-amber-400" />
|
||||||
device.type === 'dvd' ? theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600' :
|
Ripping Operations & Medien-Manager
|
||||||
theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
|
</CardTitle>
|
||||||
}`}>
|
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{device.name}</h3>
|
|
||||||
<p className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{device.path}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TypeBadge type={device.type} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4 transition-colors duration-300">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-300 ${
|
{/* Master Filter Tabs */}
|
||||||
device.status === 'empty' ? theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600' :
|
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||||||
device.status === 'ready' ? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' :
|
<button
|
||||||
theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
onClick={() => setActiveTab('all')}
|
||||||
}`}>
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
{device.status === 'empty' ? 'Leer' :
|
activeTab === 'all'
|
||||||
device.status === 'ready' ? 'Bereit' :
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
'Rippt'}
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
</span>
|
}`}
|
||||||
<button className={`text-sm font-medium transition-colors duration-300 ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}>
|
>
|
||||||
Details
|
<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>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="/api/jobs/export"
|
||||||
|
download
|
||||||
|
title="Job-Historie als CSV herunterladen"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet size={14} />
|
||||||
|
CSV
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{completedJobs.length > 0 && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||||
|
<Trash2 size={13} /> Aufräumen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Live Rip Banner inside Panel when a Job is active */}
|
||||||
|
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
|
||||||
|
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{posterUrl(aktiverJob.meta) ? (
|
||||||
|
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
|
||||||
|
<Disc size={32} className="text-amber-400 animate-disc-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
|
||||||
|
AKTIVER RIP IN ARBEIT
|
||||||
|
</span>
|
||||||
|
<TypeBadge type={aktiverJob.type} />
|
||||||
|
<StatusBadge status={aktiverJob.status} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">
|
||||||
|
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Jobs */}
|
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<div className="w-36 space-y-1 text-right">
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
|
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
|
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto transition-colors duration-300">
|
</div>
|
||||||
<table className="w-full transition-colors duration-300">
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
<thead className={theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}>
|
Details
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unified Jobs Table */}
|
||||||
|
{filteredJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
|
||||||
|
<Disc size={32} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</th>
|
<th className="px-6 py-3.5">Typ</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Titel</th>
|
<th className="px-6 py-3.5">Titel & Inhaltsangabe</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Status</th>
|
<th className="px-6 py-3.5">Status & Phase</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Gerät</th>
|
<th className="px-6 py-3.5">Laufwerk</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fortschritt</th>
|
<th className="px-6 py-3.5">Fortschritt</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Startzeit</th>
|
<th className="px-6 py-3.5">Startzeit</th>
|
||||||
|
<th className="px-6 py-3.5 text-right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className={`divide-y ${theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200'}`}>
|
<tbody className="divide-y divide-slate-800/80 text-sm">
|
||||||
{jobs.length === 0 ? (
|
{filteredJobs.map(job => {
|
||||||
<tr>
|
const poster = posterUrl(job.meta)
|
||||||
<td colSpan={6} className="px-6 py-12 text-center transition-colors duration-300">
|
return (
|
||||||
<div className={`mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-400'}`}>Noch keine Jobs</div>
|
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
jobs.slice(0, 10).map(job => (
|
|
||||||
<tr key={job.id} className="hover:bg-slate-50 transition-colors duration-300">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4">
|
||||||
<div className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="flex items-center gap-3">
|
||||||
{job.title || 'Unbekannt'}
|
{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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
{job.device}
|
{job.device}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<ProgressBar progress={job.progress} />
|
<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>
|
||||||
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
{new Date(job.startTime).toLocaleString('de-DE')}
|
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
)
|
</Card>
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) {
|
{/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
|
||||||
const { theme } = useDarkMode()
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
const bgColors = darkMode
|
|
||||||
? {
|
|
||||||
card: 'bg-slate-800 border-slate-700',
|
|
||||||
iconBg: {
|
|
||||||
amber: 'bg-amber-700',
|
|
||||||
blue: 'bg-blue-700',
|
|
||||||
emerald: 'bg-emerald-700',
|
|
||||||
rose: 'bg-rose-700',
|
|
||||||
},
|
|
||||||
iconText: {
|
|
||||||
amber: 'text-amber-300',
|
|
||||||
blue: 'text-blue-300',
|
|
||||||
emerald: 'text-emerald-300',
|
|
||||||
rose: 'text-rose-300',
|
|
||||||
},
|
|
||||||
textLabel: 'text-slate-400',
|
|
||||||
textValue: 'text-slate-200',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
card: 'bg-white border-slate-200',
|
|
||||||
iconBg: {
|
|
||||||
amber: 'bg-amber-500',
|
|
||||||
blue: 'bg-blue-500',
|
|
||||||
emerald: 'bg-emerald-500',
|
|
||||||
rose: 'bg-rose-500',
|
|
||||||
},
|
|
||||||
iconText: {
|
|
||||||
amber: 'text-amber-600',
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
emerald: 'text-emerald-600',
|
|
||||||
rose: 'text-rose-600',
|
|
||||||
},
|
|
||||||
textLabel: 'text-slate-500',
|
|
||||||
textValue: 'text-slate-900',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Left: Server Status Card */}
|
||||||
<div className={`${bgColors.card} rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow`}>
|
<div className="lg:col-span-7">
|
||||||
<div className="flex items-center gap-4">
|
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
|
||||||
<div className={`${bgColors.iconBg[color as keyof typeof bgColors.iconBg]} p-3 rounded-lg`}>
|
<CardHeader className="border-b border-slate-800">
|
||||||
<Icon className={`${bgColors.iconText[color as keyof typeof bgColors.iconText]} w-6 h-6`} />
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
</div>
|
<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>
|
||||||
<p className={`text-sm font-medium ${bgColors.textLabel}`}>{label}</p>
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<p className={`text-3xl font-bold ${bgColors.textValue}`}>{value}</p>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* POS 4: Live-Log Section */}
|
||||||
|
<LiveLogSection />
|
||||||
|
|
||||||
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={aufraeumenOffen}
|
||||||
|
onClose={() => setAufraeumenOffen(false)}
|
||||||
|
onConfirm={erledigteAufraeumen}
|
||||||
|
title="Job-Liste aufräumen?"
|
||||||
|
message="Alle fertigen und fehlgeschlagenen Jobs verschwinden aus der Liste. Die gerippten Dateien bleiben selbstverständlich liegen."
|
||||||
|
confirmText="Aufräumen"
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,18 +19,21 @@ export default function LogsPage() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { theme } = useDarkMode()
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshLogs = async () => {
|
const refreshLogs = async (manuell = false) => {
|
||||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
if (manuell) setRefreshing(true)
|
||||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||||
setLogs(response.data)
|
setLogs(response.data)
|
||||||
|
if (manuell) toast('success', 'Logs aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Logs:', error)
|
console.error('Fehler beim Laden der Logs:', error)
|
||||||
|
if (manuell) toast('error', 'Logs konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,19 +45,19 @@ export default function LogsPage() {
|
|||||||
|
|
||||||
const getLevelColor = (level: string) => {
|
const getLevelColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20'
|
case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20'
|
case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||||
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20'
|
case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20'
|
default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLevelIcon = (level: string) => {
|
const getLevelIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return <AlertCircle size={16} />
|
case 'error': return <AlertCircle size={15} />
|
||||||
case 'warning': return <AlertCircle size={16} />
|
case 'warning': return <AlertCircle size={15} />
|
||||||
case 'success': return <CheckCircle size={16} />
|
case 'success': return <CheckCircle size={15} />
|
||||||
default: return <Terminal size={16} />
|
default: return <Terminal size={15} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,133 +82,121 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 transition-colors duration-300">
|
<div className="space-y-6 max-w-5xl mx-auto">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
title="System-Logs"
|
||||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1>
|
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p>
|
action={
|
||||||
</div>
|
<Button variant="secondary" onClick={() => refreshLogs(true)}>
|
||||||
|
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
<div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
|
||||||
<Terminal size={18} />
|
<Terminal size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
|
||||||
<AlertCircle size={18} />
|
<AlertCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
|
||||||
<CheckCircle size={18} />
|
<CheckCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
|
||||||
<AlertCircle size={18} />
|
<AlertCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span>
|
<div>
|
||||||
</div>
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p>
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters & Log List */}
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card className="overflow-hidden">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span>
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
|
||||||
|
{['all', 'info', 'success', 'warning', 'error'].map(f => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('all')}
|
key={f}
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
onClick={() => setFilter(f)}
|
||||||
filter === 'all'
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||||
? 'bg-indigo-600 text-white'
|
filter === f
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/20'
|
||||||
|
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Alle
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
))}
|
||||||
onClick={() => setFilter('info')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'info'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Info
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('success')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'success'
|
|
||||||
? 'bg-emerald-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Erfolg
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={refreshLogs}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
|
||||||
Neu laden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs List */}
|
|
||||||
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
{filteredLogs.length === 0 ? (
|
{filteredLogs.length === 0 ? (
|
||||||
<div className="text-center py-12 transition-colors duration-300">
|
<div className="text-center py-16">
|
||||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||||
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
<Terminal size={32} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
<div className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
||||||
{filteredLogs.map(log => (
|
{filteredLogs.map(log => (
|
||||||
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}>
|
<div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
|
||||||
{getLevelIcon(log.level)}
|
{getLevelIcon(log.level)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||||
{log.source}
|
{log.source}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
{log.timestamp}
|
{log.timestamp}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}>
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
|
||||||
{getLevelLabel(log.level)}
|
{getLevelLabel(log.level)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
{log.message}
|
{log.message}
|
||||||
</p>
|
</p>
|
||||||
{log.details && (
|
{log.details && (
|
||||||
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
<p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
|
||||||
{log.details}
|
{log.details}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +207,7 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { api } from '../lib/api'
|
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
|
|
||||||
interface DeviceOption {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Metadata {
|
|
||||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
overview?: string
|
|
||||||
poster_path?: string
|
|
||||||
backdrop_path?: string
|
|
||||||
runtime?: number
|
|
||||||
genres?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Track {
|
|
||||||
track_number: number
|
|
||||||
title: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PrescanResult {
|
|
||||||
disc_type: 'cd' | 'dvd' | 'bluray'
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
confidence: number
|
|
||||||
metadata?: Metadata
|
|
||||||
tracks?: Track[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MetadataPreview() {
|
|
||||||
const [device, setDevice] = useState<string>('')
|
|
||||||
const [devices, setDevices] = useState<DeviceOption[]>([])
|
|
||||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [confirming, setConfirming] = useState(false)
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
|
|
||||||
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDevices = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/devices')
|
|
||||||
setDevices(response.data)
|
|
||||||
if (response.data.length === 1) {
|
|
||||||
setDevice(response.data[0].path)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setDevices([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadDevices()
|
|
||||||
const interval = setInterval(loadDevices, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleScan = async () => {
|
|
||||||
if (!device) {
|
|
||||||
setError('Bitte wählen Sie ein Gerät aus.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post('/prescan', { device_path: device })
|
|
||||||
setResult(response.data)
|
|
||||||
} catch (err) {
|
|
||||||
setError('Pre-Scan fehlgeschlagen.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
setConfirming(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post('/metadata/confirm', {
|
|
||||||
title: result.title,
|
|
||||||
year: result.year,
|
|
||||||
metadata: result.metadata
|
|
||||||
})
|
|
||||||
|
|
||||||
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
|
|
||||||
await api.post('/jobs', {
|
|
||||||
device_path: device,
|
|
||||||
title: result.title
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
setError('Bestätigung fehlgeschlagen.')
|
|
||||||
} finally {
|
|
||||||
setConfirming(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = (type: string): LucideIcon => {
|
|
||||||
switch (type) {
|
|
||||||
case 'cd':
|
|
||||||
return Music
|
|
||||||
case 'dvd':
|
|
||||||
case 'bluray':
|
|
||||||
return Film
|
|
||||||
default:
|
|
||||||
return BookOpen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number): string => {
|
|
||||||
if (confidence >= 0.9) return theme === 'dark' ? 'text-green-400 bg-green-900/30' : 'text-green-600 bg-green-100'
|
|
||||||
if (confidence >= 0.7) return theme === 'dark' ? 'text-yellow-400 bg-yellow-900/30' : 'text-yellow-600 bg-yellow-100'
|
|
||||||
return theme === 'dark' ? 'text-red-400 bg-red-900/30' : 'text-red-600 bg-red-100'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-4xl mx-auto transition-colors duration-300">
|
|
||||||
<div className="mb-8 transition-colors duration-300">
|
|
||||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Metadaten-Preview</h1>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device Selection */}
|
|
||||||
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
|
||||||
<div className="flex gap-4 transition-colors duration-300">
|
|
||||||
<select
|
|
||||||
value={device}
|
|
||||||
onChange={(e) => setDevice(e.target.value)}
|
|
||||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
|
|
||||||
>
|
|
||||||
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
|
||||||
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
|
|
||||||
</option>
|
|
||||||
{devices.map((d) => (
|
|
||||||
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
|
||||||
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleScan}
|
|
||||||
disabled={!device || loading}
|
|
||||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white transition-colors ${
|
|
||||||
!device || loading
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin" size={20} />
|
|
||||||
Scannen...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Search size={20} />
|
|
||||||
Scan starten
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 transition-colors duration-300 ${theme === 'dark' ? 'bg-red-900/30 border border-red-700 text-red-400' : 'bg-red-50 border border-red-200 text-red-700'}`}>
|
|
||||||
<AlertCircle size={24} />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{result && (
|
|
||||||
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
{getIcon(result.disc_type)({ size: 32 })}
|
|
||||||
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
|
||||||
{result.disc_type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold">{result.title}</h2>
|
|
||||||
{result.year && (
|
|
||||||
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confidence Score */}
|
|
||||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-b border-gray-200'}`}>
|
|
||||||
<div className="flex items-center gap-2 transition-colors duration-300">
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>Übereinstimmung:</span>
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${getConfidenceColor(result.confidence)}`}>
|
|
||||||
{Math.round(result.confidence * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="p-6 transition-colors duration-300">
|
|
||||||
{result.metadata && (
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
|
||||||
{result.metadata.overview && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Beschreibung</h3>
|
|
||||||
<p className={`leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{result.metadata.overview}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.metadata.genres && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
|
|
||||||
<div className="flex flex-wrap gap-2 transition-colors duration-300">
|
|
||||||
{result.metadata.genres.map((genre, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-700'}`}
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.metadata.runtime && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
|
|
||||||
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tracks */}
|
|
||||||
{result.tracks && result.tracks.length > 0 && (
|
|
||||||
<div className="mt-6 transition-colors duration-300">
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Tracks</h3>
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
|
||||||
{result.tracks.map((track, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 transition-colors duration-300">
|
|
||||||
<div className="flex items-center gap-3 transition-colors duration-300">
|
|
||||||
<span className={`w-8 font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>{track.track_number}</span>
|
|
||||||
<span className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-gray-900'}`}>{track.title}</span>
|
|
||||||
</div>
|
|
||||||
{track.duration && (
|
|
||||||
<span className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
||||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 border-t border-slate-700' : 'bg-gray-50 border-t border-gray-200'}`}>
|
|
||||||
<div className="flex items-center justify-between transition-colors duration-300">
|
|
||||||
<div className="flex items-center gap-3 text-sm transition-colors duration-300">
|
|
||||||
<CheckCircle className={theme === 'dark' ? 'text-green-400' : 'text-green-600'} size={20} />
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-gray-600'}>Bereit zum Ripping</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={confirming}
|
|
||||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin" size={20} />
|
|
||||||
Bestätigen...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle size={20} />
|
|
||||||
Rippen starten
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+579
-200
@@ -1,11 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import StorageMounts from '../components/StorageMounts'
|
||||||
|
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
tmdbApiKey: string
|
tmdbApiKey: string
|
||||||
tvdbApiKey: string
|
tvdbApiKey: string
|
||||||
|
omdbApiKey: string
|
||||||
outputDir: string
|
outputDir: string
|
||||||
movieDir: string
|
movieDir: string
|
||||||
seriesDir: string
|
seriesDir: string
|
||||||
@@ -13,12 +22,22 @@ interface SettingsState {
|
|||||||
ripAllTracks: boolean
|
ripAllTracks: boolean
|
||||||
mainFeatureOnly: boolean
|
mainFeatureOnly: boolean
|
||||||
autoEject: boolean
|
autoEject: boolean
|
||||||
|
autoRipStart: boolean
|
||||||
notificationWebhook: string
|
notificationWebhook: string
|
||||||
|
transcodeEnabled: boolean
|
||||||
|
transcodePreset: string
|
||||||
|
keepOriginal: boolean
|
||||||
|
mediaServer: string
|
||||||
|
jellyfinUrl: string
|
||||||
|
jellyfinApiKey: string
|
||||||
|
workDir: string
|
||||||
|
makemkvAppKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
tmdbApiKey: '',
|
tmdbApiKey: '',
|
||||||
tvdbApiKey: '',
|
tvdbApiKey: '',
|
||||||
|
omdbApiKey: '',
|
||||||
outputDir: '/app/media',
|
outputDir: '/app/media',
|
||||||
movieDir: 'movies',
|
movieDir: 'movies',
|
||||||
seriesDir: 'series',
|
seriesDir: 'series',
|
||||||
@@ -26,21 +45,111 @@ const defaultSettings: SettingsState = {
|
|||||||
ripAllTracks: true,
|
ripAllTracks: true,
|
||||||
mainFeatureOnly: false,
|
mainFeatureOnly: false,
|
||||||
autoEject: true,
|
autoEject: true,
|
||||||
|
autoRipStart: false,
|
||||||
notificationWebhook: '',
|
notificationWebhook: '',
|
||||||
|
transcodeEnabled: true,
|
||||||
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
|
keepOriginal: false,
|
||||||
|
mediaServer: 'none',
|
||||||
|
jellyfinUrl: '',
|
||||||
|
jellyfinApiKey: '',
|
||||||
|
workDir: '',
|
||||||
|
makemkvAppKey: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||||
|
|
||||||
|
interface WorkerInfo {
|
||||||
|
name: string
|
||||||
|
encoders: string[]
|
||||||
|
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
api_version: string
|
||||||
|
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
|
||||||
|
workers: WorkerInfo[]
|
||||||
|
makemkv_key_ui: boolean
|
||||||
|
webhook_gesetzt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
|
const [quellenStatus, setQuellenStatus] = useState<Record<string, string> | null>(null)
|
||||||
|
const [quellenBusy, setQuellenBusy] = useState(false)
|
||||||
|
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||||||
|
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const updatesPruefen = async () => {
|
||||||
|
setUpdatesBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.get('/system/updates')
|
||||||
|
setUpdates(r.data)
|
||||||
|
if (r.data?.makemkv?.update) {
|
||||||
|
toast('info', `MakeMKV ${r.data.makemkv.verfuegbar} ist verfügbar (installiert: ${r.data.makemkv.installiert})`)
|
||||||
|
} else {
|
||||||
|
toast('success', 'Update-Check abgeschlossen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Update-Check fehlgeschlagen (makemkv.com/GitHub nicht erreichbar?)')
|
||||||
|
} finally {
|
||||||
|
setUpdatesBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quellenPruefen = async () => {
|
||||||
|
setQuellenBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.get('/metadata/status')
|
||||||
|
setQuellenStatus(r.data)
|
||||||
|
const kaputt = Object.entries(r.data).filter(([, v]) => v === 'fehler')
|
||||||
|
if (kaputt.length === 0) {
|
||||||
|
toast('success', 'Metadaten-Quellen geprüft — alles in Ordnung')
|
||||||
|
} else {
|
||||||
|
toast('error', `Quelle mit Problem: ${kaputt.map(([k]) => k.toUpperCase()).join(', ')}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Prüfung fehlgeschlagen — läuft die API?')
|
||||||
|
} finally {
|
||||||
|
setQuellenBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/capabilities')
|
||||||
|
.then(r => setWorkers(r.data.workers || []))
|
||||||
|
.catch(() => setWorkers([]))
|
||||||
|
api.get('/system/info')
|
||||||
|
.then(r => setSystemInfo(r.data))
|
||||||
|
.catch(() => setSystemInfo(null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const webhookTesten = async () => {
|
||||||
|
if (!settings.notificationWebhook) return
|
||||||
|
setTestBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/test', { url: settings.notificationWebhook })
|
||||||
|
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setTestBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/settings')
|
const response = await api.get('/settings')
|
||||||
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
|
|
||||||
setSettings({ ...defaultSettings, ...response.data })
|
setSettings({ ...defaultSettings, ...response.data })
|
||||||
} catch {
|
} catch {
|
||||||
setSettings(defaultSettings)
|
setSettings(defaultSettings)
|
||||||
@@ -61,7 +170,10 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/settings', settings)
|
await api.post('/settings', settings)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
|
toast('success', 'Einstellungen gespeichert')
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -70,279 +182,546 @@ export default function SettingsPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||||
|
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||||
|
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||||
|
{ id: 'worker', label: 'Worker', icon: Cpu },
|
||||||
|
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||||||
|
{ id: 'apis', label: 'APIs', icon: Globe },
|
||||||
|
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||||||
|
{ id: 'system', label: 'System', icon: Wrench },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`max-w-4xl space-y-6 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
<PageHeader
|
||||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Einstellungen</h1>
|
title="Einstellungen"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p>
|
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div className={`p-4 rounded-lg flex items-center gap-3 font-medium transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 border border-emerald-700 text-emerald-400' : 'bg-emerald-50 border border-emerald-200 text-emerald-700'}`}>
|
<div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckCircle className="flex-shrink-0" size={24} />
|
<CheckCircle className="flex-shrink-0" size={22} />
|
||||||
<span>Einstellungen erfolgreich gespeichert!</span>
|
<span>Einstellungen erfolgreich gespeichert!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`rounded-xl shadow-sm overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}>
|
<Card className="overflow-hidden">
|
||||||
{/* Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||||
<TabButton icon={Disc} label="Ripping" isActive={true} darkMode={theme === 'dark'} />
|
{tabs.map((tab) => {
|
||||||
<TabButton icon={Cpu} label="Verarbeitung" isActive={false} darkMode={theme === 'dark'} />
|
const isActive = activeTab === tab.id
|
||||||
<TabButton icon={Database} label="Datenbank" isActive={false} darkMode={theme === 'dark'} />
|
return (
|
||||||
<TabButton icon={Globe} label="APIs" isActive={false} darkMode={theme === 'dark'} />
|
<button
|
||||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} darkMode={theme === 'dark'} />
|
key={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 ${
|
||||||
|
isActive
|
||||||
|
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Tab Content */}
|
||||||
<div className="p-6 space-y-8 transition-colors duration-300">
|
<CardContent className="p-6 space-y-8">
|
||||||
{/* Ripping Settings */}
|
{/* Ripping Settings */}
|
||||||
<section>
|
{activeTab === 'ripping' && (
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<section className="space-y-6">
|
||||||
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Disc size={20} className="text-amber-500" />
|
||||||
Ripping-Einstellungen
|
Ripping-Einstellungen
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 transition-colors duration-300">
|
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||||||
<div className="flex items-center gap-3">
|
📀 Video — DVD · Blu-ray · 4K UHD
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}>
|
</p>
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (nicht nur Main Feature)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={settings.ripAllTracks}
|
label="Vollautomatik: sofort rippen"
|
||||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
|
||||||
darkMode={theme === 'dark'}
|
checked={settings.autoRipStart}
|
||||||
|
onChange={(v) => handleChange('autoRipStart', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
|
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nur Hauptfeature</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Nur Hauptfeature"
|
||||||
|
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
|
||||||
checked={settings.mainFeatureOnly}
|
checked={settings.mainFeatureOnly}
|
||||||
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
<div className="flex items-center gap-3">
|
🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
|
</p>
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||||
<div>
|
🎵 Audio-CD
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Automatischer Auswurf</h3>
|
</p>
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Disk nach erfolgreichem Ripping auswerfen</p>
|
|
||||||
</div>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Alle Tracks rippen"
|
||||||
|
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
|
||||||
|
checked={settings.ripAllTracks}
|
||||||
|
onChange={(v) => handleChange('ripAllTracks', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||||
|
Allgemein
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<Toggle
|
||||||
|
label="Automatischer Auswurf"
|
||||||
|
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
|
||||||
checked={settings.autoEject}
|
checked={settings.autoEject}
|
||||||
onChange={(v) => handleChange('autoEject', v)}
|
onChange={(v) => handleChange('autoEject', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Media-Server Integration */}
|
||||||
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-4">
|
||||||
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Tv size={18} className="text-amber-500" />
|
||||||
|
Media-Server (Jellyfin & Co.)
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Rippy benennt fertige Rips als „Titel (Jahr)" und legt NFO + Poster dazu.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChange('mediaServer', o.wert)}
|
||||||
|
title={o.hinweis}
|
||||||
|
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
|
settings.mediaServer === o.wert
|
||||||
|
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||||
|
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
|
||||||
|
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
<strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
|
||||||
|
<Input
|
||||||
|
label="Server-URL"
|
||||||
|
value={settings.jellyfinUrl}
|
||||||
|
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
||||||
|
placeholder="http://192.168.178.50:8096"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="API-Key"
|
||||||
|
value={settings.jellyfinApiKey}
|
||||||
|
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
||||||
|
placeholder="API-Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="md"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/mediaserver/refresh', {
|
||||||
|
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
|
||||||
|
})
|
||||||
|
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
|
||||||
|
>
|
||||||
|
Testen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Output Directories */}
|
{/* Verarbeitung */}
|
||||||
<section>
|
{activeTab === 'verarbeitung' && (
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<section className="space-y-6">
|
||||||
<Database size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
Ausgabeverzeichnisse
|
<Cpu size={20} className="text-amber-500" />
|
||||||
|
Verarbeitung (Kompression)
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
|
||||||
|
) : (
|
||||||
|
workers.map(w => (
|
||||||
|
<div key={w.name} className="mb-1">
|
||||||
|
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||||||
|
{w.encoders.map(e => (
|
||||||
|
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200 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>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<Toggle
|
||||||
|
label="Nach dem Rip komprimieren"
|
||||||
|
description="x265 statt 40-GB-Rohdatei (empfohlen)."
|
||||||
|
checked={settings.transcodeEnabled}
|
||||||
|
onChange={(v) => handleChange('transcodeEnabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="HandBrake-Preset"
|
||||||
|
value={settings.transcodePreset}
|
||||||
|
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
||||||
|
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — für UHD-Quellen</option>
|
||||||
|
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||||
|
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<Toggle
|
||||||
|
label="Original behalten"
|
||||||
|
description="Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)."
|
||||||
|
checked={settings.keepOriginal}
|
||||||
|
onChange={(v) => handleChange('keepOriginal', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Arbeitsverzeichnis für Roh-Rips (optional)"
|
||||||
|
value={settings.workDir}
|
||||||
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
|
placeholder="leer = /app/temp (Container-Platte)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||||
|
{systemInfo?.plaetze?.length ? (
|
||||||
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Worker */}
|
||||||
|
{activeTab === 'worker' && (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Cpu size={20} className="text-amber-500" />
|
||||||
|
Encoding-Worker
|
||||||
|
</h2>
|
||||||
|
<WorkerVerwaltung />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Speicherziele */}
|
||||||
|
{activeTab === 'speicherziele' && (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
|
Speicherziele
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 transition-colors duration-300">
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||||||
Hauptverzeichnis
|
</h3>
|
||||||
</label>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<input
|
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵).
|
||||||
type="text"
|
</p>
|
||||||
value={settings.outputDir}
|
<div className="grid grid-cols-3 gap-3">
|
||||||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200 focus:ring-indigo-500' : 'bg-white border-slate-300 text-slate-900 focus:ring-indigo-600'} border rounded-lg focus:ring-2 focus:border-transparent transition-all`}
|
<Input
|
||||||
placeholder="/app/media"
|
key={feld}
|
||||||
|
label={label}
|
||||||
|
value={settings[feld]}
|
||||||
|
onChange={(e) => handleChange(feld, e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Basisverzeichnis für alle Ausgaben</p>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<StorageMounts />
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Filme
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.movieDir}
|
|
||||||
onChange={(e) => handleChange('movieDir', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="movies"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Serien
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.seriesDir}
|
|
||||||
onChange={(e) => handleChange('seriesDir', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="series"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Musik
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.musicDir}
|
|
||||||
onChange={(e) => handleChange('musicDir', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="music"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* APIs */}
|
||||||
<section>
|
{activeTab === 'apis' && (
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<section className="space-y-6">
|
||||||
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Globe size={20} className="text-amber-500" />
|
||||||
API-Schlüssel
|
API-Schlüssel
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
||||||
TMDB API Key
|
</Button>
|
||||||
</label>
|
{quellenStatus ? (
|
||||||
<input
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
||||||
|
<span
|
||||||
|
key={quelle}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
|
||||||
|
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
|
||||||
|
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
label="TMDB API Key"
|
||||||
value={settings.tmdbApiKey}
|
value={settings.tmdbApiKey}
|
||||||
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
|
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="Dein TMDB API Key"
|
placeholder="Dein TMDB API Key"
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<Input
|
||||||
Hol dir einen Key auf <a href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>themoviedb.org</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
TVDb API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
|
label="TVDb API Key"
|
||||||
value={settings.tvdbApiKey}
|
value={settings.tvdbApiKey}
|
||||||
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
|
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="Dein TVDb API Key"
|
placeholder="Dein TVDb API Key"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="OMDb API Key (Fallback)"
|
||||||
|
value={settings.omdbApiKey}
|
||||||
|
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
|
||||||
|
placeholder="Dein OMDb API Key"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Webhook */}
|
{/* Benachrichtigungen */}
|
||||||
<section>
|
{activeTab === 'benachrichtigungen' && (
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<section className="space-y-6">
|
||||||
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<AlertCircle size={20} className="text-amber-500" />
|
||||||
Webhook-Benachrichtigungen
|
Webhook-Benachrichtigungen
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<div>
|
||||||
Webhook URL
|
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
|
||||||
</label>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<input
|
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren.
|
||||||
type="text"
|
|
||||||
value={settings.notificationWebhook}
|
|
||||||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
<div className="space-y-2">
|
||||||
<div className={`flex items-center justify-end gap-4 pt-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-t`}>
|
<div className="flex gap-2 items-end">
|
||||||
<button
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Webhook URL"
|
||||||
|
value={settings.notificationWebhook}
|
||||||
|
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
||||||
|
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="md"
|
||||||
|
onClick={webhookTesten}
|
||||||
|
disabled={!settings.notificationWebhook || testBusy}
|
||||||
|
>
|
||||||
|
<Send size={15} />
|
||||||
|
{testBusy ? 'Sende…' : 'Test senden'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System */}
|
||||||
|
{activeTab === 'system' && (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Wrench size={20} className="text-amber-500" />
|
||||||
|
System & Kern-Werkzeuge
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
|
||||||
|
{!systemInfo || systemInfo.workers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||||
|
) : (
|
||||||
|
systemInfo.workers.map(w => (
|
||||||
|
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}</span>
|
||||||
|
{w.info?.makemkv && (
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
|
MakeMKV {w.info.makemkv}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{w.info?.handbrake && (
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
|
HandBrake {w.info.handbrake}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
||||||
|
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — wichtig wegen der
|
||||||
|
Disc-Schlüssel-Datenbank.
|
||||||
|
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
||||||
|
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
||||||
|
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
||||||
|
dagegen automatisch die neueste HandBrake-Version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="MakeMKV Beta-Key"
|
||||||
|
value={settings.makemkvAppKey}
|
||||||
|
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||||
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||||||
|
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
|
||||||
|
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{updates ? (
|
||||||
|
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
|
||||||
|
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
|
||||||
|
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||||||
|
{updates.makemkv?.update
|
||||||
|
? (
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||||||
|
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
||||||
|
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
||||||
|
</div>
|
||||||
|
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
|
||||||
|
<div>
|
||||||
|
<strong>HandBrake (Docker-Worker):</strong> {updates.handbrake?.installiert || '—'}
|
||||||
|
<span className="text-xs ml-1 text-slate-500 dark:text-slate-400">— stabile Debian-Version, bewusst so, kein Update nötig</span>
|
||||||
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
Neueste offizielle Version wäre {updates.handbrake?.verfuegbar || 'unbekannt'} — die nutzen die
|
||||||
|
<strong> nativen Worker (Windows)</strong> automatisch. Der Docker-Worker bleibt bei der
|
||||||
|
Debian-Version (für die Kompression identisch, robustere Builds).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Save Button */}
|
||||||
|
{activeTab !== 'worker' && (
|
||||||
|
<div className="flex items-center justify-end gap-4 pt-6 border-t border-slate-200/80 dark:border-slate-800">
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="lg"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-slate-950 border-t-transparent"></div>
|
||||||
Speichern...
|
Speichern...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save size={20} />
|
<Save size={18} />
|
||||||
Einstellungen speichern
|
Einstellungen speichern
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({ icon: Icon, label, isActive, darkMode }: { icon: any, label: string, isActive: boolean, darkMode: boolean }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
|
|
||||||
isActive
|
|
||||||
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`
|
|
||||||
: `${darkMode ? 'border-transparent text-slate-400 hover:text-slate-300' : 'border-transparent text-slate-500 hover:text-slate-700'}`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
checked ? 'bg-indigo-600' : darkMode ? 'bg-slate-600' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,22 @@
|
|||||||
|
|
||||||
FROM python:3.12-slim-bookworm AS makemkv-build
|
FROM python:3.12-slim-bookworm AS makemkv-build
|
||||||
|
|
||||||
|
# Zurück auf die AKTUELLE Version (24.07.2026): 1.17.7 war wegen des
|
||||||
|
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
|
||||||
|
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
|
||||||
|
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
|
||||||
|
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt). Die AKTUELLE
|
||||||
|
# Version zählt, weil sie die neueste AACS-Schlüssel-Datenbank mitbringt —
|
||||||
|
# 1.17.7 kannte z. B. den Key der Summer-Wars-UHD (MKB v82) nicht.
|
||||||
|
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
|
||||||
|
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
|
||||||
|
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
|
||||||
|
# konsequent HTTP-Fehler), Tarballs von Hand laden und lokal servieren:
|
||||||
|
# mkdir ~/makemkv-mirror && curl -o … && docker run -d --name makemkv-mirror \
|
||||||
|
# -p 8099:80 -v ~/makemkv-mirror:/usr/share/nginx/html:ro nginx:alpine
|
||||||
|
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
|
||||||
ARG MAKEMKV_VERSION=1.18.4
|
ARG MAKEMKV_VERSION=1.18.4
|
||||||
|
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -27,8 +42,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
# Download mit doppeltem Netz (Cloudflare drosselt BuildKit-Downloads
|
||||||
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \
|
# hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
|
||||||
|
# 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
|
||||||
|
# SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
|
||||||
|
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
|
||||||
|
COPY docker/worker/vendor/ ./vendor/
|
||||||
|
RUN if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
|
||||||
|
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
|
||||||
|
echo "Nutze lokale Tarballs aus vendor/"; \
|
||||||
|
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
|
||||||
|
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
|
||||||
|
else \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
||||||
|
-o oss.tar.gz "${MAKEMKV_URL_BASE}/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
||||||
|
&& curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
||||||
|
-o bin.tar.gz "${MAKEMKV_URL_BASE}/makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
|
||||||
|
fi \
|
||||||
|
&& sha256sum oss.tar.gz bin.tar.gz \
|
||||||
&& tar xzf oss.tar.gz \
|
&& tar xzf oss.tar.gz \
|
||||||
&& tar xzf bin.tar.gz
|
&& tar xzf bin.tar.gz
|
||||||
|
|
||||||
@@ -37,15 +68,21 @@ RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
|||||||
&& make -j"$(nproc)" \
|
&& make -j"$(nproc)" \
|
||||||
&& make install
|
&& make install
|
||||||
|
|
||||||
|
# PREFIX explizit: makemkv-bin installiert sonst nach /usr — und das COPY der
|
||||||
|
# Final-Stage nimmt nur /usr/local mit (Deploy 23.07.: makemkvcon fehlte im Image).
|
||||||
|
# Das abschließende test -x lässt den Build laut scheitern statt still lückenhaft.
|
||||||
RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
||||||
&& mkdir -p tmp \
|
&& mkdir -p tmp \
|
||||||
&& touch tmp/eula_accepted \
|
&& touch tmp/eula_accepted \
|
||||||
&& make \
|
&& make PREFIX=/usr/local \
|
||||||
&& make install
|
&& make install PREFIX=/usr/local \
|
||||||
|
&& test -x /usr/local/bin/makemkvcon
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip
|
||||||
|
# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
libexpat1 \
|
libexpat1 \
|
||||||
@@ -55,11 +92,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cdparanoia \
|
cdparanoia \
|
||||||
cd-discid \
|
cd-discid \
|
||||||
flac \
|
flac \
|
||||||
|
handbrake-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=makemkv-build /usr/local /usr/local
|
COPY --from=makemkv-build /usr/local /usr/local
|
||||||
RUN ldconfig
|
RUN ldconfig
|
||||||
|
|
||||||
|
# Version fürs UI sichtbar machen (Einstellungen → System): makemkvcon hat
|
||||||
|
# keinen --version-Schalter, also kommt die Wahrheit aus dem Build selbst.
|
||||||
|
ARG MAKEMKV_VERSION=1.18.4
|
||||||
|
ENV MAKEMKV_VERSION=${MAKEMKV_VERSION}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY docker/worker/requirements.txt .
|
COPY docker/worker/requirements.txt .
|
||||||
@@ -69,4 +112,6 @@ COPY docker/worker/ .
|
|||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
|
# Der lokale Worker bedient BEIDE Queues (rip + transcode). Ein optionaler
|
||||||
|
# Remote-GPU-Worker startet dasselbe Image nur mit "-Q transcode".
|
||||||
|
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "celery,transcode"]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Encoder-Fähigkeiten dieses Workers — ehrlich erkannt, nicht behauptet.
|
||||||
|
|
||||||
|
Jeder Worker meldet beim Start, was auf SEINER Maschine wirklich verfügbar
|
||||||
|
ist (Commander-Anforderung 23.07.: das UI zeigt an, WAS da ist). Ein
|
||||||
|
Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_encoder() -> list:
|
||||||
|
"""Liste der verfügbaren Encoder-Backends auf dieser Maschine."""
|
||||||
|
gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei
|
||||||
|
|
||||||
|
# VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri
|
||||||
|
if os.path.exists("/dev/dri/renderD128"):
|
||||||
|
gefunden.append("vaapi")
|
||||||
|
|
||||||
|
# NVENC: NVIDIA-Treiber im Container sichtbar
|
||||||
|
if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"):
|
||||||
|
gefunden.append("nvenc")
|
||||||
|
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_ip() -> str:
|
||||||
|
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
|
||||||
|
|
||||||
|
Ehrlicher Vorbehalt: im Docker-Bridge-Netz ist das die Container-IP —
|
||||||
|
für die Maschinen-Zuordnung ist deshalb WORKER_NAME (Anzeigename in
|
||||||
|
compose/.env) die verlässliche Größe; die IP ist Zusatz-Indiz.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("192.0.2.1", 9)) # TEST-NET — nur Routing, kein Paket nötig
|
||||||
|
return s.getsockname()[0]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeug_versionen() -> dict:
|
||||||
|
"""Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System).
|
||||||
|
|
||||||
|
MakeMKV-Version kommt aus dem Build (ENV MAKEMKV_VERSION im Dockerfile) —
|
||||||
|
makemkvcon hat keinen dokumentierten --version-Schalter (usage.txt).
|
||||||
|
HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options)
|
||||||
|
und gibt z.B. "HandBrake 1.6.1" aus.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
info = {
|
||||||
|
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"ip": erkenne_ip(),
|
||||||
|
}
|
||||||
|
if shutil.which("makemkvcon"):
|
||||||
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
|
if shutil.which("HandBrakeCLI"):
|
||||||
|
try:
|
||||||
|
aus = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--version"],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
|
info["handbrake"] = treffer.group(1) if treffer else "installiert"
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
info["handbrake"] = "installiert"
|
||||||
|
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
|
||||||
|
try:
|
||||||
|
import db
|
||||||
|
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
ui_key = ""
|
||||||
|
if ui_key:
|
||||||
|
info["makemkv_key"] = "ui"
|
||||||
|
elif os.getenv("MAKEMKV_APP_KEY"):
|
||||||
|
info["makemkv_key"] = "env"
|
||||||
|
else:
|
||||||
|
info["makemkv_key"] = "keiner"
|
||||||
|
return info
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from celery import Celery
|
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
from celery.signals import worker_ready
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
@@ -16,4 +19,48 @@ celery_app.conf.update(
|
|||||||
result_serializer="json",
|
result_serializer="json",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
|
broker_connection_retry_on_startup=True,
|
||||||
|
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
|
||||||
|
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
|
||||||
|
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
|
||||||
|
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
|
||||||
|
# transcode-Queue (irgendein freier Worker).
|
||||||
|
worker_direct=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
|
||||||
|
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
||||||
|
import caps # noqa: E402
|
||||||
|
import db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@worker_ready.connect
|
||||||
|
def melde_faehigkeiten(**kwargs):
|
||||||
|
"""Beim Start + minütlich: Encoder-Fähigkeiten und Lebenszeichen melden.
|
||||||
|
|
||||||
|
last_seen ist die Basis der Erreichbarkeits-Anzeige im UI — ein Worker,
|
||||||
|
der sich 2 Minuten nicht meldet, gilt als offline.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Anzeigename: WORKER_NAME (compose/.env) macht die Maschine im UI
|
||||||
|
# zuordenbar UND stabil — der Container-Hostname wechselt bei jedem
|
||||||
|
# Rebuild und hinterließ Offline-Leichen in der Worker-Liste.
|
||||||
|
anzeigename = os.getenv("WORKER_NAME") or socket.gethostname()
|
||||||
|
|
||||||
|
def herzschlag():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
db.init_db()
|
||||||
|
db.save_worker(
|
||||||
|
anzeigename,
|
||||||
|
caps.erkenne_encoder(),
|
||||||
|
caps.werkzeug_versionen(),
|
||||||
|
)
|
||||||
|
except Exception as e: # DB weg → weiterversuchen, nicht sterben
|
||||||
|
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
threading.Thread(target=herzschlag, daemon=True, name="worker-herzschlag").start()
|
||||||
|
|||||||
+121
-1
@@ -36,7 +36,9 @@ jobs = Table(
|
|||||||
Column("status", String(16), nullable=False, server_default="pending"),
|
Column("status", String(16), nullable=False, server_default="pending"),
|
||||||
Column("progress", Integer, nullable=False, server_default="0"),
|
Column("progress", Integer, nullable=False, server_default="0"),
|
||||||
Column("output_path", Text),
|
Column("output_path", Text),
|
||||||
|
Column("target_dir", String(255)),
|
||||||
Column("error", Text),
|
Column("error", Text),
|
||||||
|
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
|
||||||
Column("created_at", DateTime(timezone=True)),
|
Column("created_at", DateTime(timezone=True)),
|
||||||
Column("finished_at", DateTime(timezone=True)),
|
Column("finished_at", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
@@ -57,8 +59,126 @@ def utcnow() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""Legt fehlende Tabellen an (idempotent)."""
|
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
|
||||||
|
|
||||||
|
Die ALTERs stehen identisch in docker/api/db.py — wer zuerst startet,
|
||||||
|
migriert; der andere findet die Spalten dann bereits vor.
|
||||||
|
"""
|
||||||
metadata.create_all(engine)
|
metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings_table = Table(
|
||||||
|
"settings",
|
||||||
|
metadata,
|
||||||
|
Column("key", String(64), primary_key=True),
|
||||||
|
Column("value", Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
workers = Table(
|
||||||
|
"workers",
|
||||||
|
metadata,
|
||||||
|
Column("name", String(128), primary_key=True),
|
||||||
|
Column("encoders", Text),
|
||||||
|
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||||
|
Column("last_seen", DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
||||||
|
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = json.dumps(encoder_liste)
|
||||||
|
info_payload = json.dumps(info or {})
|
||||||
|
with engine.begin() as conn:
|
||||||
|
vorhanden = conn.execute(
|
||||||
|
workers.select().where(workers.c.name == name)
|
||||||
|
).first()
|
||||||
|
if vorhanden:
|
||||||
|
conn.execute(
|
||||||
|
workers.update().where(workers.c.name == name).values(
|
||||||
|
encoders=payload, info=info_payload, last_seen=utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
workers.insert().values(
|
||||||
|
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(key: str = "ui") -> dict:
|
||||||
|
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||||
|
).first()
|
||||||
|
if zeile and zeile[0]:
|
||||||
|
return json.loads(zeile[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict:
|
||||||
|
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
||||||
|
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs).where(jobs.c.id == job_id)
|
||||||
|
).mappings().first()
|
||||||
|
return dict(zeile) if zeile else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(werte: dict, key: str = "ui") -> None:
|
||||||
|
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||||
|
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
payload = json.dumps(werte)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
vorhanden = conn.execute(
|
||||||
|
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||||
|
).first()
|
||||||
|
if vorhanden:
|
||||||
|
conn.execute(
|
||||||
|
settings_table.update()
|
||||||
|
.where(settings_table.c.key == key)
|
||||||
|
.values(value=payload)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(settings_table.insert().values(key=key, value=payload))
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_status(job_id: str) -> str:
|
||||||
|
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs.c.status).where(jobs.c.id == job_id)
|
||||||
|
).first()
|
||||||
|
return zeile[0] if zeile else ""
|
||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, **fields) -> None:
|
def update_job(job_id: str, **fields) -> None:
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
|
|||||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||||
|
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
|
||||||
|
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
|
||||||
|
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
|
||||||
|
UHD_MIN_BYTES = 55 * 1024**3
|
||||||
|
|
||||||
|
|
||||||
def _open_nonblock(device_path: str) -> int:
|
def _open_nonblock(device_path: str) -> int:
|
||||||
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
|
||||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||||
return "cd"
|
return "cd"
|
||||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||||
|
if size_bytes >= UHD_MIN_BYTES:
|
||||||
|
return "uhd"
|
||||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Media-Server-Aufbereitung: sprechende Ordnernamen + NFO + Poster.
|
||||||
|
|
||||||
|
Commander-Wunsch 24.07.: Rippy soll sich auf das Zielsystem (Jellyfin, Plex,
|
||||||
|
Emby, Kodi) vorbereiten. Das Setting `mediaServer` steuert:
|
||||||
|
- ALLE Systeme: Zielordner heißt „<Titel> (Jahr)" statt Job-UUID — daran
|
||||||
|
erkennen Jellyfin & Co. den Film (Namensschema laut jellyfin.org/docs/
|
||||||
|
general/server/media/movies bzw. support.plex.tv „Naming and Organizing").
|
||||||
|
- jellyfin / emby / kodi: zusätzlich movie.nfo bzw. tvshow.nfo (Kodi-Schema,
|
||||||
|
kodi.wiki/view/NFO_files) + poster.jpg — diese Server lesen NFO nativ.
|
||||||
|
- plex: NUR Benennung (Plex liest NFO ohne Zusatz-Agent nicht).
|
||||||
|
- none: Verhalten wie bisher (UUID-Ordner, keine Extras).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
NFO_SERVER = ("jellyfin", "emby", "kodi")
|
||||||
|
# Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen
|
||||||
|
_VERBOTEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sicherer_name(titel: str, jahr=None) -> str:
|
||||||
|
"""Pure Funktion (testbar): Titel → Dateisystem-tauglicher Ordnername."""
|
||||||
|
name = _VERBOTEN.sub("", titel or "").strip().rstrip(".")
|
||||||
|
name = re.sub(r"\s+", " ", name)[:150].strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
# Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen —
|
||||||
|
# "X (2009) (2009)" sah man sonst im Log UND im Ordnernamen (Fund 24.07.).
|
||||||
|
if jahr and not name.endswith(f"({jahr})"):
|
||||||
|
name = f"{name} ({jahr})"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
|
||||||
|
"""Zielordner unter `basis`: sprechender Name, UUID nur als Fallback.
|
||||||
|
|
||||||
|
Existiert der Ordner schon (Disc doppelt gerippt), wird die Job-ID
|
||||||
|
angehängt statt fremde Dateien zu überschreiben.
|
||||||
|
"""
|
||||||
|
name = sicherer_name(titel, jahr)
|
||||||
|
if not name:
|
||||||
|
return os.path.join(basis, job_id)
|
||||||
|
pfad = os.path.join(basis, name)
|
||||||
|
if os.path.exists(pfad):
|
||||||
|
pfad = os.path.join(basis, f"{name} [{job_id[:8]}]")
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def serien_ordner(basis: str, serie: str, staffel: int) -> str:
|
||||||
|
"""Jellyfin-Konvention: <basis>/<Serie>/Season NN (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Namensschema laut jellyfin.org/docs/general/server/media/shows — Emby,
|
||||||
|
Kodi und Plex verstehen dieselbe Struktur.
|
||||||
|
"""
|
||||||
|
name = sicherer_name(serie) or "Unbekannte Serie"
|
||||||
|
return os.path.join(basis, name, f"Season {int(staffel):02d}")
|
||||||
|
|
||||||
|
|
||||||
|
def matche_episoden(datei_sekunden: list, episoden: list,
|
||||||
|
toleranz_prozent: float = 8.0, toleranz_min_s: int = 180):
|
||||||
|
"""Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||||
|
|
||||||
|
datei_sekunden: Laufzeiten der MKVs in Disc-Reihenfolge (Sekunden).
|
||||||
|
episoden: [(episoden_nr, laufzeit_sekunden), …] in Serienreihenfolge.
|
||||||
|
Rückgabe: Liste der Episoden-Nummern je Datei — oder None, wenn die
|
||||||
|
Zuordnung nicht EINDEUTIG gelingt (dann lieber gar nicht umbenennen
|
||||||
|
als falsch; ARMs offene Wunde #395 war genau das Ratespiel).
|
||||||
|
|
||||||
|
Zwei Stufen:
|
||||||
|
1. Komplette Staffel auf der Disc (Dateizahl == Episodenzahl, Anime-Box):
|
||||||
|
Sequenz-Zuordnung 1:1 in Reihenfolge — auch bei uniformen Laufzeiten
|
||||||
|
eindeutig, weil nichts übrig bleibt.
|
||||||
|
2. Sonst Einzel-Zuordnung über DISTINKTE Laufzeiten; passt eine Datei
|
||||||
|
auf mehrere Episoden ähnlich gut → None. Teil-Discs mit lauter
|
||||||
|
gleich langen Episoden sind über Laufzeit prinzipiell nicht
|
||||||
|
zuordenbar — das gibt ehrlich None statt einer Ratenummer.
|
||||||
|
"""
|
||||||
|
if not datei_sekunden or not episoden:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def toleranz(dauer):
|
||||||
|
return max(toleranz_min_s, dauer * toleranz_prozent / 100)
|
||||||
|
|
||||||
|
# Stufe 1: komplette Staffel in Disc-Reihenfolge
|
||||||
|
if len(datei_sekunden) == len(episoden):
|
||||||
|
if all(
|
||||||
|
ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer)
|
||||||
|
for dauer, (_, ep_dauer) in zip(datei_sekunden, episoden)
|
||||||
|
):
|
||||||
|
return [nr for nr, _ in episoden]
|
||||||
|
|
||||||
|
# Stufe 2: eindeutige Einzel-Zuordnung, ordnungserhaltend
|
||||||
|
zuordnung = []
|
||||||
|
start = 0
|
||||||
|
for dauer in datei_sekunden:
|
||||||
|
kandidaten = [
|
||||||
|
(nr, ep_dauer) for nr, ep_dauer in episoden[start:]
|
||||||
|
if ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer)
|
||||||
|
]
|
||||||
|
if len(kandidaten) == 0:
|
||||||
|
return None
|
||||||
|
if len(kandidaten) > 1:
|
||||||
|
# Nur eindeutig, wenn der beste Kandidat KLAR besser passt als
|
||||||
|
# der zweitbeste (doppelt so nah) — sonst wäre es geraten.
|
||||||
|
kandidaten.sort(key=lambda k: abs(dauer - k[1]))
|
||||||
|
if not (abs(dauer - kandidaten[0][1]) * 2 < abs(dauer - kandidaten[1][1])):
|
||||||
|
return None
|
||||||
|
nr = kandidaten[0][0]
|
||||||
|
zuordnung.append(nr)
|
||||||
|
start = next(i for i, (e_nr, _) in enumerate(episoden) if e_nr == nr) + 1
|
||||||
|
return zuordnung
|
||||||
|
|
||||||
|
|
||||||
|
def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list) -> list:
|
||||||
|
"""Benennt die MKVs im Ordner nach Jellyfin-Schema um: 'Serie S01E02.mkv'.
|
||||||
|
|
||||||
|
Reihenfolge = sortierte Dateinamen (MakeMKV nummeriert title_t00…tNN in
|
||||||
|
Disc-Reihenfolge — dieselbe Reihenfolge, in der gematcht wurde).
|
||||||
|
"""
|
||||||
|
meldungen = []
|
||||||
|
dateien = sorted(f for f in os.listdir(ordner) if f.endswith(".mkv"))
|
||||||
|
if len(dateien) != len(zuordnung):
|
||||||
|
return [f"Umbenennen übersprungen: {len(dateien)} Dateien, {len(zuordnung)} Zuordnungen"]
|
||||||
|
serien_name = sicherer_name(serie) or "Serie"
|
||||||
|
for alt, ep_nr in zip(dateien, zuordnung):
|
||||||
|
neu = f"{serien_name} S{int(staffel):02d}E{int(ep_nr):02d}.mkv"
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
ziel = os.path.join(ordner, neu)
|
||||||
|
if os.path.exists(ziel):
|
||||||
|
meldungen.append(f"Umbenennen übersprungen ({neu} existiert schon)")
|
||||||
|
continue
|
||||||
|
os.rename(os.path.join(ordner, alt), ziel)
|
||||||
|
meldungen.append(f"{alt} → {neu}")
|
||||||
|
return meldungen
|
||||||
|
|
||||||
|
|
||||||
|
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
|
||||||
|
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby).
|
||||||
|
|
||||||
|
API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh,
|
||||||
|
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
|
||||||
|
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
||||||
|
"""
|
||||||
|
if media_server not in ("jellyfin", "emby") or not url or not api_key:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
antwort = requests.post(
|
||||||
|
url.rstrip("/") + "/Library/Refresh",
|
||||||
|
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||||
|
)
|
||||||
|
if antwort.status_code < 300:
|
||||||
|
return f"{media_server}-Bibliothek aktualisiert ({url})"
|
||||||
|
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||||
|
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
||||||
|
ist_serie = (meta.get("type") == "tv")
|
||||||
|
wurzel = "tvshow" if ist_serie else "movie"
|
||||||
|
zeilen = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
f"<{wurzel}>",
|
||||||
|
f" <title>{escape(titel or '')}</title>",
|
||||||
|
]
|
||||||
|
if jahr:
|
||||||
|
zeilen.append(f" <year>{escape(str(jahr))}</year>")
|
||||||
|
if meta.get("overview"):
|
||||||
|
zeilen.append(f" <plot>{escape(meta['overview'])}</plot>")
|
||||||
|
for genre in meta.get("genres") or []:
|
||||||
|
zeilen.append(f" <genre>{escape(str(genre))}</genre>")
|
||||||
|
if meta.get("runtime"):
|
||||||
|
zeilen.append(f" <runtime>{escape(str(meta['runtime']))}</runtime>")
|
||||||
|
quelle = meta.get("source") or "Rippy"
|
||||||
|
zeilen.append(f" <details>Source: {escape(quelle)}</details>")
|
||||||
|
zeilen.append(f"</{wurzel}>")
|
||||||
|
return "\n".join(zeilen) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict,
|
||||||
|
serien_root: str = None) -> list:
|
||||||
|
"""Schreibt NFO + Poster in den fertigen Ordner (best effort).
|
||||||
|
|
||||||
|
serien_root: bei Staffel-Rips der Serien-Ordner (Parent von "Season NN") —
|
||||||
|
tvshow.nfo + poster.jpg gehören laut Kodi-Schema dorthin, nicht in die
|
||||||
|
Staffel. Bestehende Dateien werden dort NIE überschrieben (Staffel 2
|
||||||
|
einer schon aufbereiteten Serie).
|
||||||
|
|
||||||
|
Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster
|
||||||
|
darf einen gelungenen Rip nicht auf 'failed' drehen.
|
||||||
|
"""
|
||||||
|
meldungen = []
|
||||||
|
if media_server not in NFO_SERVER:
|
||||||
|
return meldungen
|
||||||
|
meta = meta or {}
|
||||||
|
|
||||||
|
if serien_root:
|
||||||
|
ziel_ordner = serien_root
|
||||||
|
nfo_name = "tvshow.nfo"
|
||||||
|
nfo_meta = {**meta, "type": "tv"}
|
||||||
|
ueberschreiben = False
|
||||||
|
else:
|
||||||
|
ziel_ordner = ordner
|
||||||
|
nfo_name = "tvshow.nfo" if meta.get("type") == "tv" else "movie.nfo"
|
||||||
|
nfo_meta = meta
|
||||||
|
ueberschreiben = True
|
||||||
|
|
||||||
|
nfo_pfad = os.path.join(ziel_ordner, nfo_name)
|
||||||
|
try:
|
||||||
|
if ueberschreiben or not os.path.exists(nfo_pfad):
|
||||||
|
with open(nfo_pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write(baue_nfo(nfo_meta, titel, jahr))
|
||||||
|
meldungen.append(f"{nfo_name} geschrieben")
|
||||||
|
except OSError as e:
|
||||||
|
meldungen.append(f"NFO fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
poster = meta.get("poster_path") or ""
|
||||||
|
poster_pfad = os.path.join(ziel_ordner, "poster.jpg")
|
||||||
|
if poster and (ueberschreiben or not os.path.exists(poster_pfad)):
|
||||||
|
if not poster.startswith("http"):
|
||||||
|
poster = f"https://image.tmdb.org/t/p/w500{poster}"
|
||||||
|
try:
|
||||||
|
antwort = requests.get(poster, timeout=15)
|
||||||
|
if antwort.status_code == 200 and antwort.content:
|
||||||
|
with open(poster_pfad, "wb") as f:
|
||||||
|
f.write(antwort.content)
|
||||||
|
meldungen.append("poster.jpg gespeichert")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
meldungen.append(f"Poster fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
return meldungen
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
|
||||||
|
|
||||||
|
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
|
||||||
|
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
|
||||||
|
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
||||||
|
|
||||||
|
Das Modul existiert bewusst identisch in API und Worker
|
||||||
|
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||||
|
Wer es ändert, ändert BEIDE Dateien.
|
||||||
|
|
||||||
|
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
||||||
|
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
||||||
|
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
|
||||||
|
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
|
||||||
|
docs.ntfy.sh/publish
|
||||||
|
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
|
||||||
|
(Home Assistant, n8n, eigene Skripte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TIMEOUT_SEKUNDEN = 10
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_webhook_typ(url: str) -> str:
|
||||||
|
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
|
||||||
|
u = url.lower()
|
||||||
|
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
|
||||||
|
return "discord"
|
||||||
|
if "hooks.slack.com" in u:
|
||||||
|
return "slack"
|
||||||
|
if "ntfy" in u:
|
||||||
|
return "ntfy"
|
||||||
|
return "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
|
||||||
|
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
|
||||||
|
typ = erkenne_webhook_typ(url)
|
||||||
|
if typ == "discord":
|
||||||
|
return typ, {"content": f"**{titel}**\n{text}"}
|
||||||
|
if typ == "slack":
|
||||||
|
return typ, {"text": f"*{titel}*\n{text}"}
|
||||||
|
if typ == "ntfy":
|
||||||
|
return typ, None
|
||||||
|
return typ, {"title": titel, "message": text, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
|
||||||
|
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
|
||||||
|
typ, payload = baue_payload(url, titel, text, level)
|
||||||
|
try:
|
||||||
|
if typ == "ntfy":
|
||||||
|
antwort = requests.post(
|
||||||
|
url, data=text.encode("utf-8"),
|
||||||
|
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 300:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Webhook antwortete mit HTTP {antwort.status_code}: "
|
||||||
|
f"{(antwort.text or '')[:200]}"
|
||||||
|
)
|
||||||
+256
-9
@@ -20,6 +20,11 @@ import tempfile
|
|||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
|
|
||||||
|
class RipAbbruch(Exception):
|
||||||
|
"""Kooperativer Abbruch: vom Fortschritts-Callback geworfen, wenn der
|
||||||
|
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||||
|
|
||||||
|
|
||||||
def check_makemkv_installed() -> bool:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
return shutil.which("makemkvcon") is not None
|
||||||
@@ -35,24 +40,147 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
||||||
|
--noscan KEIN Scan über alle Geräte — der brachte MakeMKV im
|
||||||
|
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
|
||||||
|
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
|
||||||
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
||||||
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
"makemkvcon",
|
||||||
"-r",
|
"-r",
|
||||||
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
f"dev:{device_path}",
|
||||||
"all",
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tinfo_dauern(ausgabe: str) -> dict:
|
||||||
|
"""TINFO-Zeilen → {titel_nr: dauer_sekunden} (pure Funktion, testbar).
|
||||||
|
|
||||||
|
makemkvcon-Robot-Format laut makemkv.com/developers/usage.txt:
|
||||||
|
TINFO:<titel>,<attr_id>,<code>,"<wert>" — Attribut 9 ist die Laufzeit
|
||||||
|
als "H:MM:SS" (ap_iaDuration).
|
||||||
|
"""
|
||||||
|
dauern = {}
|
||||||
|
for zeile in ausgabe.splitlines():
|
||||||
|
treffer = re.match(r'TINFO:(\d+),9,\d+,"(\d+):(\d{2}):(\d{2})"', zeile.strip())
|
||||||
|
if treffer:
|
||||||
|
nr, h, m, s = (int(g) for g in treffer.groups())
|
||||||
|
dauern[nr] = h * 3600 + m * 60 + s
|
||||||
|
return dauern
|
||||||
|
|
||||||
|
|
||||||
|
def parse_titel_info(ausgabe: str) -> list:
|
||||||
|
"""TINFO-Zeilen → volle Titel-Liste für die Auswahl-Tabelle (pure Funktion).
|
||||||
|
|
||||||
|
Attribut-IDs laut MakeMKV apdefs.h (makemkvgui/inc/lgpl/apdefs.h, LGPL):
|
||||||
|
8 = ChapterCount, 9 = Duration ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||||
|
Rückgabe: [{"nr", "dauer_s", "groesse_bytes", "kapitel"}, …] sortiert.
|
||||||
|
"""
|
||||||
|
titel: dict = {}
|
||||||
|
for zeile in ausgabe.splitlines():
|
||||||
|
treffer = re.match(r'TINFO:(\d+),(\d+),\d+,"(.*)"\s*$', zeile.strip())
|
||||||
|
if not treffer:
|
||||||
|
continue
|
||||||
|
nr, attr, wert = int(treffer.group(1)), int(treffer.group(2)), treffer.group(3)
|
||||||
|
eintrag = titel.setdefault(nr, {"nr": nr, "dauer_s": 0, "groesse_bytes": 0, "kapitel": 0})
|
||||||
|
if attr == 9:
|
||||||
|
teile = wert.split(":")
|
||||||
|
if len(teile) == 3 and all(t.isdigit() for t in teile):
|
||||||
|
h, m, s = (int(t) for t in teile)
|
||||||
|
eintrag["dauer_s"] = h * 3600 + m * 60 + s
|
||||||
|
elif attr == 11 and wert.isdigit():
|
||||||
|
eintrag["groesse_bytes"] = int(wert)
|
||||||
|
elif attr == 8 and wert.isdigit():
|
||||||
|
eintrag["kapitel"] = int(wert)
|
||||||
|
return [titel[nr] for nr in sorted(titel)]
|
||||||
|
|
||||||
|
|
||||||
|
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
||||||
|
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
return parse_titel_info(ergebnis.stdout or "")
|
||||||
|
|
||||||
|
|
||||||
|
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||||
|
progress_cb=None) -> dict:
|
||||||
|
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
||||||
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
|
gesamt = len(titel_liste)
|
||||||
|
alle_dateien = []
|
||||||
|
for index, nr in enumerate(titel_liste):
|
||||||
|
def anteilig(p, _i=index):
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(int((_i * 100 + p) / gesamt))
|
||||||
|
|
||||||
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
|
||||||
|
if ergebnis.get("status") == "cancelled":
|
||||||
|
return ergebnis
|
||||||
|
if ergebnis.get("status") != "success":
|
||||||
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
return ergebnis
|
||||||
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||||
|
|
||||||
|
|
||||||
|
def laengster_titel(dauern: dict):
|
||||||
|
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||||
|
if not dauern:
|
||||||
|
return None
|
||||||
|
return max(dauern, key=dauern.get)
|
||||||
|
|
||||||
|
|
||||||
|
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
||||||
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return {}
|
||||||
|
return parse_tinfo_dauern(ergebnis.stdout or "")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
|
"""HandBrake-Scan-Ausgabe → Dauer in Sekunden (pure Funktion, testbar).
|
||||||
|
|
||||||
|
HandBrakeCLI --scan schreibt je Titel "+ duration: HH:MM:SS"
|
||||||
|
(handbrake.fr/docs, Scan-Ausgabe); bei einer MKV-Datei gibt es genau
|
||||||
|
einen Titel.
|
||||||
|
"""
|
||||||
|
treffer = re.search(r"\+ duration: (\d+):(\d{2}):(\d{2})", ausgabe)
|
||||||
|
if not treffer:
|
||||||
|
return 0
|
||||||
|
h, m, s = (int(g) for g in treffer.groups())
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
|
||||||
|
|
||||||
|
def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||||
|
"""Laufzeit einer MKV-Datei in Sekunden (HandBrakeCLI --scan)."""
|
||||||
|
if not check_handbrake_installed():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--scan", "-i", pfad],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return 0
|
||||||
|
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
|
||||||
|
|
||||||
|
|
||||||
def get_progress_from_prgv(line: str) -> int:
|
def get_progress_from_prgv(line: str) -> int:
|
||||||
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||||
|
|
||||||
@@ -68,6 +196,81 @@ def get_progress_from_prgv(line: str) -> int:
|
|||||||
return min(100, int(total * 100 / maximum))
|
return min(100, int(total * 100 / maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def check_handbrake_installed() -> bool:
|
||||||
|
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||||
|
return shutil.which("HandBrakeCLI") is not None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||||
|
|
||||||
|
|
||||||
|
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
|
||||||
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||||
|
|
||||||
|
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||||
|
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||||
|
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||||
|
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"HandBrakeCLI",
|
||||||
|
"--input", input_path,
|
||||||
|
"--output", output_path,
|
||||||
|
"--preset", preset,
|
||||||
|
"--all-audio",
|
||||||
|
"--all-subtitles",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_from_line(line: str) -> int:
|
||||||
|
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||||
|
|
||||||
|
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
||||||
|
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
||||||
|
"""
|
||||||
|
match = re.search(r'(\d+\.\d+)\s*%', line)
|
||||||
|
if match:
|
||||||
|
return int(float(match.group(1)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
|
||||||
|
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
||||||
|
if not check_handbrake_installed():
|
||||||
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
build_handbrake_cmd(input_path, output_path, preset),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in process.stdout:
|
||||||
|
progress = get_progress_from_line(line)
|
||||||
|
if progress > 0 and progress_cb:
|
||||||
|
progress_cb(progress)
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
|
return {"status": "success", "output_path": output_path}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"HandBrake endete mit Code {process.returncode}",
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -94,7 +297,7 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
@@ -103,7 +306,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
build_makemkv_cmd(device_path, output_dir),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -111,6 +314,18 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
|
# (Befund 24.07., Summer-Wars-UHD).
|
||||||
|
kritische_meldungen = []
|
||||||
|
KRITISCH = (
|
||||||
|
"volume key is unknown",
|
||||||
|
"evaluation period has expired",
|
||||||
|
"registration key",
|
||||||
|
"Too old version",
|
||||||
|
)
|
||||||
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0 and progress_cb:
|
if progress >= 0 and progress_cb:
|
||||||
@@ -120,6 +335,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
teile = line.split(",", 4)
|
teile = line.split(",", 4)
|
||||||
if len(teile) >= 4:
|
if len(teile) >= 4:
|
||||||
letzte_meldung = teile[3].strip('"')
|
letzte_meldung = teile[3].strip('"')
|
||||||
|
if any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
|
kritische_meldungen.append(letzte_meldung)
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
@@ -140,6 +361,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
|
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
|
||||||
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
|
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
|
||||||
),
|
),
|
||||||
@@ -149,17 +371,36 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||||
|
output_dir: str = None, nur_hauptfilm: bool = False,
|
||||||
|
titel_liste: list = None) -> dict:
|
||||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||||
|
|
||||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift.
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||||
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||||
|
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||||
|
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||||
|
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||||
"""
|
"""
|
||||||
|
if output_dir is None:
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
|
||||||
|
if titel_liste:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb)
|
||||||
|
|
||||||
|
titel = "all"
|
||||||
|
if nur_hauptfilm:
|
||||||
|
dauern = lies_titel_dauern(device_path)
|
||||||
|
haupt = laengster_titel(dauern)
|
||||||
|
if haupt is not None:
|
||||||
|
titel = str(haupt)
|
||||||
|
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
||||||
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel)
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
||||||
"""Rippt eine CD mit abcde (FLAC)."""
|
"""Rippt eine CD mit abcde (FLAC)."""
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
@@ -172,6 +413,7 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
|
|||||||
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if output_dir is None:
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
@@ -190,8 +432,13 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
|
|||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
melde(50, line.strip()[:200])
|
melde(50, line.strip()[:200])
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
|
|||||||
+532
-32
@@ -1,20 +1,320 @@
|
|||||||
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
|
"""Rip- und Transcode-Tasks — bewusst GETRENNT (23.07.2026, Basis für Etappe 12/20).
|
||||||
|
|
||||||
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
|
Warum zwei Tasks: Rippen braucht das Laufwerk (läuft immer lokal), Kompression
|
||||||
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
|
braucht nur CPU/GPU + Zugriff auf die Rohdatei. Als eigener Celery-Task auf der
|
||||||
Ereignisse ins Log. Das UI liest beides über die API.
|
Queue "transcode" kann die Kompression damit auch ein Remote-Worker mit GPU
|
||||||
|
übernehmen (optionales Add-on) — und fehlgeschlagene Kompressionen lassen sich
|
||||||
|
neu anstoßen, ohne die Disc neu zu rippen (POST /jobs/{id}/retry-transcode).
|
||||||
|
|
||||||
|
Zwei Stufen (Commander-Entscheid 23.07.): MakeMKV rippt verlustfrei (einziger
|
||||||
|
Weg durch AACS — HandBrake kann verschlüsselte Discs nicht lesen), HandBrake
|
||||||
|
komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||||
|
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
import medien
|
||||||
|
import notify
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from detection import detect_disc_type
|
|
||||||
from ripping import rip_cd, rip_video
|
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
|
||||||
|
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
|
||||||
|
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
|
||||||
|
try:
|
||||||
|
from detection import detect_disc_type, disc_size_bytes
|
||||||
|
except ImportError: # Windows: kein fcntl
|
||||||
|
detect_disc_type = None
|
||||||
|
disc_size_bytes = None
|
||||||
|
|
||||||
|
from ripping import (
|
||||||
|
DEFAULT_HB_PRESET,
|
||||||
|
RIP_OUTPUT_DIR,
|
||||||
|
RipAbbruch,
|
||||||
|
lies_datei_dauer,
|
||||||
|
lies_titel_info,
|
||||||
|
rip_cd,
|
||||||
|
rip_video,
|
||||||
|
run_handbrake,
|
||||||
|
)
|
||||||
|
|
||||||
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
|
||||||
|
|
||||||
|
def _transcode_queue(node: str):
|
||||||
|
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||||
|
gewählter Worker via worker_direct, wenn online — sonst geteilte Queue."""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
antworten = celery_app.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
||||||
|
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
||||||
|
|
||||||
|
Der native Windows-Worker sieht /app/temp und /app/media nicht — er
|
||||||
|
mountet Rippys Freigabe als Netzlaufwerk und setzt RIPPY_PATH_MAP,
|
||||||
|
z. B. "/app/media=Z:\\media;/app/temp=Y:\\temp". Ohne Mapping (Docker-
|
||||||
|
Worker: identische Mounts) kommt der Pfad unverändert zurück.
|
||||||
|
"""
|
||||||
|
mapping = mapping if mapping is not None else os.getenv("RIPPY_PATH_MAP", "")
|
||||||
|
if not pfad or not mapping:
|
||||||
|
return pfad
|
||||||
|
for paar in mapping.split(";"):
|
||||||
|
if "=" not in paar:
|
||||||
|
continue
|
||||||
|
quelle, ziel = paar.split("=", 1)
|
||||||
|
if quelle and pfad.startswith(quelle):
|
||||||
|
rest = pfad[len(quelle):]
|
||||||
|
if "\\" in ziel:
|
||||||
|
rest = rest.replace("/", "\\")
|
||||||
|
return ziel + rest
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||||||
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def _zielbasis(target_dir, disc_type: str) -> str:
|
||||||
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
|
||||||
|
if target_dir:
|
||||||
|
normalisiert = os.path.normpath(target_dir)
|
||||||
|
if normalisiert.startswith(MEDIA_ROOT):
|
||||||
|
return normalisiert
|
||||||
|
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict) -> str:
|
||||||
|
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
||||||
|
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
||||||
|
|
||||||
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
|
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||||||
|
"""
|
||||||
|
work_dir = (einstellungen.get("workDir") or "").strip()
|
||||||
|
if work_dir:
|
||||||
|
normalisiert = os.path.normpath(work_dir)
|
||||||
|
if normalisiert.startswith(MEDIA_ROOT):
|
||||||
|
return normalisiert
|
||||||
|
return RAW_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _frei_bytes(pfad: str) -> int:
|
||||||
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
||||||
|
kandidat = pfad
|
||||||
|
while kandidat and not os.path.exists(kandidat):
|
||||||
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
try:
|
||||||
|
return shutil.disk_usage(kandidat or "/").free
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _platz_pruefen(pfad: str, benoetigt: int, zweck: str) -> str:
|
||||||
|
"""Gibt eine Klartext-Fehlermeldung zurück, wenn der Platz nicht reicht —
|
||||||
|
sonst leeren String. VOR dem Rip geprüft: eine volle Platte nach 40 GB
|
||||||
|
ist der teuerste Fehlschlag, den es gibt."""
|
||||||
|
frei = _frei_bytes(pfad)
|
||||||
|
if frei < 0 or benoetigt <= 0:
|
||||||
|
return "" # unbekannt → nicht raten, lieber rippen lassen
|
||||||
|
if frei >= benoetigt:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f"Zu wenig Platz für {zweck} in {pfad}: "
|
||||||
|
f"{frei / 1024**3:.1f} GB frei, benötigt ~{benoetigt / 1024**3:.1f} GB. "
|
||||||
|
"Abhilfe: anderes Ziel wählen oder das Arbeitsverzeichnis unter "
|
||||||
|
"Einstellungen → Verarbeitung auf eine große Freigabe legen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||||
|
"""MakeMKV-Beta-Key aus den UI-Einstellungen aktivieren (schlägt Env).
|
||||||
|
|
||||||
|
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||||
|
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||||||
|
"""
|
||||||
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
ordner = os.path.expanduser("~/.MakeMKV")
|
||||||
|
try:
|
||||||
|
os.makedirs(ordner, exist_ok=True)
|
||||||
|
with open(os.path.join(ordner, "settings.conf"), "w") as f:
|
||||||
|
f.write(f'app_Key = "{key}"\n')
|
||||||
|
except OSError as e:
|
||||||
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _abbruch_angefordert(job_id: str) -> bool:
|
||||||
|
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||||
|
return db.get_job_status(job_id) == "canceling"
|
||||||
|
|
||||||
|
|
||||||
|
def _serien_episoden_zuordnen(ausgabe: str, serie: str, staffel, meta: dict) -> list:
|
||||||
|
"""Episoden-Zuordnung per Laufzeitabgleich (ARM-Wunde #395, besser gelöst).
|
||||||
|
|
||||||
|
Laufzeiten der Staffel kommen von der API (TMDB); die der Dateien vom
|
||||||
|
HandBrake-Scan. Nur bei EINDEUTIGEM Treffer wird umbenannt. Wirft nie.
|
||||||
|
"""
|
||||||
|
if meta.get("source") != "tmdb" or meta.get("type") != "tv" or not meta.get("id"):
|
||||||
|
return ["Episoden-Zuordnung übersprungen (nur mit TMDB-Serien-Treffer möglich)"]
|
||||||
|
try:
|
||||||
|
antwort = requests.get(
|
||||||
|
f"{API_URL}/metadata/tv/{meta['id']}/season/{int(staffel)}", timeout=30
|
||||||
|
)
|
||||||
|
if antwort.status_code != 200:
|
||||||
|
return [f"Episoden-Laufzeiten nicht abrufbar (HTTP {antwort.status_code})"]
|
||||||
|
episoden = [
|
||||||
|
(e["episode"], (e.get("runtime") or 0) * 60)
|
||||||
|
for e in antwort.json().get("episodes", [])
|
||||||
|
]
|
||||||
|
except (requests.RequestException, ValueError) as e:
|
||||||
|
return [f"Episoden-Laufzeiten nicht abrufbar: {e}"]
|
||||||
|
if not episoden:
|
||||||
|
return ["Keine Episoden-Laufzeiten bei TMDB hinterlegt — Dateinamen bleiben"]
|
||||||
|
|
||||||
|
dateien = sorted(
|
||||||
|
os.path.join(ausgabe, f) for f in os.listdir(ausgabe) if f.endswith(".mkv")
|
||||||
|
)
|
||||||
|
if not dateien:
|
||||||
|
return []
|
||||||
|
dauern = [lies_datei_dauer(p) for p in dateien]
|
||||||
|
if 0 in dauern:
|
||||||
|
return ["Datei-Laufzeiten nicht lesbar — Dateinamen bleiben"]
|
||||||
|
|
||||||
|
zuordnung = medien.matche_episoden(dauern, episoden)
|
||||||
|
if not zuordnung:
|
||||||
|
return [
|
||||||
|
"Keine EINDEUTIGE Episoden-Zuordnung über die Laufzeiten — "
|
||||||
|
"Dateinamen bleiben (lieber ehrlich als falsch benannt)"
|
||||||
|
]
|
||||||
|
return medien.episoden_umbenennen(ausgabe, serie, staffel, zuordnung)
|
||||||
|
|
||||||
|
|
||||||
|
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
||||||
|
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
url = (einstellungen.get("notificationWebhook") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
notify.sende(url, betreff, text, level)
|
||||||
|
except Exception as e:
|
||||||
|
db.add_log("warning", "notify", f"Job {job_id}: Benachrichtigung fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||||
|
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres,
|
||||||
|
bereitet bei Erfolg fürs Medien-System auf (NFO/Poster) und meldet das
|
||||||
|
Ende per Webhook (falls konfiguriert)."""
|
||||||
|
job = db.get_job(job_id) or {}
|
||||||
|
titel = job.get("title") or job_id[:8]
|
||||||
|
|
||||||
|
if ergebnis.get("status") == "cancelled":
|
||||||
|
db.update_job(
|
||||||
|
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
||||||
|
_benachrichtigen(job_id, f'⚠️ Rippy: „{titel}" abgebrochen',
|
||||||
|
"Der Job wurde auf Wunsch abgebrochen — Rohdaten bleiben erhalten.",
|
||||||
|
"warning")
|
||||||
|
return
|
||||||
|
if ergebnis.get("status") == "success":
|
||||||
|
ausgabe = ergebnis.get("output_dir")
|
||||||
|
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
serie = meta.get("series")
|
||||||
|
staffel = meta.get("season")
|
||||||
|
|
||||||
|
# Serien-Rips: Episoden per Laufzeit zuordnen und umbenennen
|
||||||
|
# (Show S01E02.mkv) — nur bei EINDEUTIGER Zuordnung, sonst
|
||||||
|
# bleiben die MakeMKV-Namen (ehrlich geloggt).
|
||||||
|
if serie and staffel:
|
||||||
|
for meldung in _serien_episoden_zuordnen(ausgabe, serie, staffel, meta):
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
|
|
||||||
|
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster);
|
||||||
|
# bei Serien wandern tvshow.nfo/poster in den Serien-Ordner.
|
||||||
|
for meldung in medien.aufbereiten(
|
||||||
|
ausgabe, einstellungen.get("mediaServer") or "none",
|
||||||
|
serie or job.get("title") or "", meta.get("year"), meta,
|
||||||
|
serien_root=os.path.dirname(ausgabe) if serie and staffel else None,
|
||||||
|
):
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
|
|
||||||
|
# Bibliotheks-Refresh (Jellyfin/Emby): der Server scannt sofort
|
||||||
|
meldung = medien.bibliothek_refresh(
|
||||||
|
einstellungen.get("mediaServer") or "none",
|
||||||
|
(einstellungen.get("jellyfinUrl") or "").strip(),
|
||||||
|
(einstellungen.get("jellyfinApiKey") or "").strip(),
|
||||||
|
)
|
||||||
|
if meldung:
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
progress=100,
|
||||||
|
output_path=ausgabe,
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ausgabe}")
|
||||||
|
_benachrichtigen(job_id, f'✅ Rippy: „{titel}" ist fertig',
|
||||||
|
f"Abgelegt unter: {ausgabe}", "success")
|
||||||
|
else:
|
||||||
|
fehler = ergebnis.get("error", "unbekannter Fehler")
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
error=fehler,
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log("error", "worker", f"Job {job_id}: {fehler}")
|
||||||
|
_benachrichtigen(job_id, f'❌ Rippy: „{titel}" fehlgeschlagen', fehler, "error")
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||||
def rip_disc(self, device_path: str, job_id: str):
|
def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API."""
|
"""Stufe 1: Rippt die Disc; bei Video folgt die Kompression als eigener Task.
|
||||||
|
|
||||||
|
target_dir (optional): vom Nutzer gewähltes Ablageziel unter /app/media —
|
||||||
|
dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar.
|
||||||
|
"""
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
||||||
|
if detect_disc_type is None:
|
||||||
|
ergebnis = {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Dieser Worker kann nur komprimieren (nativer "
|
||||||
|
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
|
||||||
|
"wird auf der Rippy-Hauptmaschine.",
|
||||||
|
}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
_job_abschliessen(job_id, {"status": "cancelled"})
|
||||||
|
return {"status": "cancelled"}
|
||||||
|
|
||||||
disc_type = detect_disc_type(device_path)
|
disc_type = detect_disc_type(device_path)
|
||||||
|
|
||||||
if disc_type in ("no_disc", "unknown"):
|
if disc_type in ("no_disc", "unknown"):
|
||||||
@@ -22,9 +322,7 @@ def rip_disc(self, device_path: str, job_id: str):
|
|||||||
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
||||||
else "Disc-Typ nicht erkennbar"
|
else "Disc-Typ nicht erkennbar"
|
||||||
)
|
)
|
||||||
db.update_job(
|
db.update_job(job_id, status="failed", error=fehler, finished_at=db.utcnow())
|
||||||
job_id, status="failed", error=fehler, finished_at=db.utcnow()
|
|
||||||
)
|
|
||||||
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
||||||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||||
|
|
||||||
@@ -38,32 +336,234 @@ def rip_disc(self, device_path: str, job_id: str):
|
|||||||
if progress == letzter[0]:
|
if progress == letzter[0]:
|
||||||
return
|
return
|
||||||
letzter[0] = progress
|
letzter[0] = progress
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
raise RipAbbruch()
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"progress": progress, "status": "ripping", "message": message},
|
meta={"progress": progress, "status": "ripping", "message": message},
|
||||||
)
|
)
|
||||||
db.update_job(job_id, progress=progress)
|
db.update_job(job_id, progress=progress)
|
||||||
|
|
||||||
if disc_type == "cd":
|
einstellungen = db.get_settings()
|
||||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
else:
|
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
|
||||||
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
|
|
||||||
if ergebnis.get("status") == "success":
|
|
||||||
db.update_job(
|
|
||||||
job_id,
|
|
||||||
status="completed",
|
|
||||||
progress=100,
|
|
||||||
output_path=ergebnis.get("output_dir"),
|
|
||||||
finished_at=db.utcnow(),
|
|
||||||
)
|
|
||||||
db.add_log("success", "worker", f"Job {job_id}: Rip abgeschlossen → {ergebnis.get('output_dir')}")
|
|
||||||
else:
|
|
||||||
db.update_job(
|
|
||||||
job_id,
|
|
||||||
status="failed",
|
|
||||||
error=ergebnis.get("error", "unbekannter Fehler"),
|
|
||||||
finished_at=db.utcnow(),
|
|
||||||
)
|
|
||||||
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
|
||||||
|
|
||||||
|
if ist_video:
|
||||||
|
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||||
|
_makemkv_key_anwenden(einstellungen)
|
||||||
|
|
||||||
|
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||||||
|
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||||||
|
# Serien-Rips landen stattdessen in <Serie>/Season NN (Staffel-Flow).
|
||||||
|
job = db.get_job(job_id) or {}
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
if meta.get("series") and meta.get("season"):
|
||||||
|
final_dir = medien.serien_ordner(
|
||||||
|
_zielbasis(target_dir, disc_type), meta["series"], meta["season"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_dir = medien.zielordner(
|
||||||
|
_zielbasis(target_dir, disc_type),
|
||||||
|
job.get("title") or "", meta.get("year"), job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
|
||||||
|
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
|
||||||
|
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
|
||||||
|
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
|
||||||
|
nur_hauptfilm = meta.get("main_feature_only")
|
||||||
|
if nur_hauptfilm is None:
|
||||||
|
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||||||
|
if meta.get("series") or titel_liste:
|
||||||
|
nur_hauptfilm = False
|
||||||
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
|
db.update_job(job_id, output_path=final_dir)
|
||||||
|
|
||||||
|
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id)
|
||||||
|
|
||||||
|
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||||
|
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||||
|
try:
|
||||||
|
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
|
||||||
|
except OSError:
|
||||||
|
disc_bytes = 0
|
||||||
|
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||||
|
if ist_video:
|
||||||
|
rip_ziel = raw_dir if transcode_an else final_dir
|
||||||
|
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||||||
|
if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
|
||||||
|
fehler = _platz_pruefen(final_dir, disc_bytes + marge, "das Original (keepOriginal)")
|
||||||
|
if fehler:
|
||||||
|
ergebnis = {"status": "error", "error": fehler}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
if disc_type == "cd":
|
||||||
|
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
||||||
|
elif transcode_an:
|
||||||
|
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||||
|
ergebnis = rip_video(
|
||||||
|
device_path, job_id, disc_type,
|
||||||
|
progress_cb=fortschritt,
|
||||||
|
output_dir=raw_dir,
|
||||||
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
|
titel_liste=titel_liste,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ergebnis = rip_video(
|
||||||
|
device_path, job_id, disc_type,
|
||||||
|
progress_cb=fortschritt, output_dir=final_dir,
|
||||||
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
|
titel_liste=titel_liste,
|
||||||
|
)
|
||||||
|
|
||||||
|
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
||||||
|
# niemandem. Zwei bekannte Ursachen (Befunde 24.07., Summer-Wars-UHD):
|
||||||
|
if ergebnis.get("status") == "error" and disc_type == "uhd":
|
||||||
|
fehler_text = ergebnis.get("error") or ""
|
||||||
|
if "volume key is unknown" in fehler_text:
|
||||||
|
# LibreDrive lief bereits — MakeMKV kennt nur den Disc-Schlüssel
|
||||||
|
# nicht: Version zu alt ODER Disc neuer als die Key-Datenbank.
|
||||||
|
ergebnis["error"] += (
|
||||||
|
" — Klartext: Das Laufwerk liest die Disc (LibreDrive OK), "
|
||||||
|
"aber MakeMKV kennt den Schlüssel dieser Disc nicht. Erst "
|
||||||
|
"prüfen: MakeMKV aktuell? (Einstellungen → System; Update = "
|
||||||
|
"Image-Rebuild). Ist es aktuell, ist die Disc neuer als die "
|
||||||
|
"Schlüssel-Datenbank — MakeMKV hat einen AACS-Dump unter "
|
||||||
|
"/root/.MakeMKV/ im Worker gespeichert; im MakeMKV-Forum "
|
||||||
|
"(Bereich 'Ultra HD Blu-ray') einreichen, mit einem der "
|
||||||
|
"nächsten Updates ist die Disc dann rippbar."
|
||||||
|
)
|
||||||
|
elif "Failed to open disc" in fehler_text:
|
||||||
|
ergebnis["error"] += (
|
||||||
|
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
||||||
|
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
||||||
|
"(MakeMKV-Forum 'Ultimate UHD Drives Flashing Guide'). "
|
||||||
|
"Normale BD/DVD gehen weiterhin."
|
||||||
|
)
|
||||||
|
|
||||||
|
if ergebnis.get("status") == "success" and transcode_an:
|
||||||
|
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||||
|
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||||
|
# freier Worker, inkl. Remote-GPU).
|
||||||
|
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||||
|
db.update_job(job_id, status="transcoding", progress=0)
|
||||||
|
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||||
|
db.add_log("info", "worker",
|
||||||
|
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||||
|
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
|
||||||
|
transcode_files.apply_async(
|
||||||
|
args=[job_id, raw_dir, final_dir],
|
||||||
|
queue=ziel_queue,
|
||||||
|
)
|
||||||
|
return {"status": "ripped", "raw_dir": raw_dir}
|
||||||
|
|
||||||
|
_job_abschliessen(job_id, 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")
|
||||||
|
def scan_tracks(device_path: str):
|
||||||
|
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||||
|
|
||||||
|
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
|
||||||
|
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
|
||||||
|
Laufwerk). Ein Info-Lauf dauert je nach Disc 20–120 s.
|
||||||
|
"""
|
||||||
|
db.init_db()
|
||||||
|
key = f"tracks:{device_path}"
|
||||||
|
db.save_settings({"status": "running"}, key)
|
||||||
|
try:
|
||||||
|
titel = lies_titel_info(device_path)
|
||||||
|
db.save_settings({"status": "done", "tracks": titel}, key)
|
||||||
|
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
||||||
|
return {"status": "done", "anzahl": len(titel)}
|
||||||
|
except Exception as e:
|
||||||
|
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||||
|
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
||||||
|
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||||
|
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||||
|
|
||||||
|
Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis
|
||||||
|
gelöscht — bricht die Kompression ab, bleibt das Original in /app/temp
|
||||||
|
liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530).
|
||||||
|
Über POST /jobs/{id}/retry-transcode jederzeit neu anstoßbar.
|
||||||
|
"""
|
||||||
|
db.init_db()
|
||||||
|
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
||||||
|
raw_dir = pfad_lokal(raw_dir)
|
||||||
|
final_dir = pfad_lokal(final_dir)
|
||||||
|
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
|
||||||
|
if not quellen:
|
||||||
|
ergebnis = {"status": "error", "error": f"Keine Roh-MKVs in {raw_dir} gefunden"}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
|
||||||
|
original_behalten = einstellungen.get("keepOriginal", False)
|
||||||
|
|
||||||
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
|
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||||
|
db.add_log(
|
||||||
|
"info", "worker",
|
||||||
|
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')",
|
||||||
|
)
|
||||||
|
|
||||||
|
anzahl = len(quellen)
|
||||||
|
letzter = [-1]
|
||||||
|
for index, quelle in enumerate(quellen):
|
||||||
|
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||||||
|
|
||||||
|
def datei_fortschritt(p, _index=index):
|
||||||
|
gesamt = int((_index * 100 + p) / anzahl)
|
||||||
|
if gesamt == letzter[0]:
|
||||||
|
return
|
||||||
|
letzter[0] = gesamt
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
raise RipAbbruch()
|
||||||
|
db.update_job(job_id, progress=min(99, gesamt))
|
||||||
|
|
||||||
|
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
|
||||||
|
if hb.get("status") == "cancelled":
|
||||||
|
_job_abschliessen(job_id, hb)
|
||||||
|
return hb
|
||||||
|
if hb.get("status") != "success":
|
||||||
|
ergebnis = {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||||
|
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
if original_behalten:
|
||||||
|
ziel_original = os.path.join(final_dir, "original")
|
||||||
|
shutil.move(raw_dir, ziel_original)
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
ergebnis = {"status": "success", "output_dir": final_dir}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from detection import (
|
|||||||
CDS_AUDIO,
|
CDS_AUDIO,
|
||||||
CDS_DATA_1,
|
CDS_DATA_1,
|
||||||
CDS_MIXED,
|
CDS_MIXED,
|
||||||
|
UHD_MIN_BYTES,
|
||||||
classify,
|
classify,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,18 @@ def test_bluray_ab_schwelle():
|
|||||||
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
|
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bd50_bleibt_bluray():
|
||||||
|
# BD-50 = 50 * 10^9 Bytes ≈ 46,6 GiB — knapp unter der UHD-Schwelle
|
||||||
|
assert classify(CDS_DATA_1, 50 * 1000**3) == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uhd_ab_schwelle():
|
||||||
|
# BD-66 (66 GB) und BD-100 sind 4K-UHD-Medien
|
||||||
|
assert classify(CDS_DATA_1, 66 * 1000**3) == "uhd"
|
||||||
|
assert classify(CDS_DATA_1, 100 * 1000**3) == "uhd"
|
||||||
|
assert classify(CDS_DATA_1, UHD_MIN_BYTES) == "uhd"
|
||||||
|
|
||||||
|
|
||||||
def test_unbekannter_status():
|
def test_unbekannter_status():
|
||||||
assert classify(999, 5 * 1024**3) == "unknown"
|
assert classify(999, 5 * 1024**3) == "unknown"
|
||||||
assert classify(0, 0) == "unknown"
|
assert classify(0, 0) == "unknown"
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests für die Media-Server-Aufbereitung (Benennung, NFO, Serien-Matching)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from medien import baue_nfo, matche_episoden, serien_ordner, sicherer_name, zielordner
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_entfernt_verbotene_zeichen():
|
||||||
|
assert sicherer_name('Alien: Die Wiedergeburt') == "Alien Die Wiedergeburt"
|
||||||
|
assert sicherer_name('Was/ist\\das?<>|*"') == "Wasistdas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_mit_jahr():
|
||||||
|
assert sicherer_name("Evangelion 2.22", 2009) == "Evangelion 2.22 (2009)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_kollabiert_leerraum_und_punkte():
|
||||||
|
assert sicherer_name(" Viel Raum ... ") == "Viel Raum"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_leer_bleibt_leer():
|
||||||
|
assert sicherer_name("") == ""
|
||||||
|
assert sicherer_name("???") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_nutzt_titel_und_jahr(tmp_path):
|
||||||
|
pfad = zielordner(str(tmp_path), "Inception", 2010, "abc-123")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "Inception (2010)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_faellt_auf_job_id_zurueck(tmp_path):
|
||||||
|
pfad = zielordner(str(tmp_path), "", None, "abc-123")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "abc-123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_weicht_bei_kollision_aus(tmp_path):
|
||||||
|
os.makedirs(tmp_path / "Inception (2010)")
|
||||||
|
pfad = zielordner(str(tmp_path), "Inception", 2010, "abcdef12-3456")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "Inception (2010) [abcdef12]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_baue_nfo_film_mit_plot_und_genres():
|
||||||
|
nfo = baue_nfo(
|
||||||
|
{"overview": "Ein Traum <im> Traum & so", "genres": ["Sci-Fi", "Action"]},
|
||||||
|
"Inception", 2010,
|
||||||
|
)
|
||||||
|
assert "<movie>" in nfo and "</movie>" in nfo
|
||||||
|
assert "<title>Inception</title>" in nfo
|
||||||
|
assert "<year>2010</year>" in nfo
|
||||||
|
# XML-Escaping: <, >, & dürfen den Parser nicht sprengen
|
||||||
|
assert "Ein Traum <im> Traum & so" in nfo
|
||||||
|
assert "<genre>Sci-Fi</genre>" in nfo
|
||||||
|
|
||||||
|
|
||||||
|
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
|
||||||
|
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
|
||||||
|
assert "<tvshow>" in nfo and "</tvshow>" in nfo
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_haengt_jahr_nicht_doppelt_an():
|
||||||
|
"""OMDb-Titel tragen das Jahr teils schon — kein 'X (2009) (2009)' mehr."""
|
||||||
|
assert sicherer_name("Summer Wars (2009)", 2009) == "Summer Wars (2009)"
|
||||||
|
assert sicherer_name("Summer Wars", 2009) == "Summer Wars (2009)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serien_ordner_jellyfin_schema():
|
||||||
|
assert serien_ordner("/app/media/series", "Neon Genesis: Evangelion", 2) == os.path.join(
|
||||||
|
"/app/media/series", "Neon Genesis Evangelion", "Season 02"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_komplette_staffel_auch_bei_uniformen_laufzeiten():
|
||||||
|
# Anime-Standardfall: ganze Staffel auf der Disc, alle Episoden ~24 min
|
||||||
|
dateien = [1440, 1452, 1431]
|
||||||
|
episoden = [(1, 1440), (2, 1440), (3, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_teil_disc_mit_distinkten_laufzeiten():
|
||||||
|
# Disc 2 einer Staffel: Laufzeiten unterscheiden sich klar → zuordenbar
|
||||||
|
dateien = [1440, 2650]
|
||||||
|
episoden = [(1, 700), (2, 1440), (3, 2650)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_haelt_reihenfolge():
|
||||||
|
# Datei 2 ist ein Doppellänger → muss auf die SPÄTERE lange Episode fallen
|
||||||
|
dateien = [1440, 2880]
|
||||||
|
episoden = [(1, 1440), (2, 2880)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_mehrdeutig_gibt_none():
|
||||||
|
# Alle Episoden gleich lang und mehr Kandidaten als Dateien in Toleranz →
|
||||||
|
# Datei 1 passt auf Ep 1 UND Ep 2 gleich gut → keine eindeutige Wahl
|
||||||
|
dateien = [1440]
|
||||||
|
episoden = [(1, 1440), (2, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_ohne_treffer_gibt_none():
|
||||||
|
dateien = [600] # 10 min passt auf keine 24-min-Episode
|
||||||
|
episoden = [(1, 1440), (2, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||||
|
from tasks import pfad_lokal
|
||||||
|
|
||||||
|
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
||||||
|
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
||||||
|
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
|
||||||
|
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
|
||||||
|
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
|
||||||
|
|
||||||
|
from notify import baue_payload, erkenne_webhook_typ
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_wird_an_url_erkannt():
|
||||||
|
assert erkenne_webhook_typ("https://discord.com/api/webhooks/1/abc") == "discord"
|
||||||
|
assert erkenne_webhook_typ("https://discordapp.com/api/webhooks/1/abc") == "discord"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slack_und_ntfy_und_generisch():
|
||||||
|
assert erkenne_webhook_typ("https://hooks.slack.com/services/T/B/x") == "slack"
|
||||||
|
assert erkenne_webhook_typ("https://ntfy.sh/mein-thema") == "ntfy"
|
||||||
|
assert erkenne_webhook_typ("https://ha.local/api/webhook/rippy") == "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_payload_nutzt_content():
|
||||||
|
typ, payload = baue_payload("https://discord.com/api/webhooks/1/a", "Titel", "Text")
|
||||||
|
assert typ == "discord"
|
||||||
|
assert payload == {"content": "**Titel**\nText"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_slack_payload_nutzt_text():
|
||||||
|
typ, payload = baue_payload("https://hooks.slack.com/services/x", "Titel", "Text")
|
||||||
|
assert typ == "slack"
|
||||||
|
assert payload == {"text": "*Titel*\nText"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ntfy_sendet_rohtext():
|
||||||
|
typ, payload = baue_payload("https://ntfy.sh/thema", "Titel", "Text")
|
||||||
|
assert typ == "ntfy"
|
||||||
|
assert payload is None # Roh-Text-Body, kein JSON
|
||||||
|
|
||||||
|
|
||||||
|
def test_generischer_payload_traegt_level():
|
||||||
|
typ, payload = baue_payload("https://example.org/hook", "Titel", "Text", "error")
|
||||||
|
assert typ == "generisch"
|
||||||
|
assert payload == {"title": "Titel", "message": "Text", "level": "error"}
|
||||||
@@ -9,7 +9,9 @@ import os
|
|||||||
|
|
||||||
from ripping import (
|
from ripping import (
|
||||||
build_abcde_cmd,
|
build_abcde_cmd,
|
||||||
|
build_handbrake_cmd,
|
||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
@@ -19,12 +21,33 @@ def test_makemkv_cmd_vollstaendig():
|
|||||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||||
assert cmd[0] == "makemkvcon"
|
assert cmd[0] == "makemkvcon"
|
||||||
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
|
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
||||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||||
assert "mkv" in cmd
|
assert "mkv" in cmd
|
||||||
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
||||||
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
||||||
|
"""HandBrake kann AACS nicht — es bekommt IMMER die MKV-Datei aus dem
|
||||||
|
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
||||||
|
für Blu-rays prinzipiell funktionsunfähig)."""
|
||||||
|
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
||||||
|
assert cmd[0] == "HandBrakeCLI"
|
||||||
|
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||||
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
|
assert "--preset" in cmd
|
||||||
|
assert "--all-audio" in cmd # alle Sprachen behalten
|
||||||
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_progress_parsing():
|
||||||
|
# Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||||
|
assert get_progress_from_line("kein Fortschritt hier") == 0
|
||||||
|
|
||||||
|
|
||||||
def test_prgv_parsing():
|
def test_prgv_parsing():
|
||||||
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
||||||
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests für die Hauptfilm-Auswahl (TINFO-Parsing) und Datei-Laufzeiten."""
|
||||||
|
|
||||||
|
from ripping import (
|
||||||
|
build_makemkv_cmd,
|
||||||
|
laengster_titel,
|
||||||
|
parse_scan_dauer,
|
||||||
|
parse_tinfo_dauern,
|
||||||
|
parse_titel_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
|
||||||
|
# TINFO:<titel>,<attr>,<code>,"wert" — Attribut 9 = Laufzeit "H:MM:SS"
|
||||||
|
TINFO_BEISPIEL = """
|
||||||
|
TINFO:0,2,0,"Hauptfilm"
|
||||||
|
TINFO:0,9,0,"1:52:29"
|
||||||
|
TINFO:1,9,0,"0:03:12"
|
||||||
|
TINFO:2,9,0,"0:24:01"
|
||||||
|
MSG:1011,0,1,"Using LibreDrive mode","%1","x"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_tinfo_dauern():
|
||||||
|
dauern = parse_tinfo_dauern(TINFO_BEISPIEL)
|
||||||
|
assert dauern == {0: 6749, 1: 192, 2: 1441}
|
||||||
|
|
||||||
|
|
||||||
|
def test_laengster_titel_ist_der_hauptfilm():
|
||||||
|
assert laengster_titel({0: 6749, 1: 192, 2: 1441}) == 0
|
||||||
|
assert laengster_titel({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_makemkv_cmd_mit_titelwahl():
|
||||||
|
cmd = build_makemkv_cmd("/dev/sr0", "/out", titel="7")
|
||||||
|
assert cmd[-2:] == ["7", "/out"]
|
||||||
|
assert "all" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_makemkv_cmd_default_bleibt_all():
|
||||||
|
cmd = build_makemkv_cmd("/dev/sr0", "/out")
|
||||||
|
assert cmd[-2:] == ["all", "/out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_titel_info_volle_tabelle():
|
||||||
|
"""Attr 8=Kapitel, 9=Dauer, 11=Bytes (MakeMKV apdefs.h) → Auswahl-Tabelle."""
|
||||||
|
ausgabe = """
|
||||||
|
TINFO:0,8,0,"24"
|
||||||
|
TINFO:0,9,0,"1:52:29"
|
||||||
|
TINFO:0,11,0,"31234567890"
|
||||||
|
TINFO:1,9,0,"0:03:12"
|
||||||
|
TINFO:1,11,0,"98765432"
|
||||||
|
"""
|
||||||
|
titel = parse_titel_info(ausgabe)
|
||||||
|
assert titel == [
|
||||||
|
{"nr": 0, "dauer_s": 6749, "groesse_bytes": 31234567890, "kapitel": 24},
|
||||||
|
{"nr": 1, "dauer_s": 192, "groesse_bytes": 98765432, "kapitel": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_scan_dauer():
|
||||||
|
ausgabe = "…\n + duration: 01:52:29\n…"
|
||||||
|
assert parse_scan_dauer(ausgabe) == 6749
|
||||||
|
assert parse_scan_dauer("kein treffer") == 0
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
||||||
|
|
||||||
|
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
|
||||||
|
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, Beenden. Gestartet
|
||||||
|
wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft
|
||||||
|
pythonw.exe auf — kein Konsolenfenster). Der Worker-Prozess (celery,
|
||||||
|
Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray.
|
||||||
|
|
||||||
|
Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
|
||||||
|
(install.ps1) — das Docker-Image bleibt davon unberührt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pystray
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
except ImportError:
|
||||||
|
print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
||||||
|
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||||
|
LOG_PFAD = os.path.join(BASIS, "worker.log")
|
||||||
|
|
||||||
|
prozess = None
|
||||||
|
|
||||||
|
|
||||||
|
def disc_bild(laeuft: bool) -> Image.Image:
|
||||||
|
"""Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst."""
|
||||||
|
bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||||
|
zeichner = ImageDraw.Draw(bild)
|
||||||
|
farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255)
|
||||||
|
zeichner.ellipse([4, 4, 60, 60], fill=farbe)
|
||||||
|
zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255))
|
||||||
|
return bild
|
||||||
|
|
||||||
|
|
||||||
|
def worker_laeuft() -> bool:
|
||||||
|
return prozess is not None and prozess.poll() is None
|
||||||
|
|
||||||
|
|
||||||
|
def worker_starten(icon=None, item=None):
|
||||||
|
global prozess
|
||||||
|
if worker_laeuft():
|
||||||
|
return
|
||||||
|
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
||||||
|
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
||||||
|
prozess = subprocess.Popen(
|
||||||
|
[celery, "-A", "celery_app", "worker", "--loglevel=info",
|
||||||
|
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
|
||||||
|
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
if icon:
|
||||||
|
icon.icon = disc_bild(True)
|
||||||
|
icon.update_menu()
|
||||||
|
|
||||||
|
|
||||||
|
def worker_stoppen(icon=None, item=None):
|
||||||
|
global prozess
|
||||||
|
if worker_laeuft():
|
||||||
|
prozess.terminate()
|
||||||
|
try:
|
||||||
|
prozess.wait(15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
prozess.kill()
|
||||||
|
prozess = None
|
||||||
|
if icon:
|
||||||
|
icon.icon = disc_bild(False)
|
||||||
|
icon.update_menu()
|
||||||
|
|
||||||
|
|
||||||
|
def umschalten(icon, item):
|
||||||
|
if worker_laeuft():
|
||||||
|
worker_stoppen(icon)
|
||||||
|
else:
|
||||||
|
worker_starten(icon)
|
||||||
|
|
||||||
|
|
||||||
|
def rippy_oeffnen(icon, item):
|
||||||
|
if RIPPY_HOST:
|
||||||
|
webbrowser.open(f"http://{RIPPY_HOST}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_anzeigen(icon, item):
|
||||||
|
if os.path.exists(LOG_PFAD):
|
||||||
|
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
||||||
|
|
||||||
|
|
||||||
|
def beenden(icon, item):
|
||||||
|
worker_stoppen()
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
|
||||||
|
menue = pystray.Menu(
|
||||||
|
pystray.MenuItem(
|
||||||
|
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
|
||||||
|
None, enabled=False,
|
||||||
|
),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem(
|
||||||
|
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
||||||
|
umschalten,
|
||||||
|
),
|
||||||
|
pystray.MenuItem("Rippy öffnen", rippy_oeffnen),
|
||||||
|
pystray.MenuItem("Log anzeigen", log_anzeigen),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
|
||||||
|
worker_starten()
|
||||||
|
tray.icon = disc_bild(worker_laeuft())
|
||||||
|
tray.run()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.tar.gz
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# MakeMKV-Tarballs hier ablegen (makemkv-oss-X.tar.gz + makemkv-bin-X.tar.gz),
|
||||||
|
# wenn Cloudflare die Build-Downloads drosselt — der Build nutzt sie dann
|
||||||
|
# statt des Downloads. Bleiben untracked (siehe .gitignore).
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# Design 2.0 — Briefing (Übergabe an Gemini)
|
||||||
|
|
||||||
|
> ✅ **ERLEDIGT (24.07.2026).** Design 2.0 wurde umgesetzt und ist in `main`.
|
||||||
|
> Der Arbeitsbranch `design-2.0` wurde nach dem Merge gelöscht (es gibt nur
|
||||||
|
> noch `main`). Dieses Dokument bleibt als historische Auftragsbeschreibung.
|
||||||
|
>
|
||||||
|
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
|
||||||
|
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
||||||
|
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Auftrag in einem Satz
|
||||||
|
|
||||||
|
Das Rippy-Web-UI von 428 handgeschriebenen `theme === 'dark' ? … : …`-
|
||||||
|
Ternaries auf ein **sauberes, token-basiertes Tailwind-`dark:`-System**
|
||||||
|
umstellen und dabei die Optik auf ein modernes, konsistentes „Design 2.0"
|
||||||
|
heben — **ohne** Funktion, API-Aufrufe, deutsche Texte oder die
|
||||||
|
Deploy-Kette anzufassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Was Rippy ist (Kontext in 30 Sekunden)
|
||||||
|
|
||||||
|
Rippy ist eine self-hosted Disc-Ripping-Maschine (CD/DVD/Blu-ray/4K-UHD):
|
||||||
|
Disc rein → automatisch erkannt → verlustfrei gerippt (MakeMKV) →
|
||||||
|
komprimiert (HandBrake) → Media-Server-fertig abgelegt. Alles Docker,
|
||||||
|
alles im Heimnetz. Das UI ist die einzige Bedienoberfläche.
|
||||||
|
|
||||||
|
**Tech-Stack UI** (`docker/ui/`):
|
||||||
|
- React 18 + TypeScript + Vite
|
||||||
|
- TailwindCSS (`darkMode: 'class'`)
|
||||||
|
- lucide-react (Icons)
|
||||||
|
- Kein Router (Seitenwechsel über `useState` in `App.tsx`)
|
||||||
|
- State: lokale Hooks + Polling gegen die REST-API (`src/lib/api.ts`,
|
||||||
|
axios, `baseURL: '/api'`)
|
||||||
|
|
||||||
|
**Sprach-Konvention (AGENTS.md Regel 4):** Variablen-/Funktionsnamen auf
|
||||||
|
Englisch, **alle Kommentare, UI-Texte und Docs auf Deutsch**. Der Betreiber
|
||||||
|
(„Commander") ist Nicht-Entwickler und liest alles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Der Kern des Problems
|
||||||
|
|
||||||
|
Jede Komponente entscheidet Farben **zur Laufzeit per Ternary**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SO sieht es HEUTE ~428× aus:
|
||||||
|
<div className={`rounded-xl border ${theme === 'dark'
|
||||||
|
? 'bg-slate-800 border-slate-700'
|
||||||
|
: 'bg-white border-slate-200'}`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist der größte technische Schuldenberg des UI:
|
||||||
|
- **428 Ternaries** in 18 Dateien (Inventar unten) — jede Farbe doppelt
|
||||||
|
gepflegt, Copy-Paste-Fehler vorprogrammiert.
|
||||||
|
- Jede Komponente zieht dafür `const { theme } = useDarkMode()` — Rendering
|
||||||
|
hängt an einem Context-Wert, den es fast nie bräuchte.
|
||||||
|
- Wiederkehrende Farb-Mappings sind **mehrfach dupliziert** statt zentral:
|
||||||
|
`StatusBadge` (Dashboard), `getLevelColor` (Logs), `getTypeColor`
|
||||||
|
(DeviceDiscovery) definieren jeweils eigene Status→Farbe-Tabellen.
|
||||||
|
|
||||||
|
### Warum das JETZT ein leichter Umbau ist
|
||||||
|
|
||||||
|
Die Infrastruktur für den sauberen Weg **steht bereits vollständig**:
|
||||||
|
- `tailwind.config.js`: `darkMode: 'class'` ✅
|
||||||
|
- `ThemeContext.tsx` setzt beim Umschalten `document.documentElement.
|
||||||
|
classList.add('dark')` — also `.dark` auf `<html>` ✅
|
||||||
|
- Damit funktionieren Tailwinds `dark:`-Varianten sofort überall.
|
||||||
|
|
||||||
|
Konkret heißt Migration also fast immer nur:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// NACHHER — kein Ternary, kein useDarkMode nötig:
|
||||||
|
<div className="rounded-xl border bg-white border-slate-200
|
||||||
|
dark:bg-slate-800 dark:border-slate-700">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drei Altlasten, die mit aufgeräumt werden sollten
|
||||||
|
|
||||||
|
1. **Tote CSS-Variablen** (`src/index.css`): Es gibt `:root { --bg-color … }`
|
||||||
|
und `[data-theme="dark"] { … }`. Der Dark-Block ist **toter Code** —
|
||||||
|
nichts setzt jemals `data-theme` (der Theme-Umschalter nutzt die
|
||||||
|
`.dark`-Klasse). Entweder die Variablen korrekt an `.dark` binden ODER
|
||||||
|
(empfohlen) ganz auf Tailwind-`dark:` setzen und die Variablen entfernen.
|
||||||
|
2. **Globale Transition** (`src/index.css`): `* { transition:
|
||||||
|
background-color .3s, color .3s, border-color .3s; }` lässt bei jedem
|
||||||
|
Theme-Wechsel die GESAMTE Seite animieren (Jank-Risiko) und macht die
|
||||||
|
überall verstreuten `transition-colors duration-300`-Klassen doppelt.
|
||||||
|
Auf einen bewussten, schmalen Transition-Ansatz reduzieren.
|
||||||
|
3. **Icon-Inkonsistenz**: Fast alles nutzt `lucide-react`, aber
|
||||||
|
`ConfirmDialog.tsx` nutzt Emoji (⚠️✅🤔) als „Icons". Vereinheitlichen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Empfohlener Weg (technisch)
|
||||||
|
|
||||||
|
**Nicht** blind 428 Ternaries per Hand ersetzen — das ist fehleranfällig.
|
||||||
|
Stattdessen in zwei Schichten:
|
||||||
|
|
||||||
|
### Schicht A — Wiederverwendbare Primitives (`src/components/ui/`)
|
||||||
|
|
||||||
|
Ein kleines Set typisierter Bausteine, die das Design kapseln. Heute wird
|
||||||
|
jedes davon in jeder Datei neu zusammengeklöppelt:
|
||||||
|
|
||||||
|
- `Card` / `CardHeader` — die `rounded-xl border bg-white dark:bg-slate-800`-
|
||||||
|
Kachel (kommt ~30× vor)
|
||||||
|
- `Button` (Varianten: primary / secondary / ghost / danger)
|
||||||
|
- `Badge` (Status/Typ-Pill mit zentraler Farbtabelle — löst die 3 Duplikate)
|
||||||
|
- `Input` / `Select` / `Toggle` (Formularfelder, heute inline-dupliziert)
|
||||||
|
- `Modal` (der `fixed inset-0 … backdrop-blur`-Rahmen — 4× dupliziert:
|
||||||
|
RipTargetModal, JobDetailModal, MetadataKorrektur, ConfirmDialog)
|
||||||
|
- `Section` / `PageHeader` (Seiten-Titel + Untertitel, überall gleich)
|
||||||
|
|
||||||
|
Farbwelt/Status **einmal zentral** definieren (z. B.
|
||||||
|
`src/lib/design.ts`), damit Status→Farbe nur an EINER Stelle lebt:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const STATUS_STYLES = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
transcoding: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||||
|
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||||
|
// Disc-Typen: cd=blue, dvd=purple, bluray=pink, uhd=amber
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schicht B — Seiten/Komponenten auf die Primitives umstellen
|
||||||
|
|
||||||
|
Datei für Datei die Ternaries durch `dark:`-Klassen bzw. die neuen
|
||||||
|
Primitives ersetzen. `const { theme } = useDarkMode()` fällt dabei in
|
||||||
|
fast allen Dateien weg (nur `App.tsx` braucht `toggleTheme` noch).
|
||||||
|
|
||||||
|
### Optional als echtes „2.0": Tailwind-Theme-Tokens
|
||||||
|
|
||||||
|
Statt roher `slate-*`/`indigo-*`-Klassen semantische Tokens in
|
||||||
|
`tailwind.config.js` (`theme.extend.colors`) definieren
|
||||||
|
(`surface`, `surface-muted`, `border-subtle`, `accent`, `text-primary`,
|
||||||
|
`text-muted` …) und diese nutzen. Das macht spätere Farb-Anpassungen zu
|
||||||
|
einer Ein-Zeilen-Änderung. **Kür, kein Muss** — die `dark:`-Umstellung
|
||||||
|
ist der Pflichtteil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design-Richtung (Leitplanken, nicht Korsett)
|
||||||
|
|
||||||
|
Die bestehende Farbwelt ist bereits kohärent — bitte **systematisieren und
|
||||||
|
verfeinern, nicht wild neu erfinden**. Referenz aus dem Ist-Zustand:
|
||||||
|
|
||||||
|
- **Akzent/Primär:** Indigo (`indigo-600`), Verlauf `indigo-500 → purple-600`
|
||||||
|
(Logo, Buttons, aktive Nav)
|
||||||
|
- **Flächen:** hell `slate-50` (Seite) / `white` (Karten); dunkel
|
||||||
|
`slate-950`/`slate-900` (Seite) / `slate-800` (Karten)
|
||||||
|
- **Ränder:** `slate-200` hell / `slate-700` dunkel
|
||||||
|
- **Status:** amber=wartend/Warnung, blue=Bearbeitung/Info,
|
||||||
|
purple=Komprimieren, emerald=fertig/Erfolg, rose=Fehler; Disc-Typen:
|
||||||
|
cd=blue, dvd=purple, bluray=pink, **uhd=amber** (bewusst abgesetzt)
|
||||||
|
- **Typo:** system-sans (siehe `index.css`), Überschriften `font-bold`
|
||||||
|
|
||||||
|
**Spielraum für 2.0** (gern, solange konsistent + zugänglich):
|
||||||
|
Abstände/Rhythmus vereinheitlichen, Karten-Schatten/Radien harmonisieren,
|
||||||
|
Empty-States aufwerten, Fokus-/Hover-Zustände konsistent, bessere
|
||||||
|
Dichte in den Tabellen, der neue **Anleitung**-Tab (`pages/Anleitung.tsx`)
|
||||||
|
darf visuell aufgewertet werden. Barrierefreiheit im Blick behalten
|
||||||
|
(Kontrast, Fokus-Ringe, `aria-*` wo sinnvoll).
|
||||||
|
|
||||||
|
Es gibt eine `Skill`/Guideline „Web Interface Guidelines" — an modernen
|
||||||
|
Best-Practices orientieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. HARTE Regeln (nicht verhandelbar)
|
||||||
|
|
||||||
|
1. **Nur UI, nur `docker/ui/src/`** (+ ggf. `tailwind.config.js`,
|
||||||
|
`index.css`, `index.html`). **Kein** Backend (`docker/api`,
|
||||||
|
`docker/worker`), **keine** API-Verträge, **keine** `docker-compose.yml`.
|
||||||
|
2. **Funktion bleibt 1:1.** Alle bestehenden Features, Flows, API-Calls,
|
||||||
|
Polling-Intervalle, Feldnamen im POST-Body bleiben exakt erhalten. Das
|
||||||
|
ist ein **Re-Skin + Refactor**, kein Feature-Umbau. Im Zweifel:
|
||||||
|
Verhalten unverändert lassen.
|
||||||
|
3. **Deutsch bleibt Deutsch.** Kein UI-Text wird übersetzt oder
|
||||||
|
umformuliert (außer offensichtliche Tippfehler). Kommentare Deutsch.
|
||||||
|
4. **Die CI-Ampel MUSS grün bleiben** (`.gitea/workflows/ci.yml`): bei
|
||||||
|
jedem Push laufen Ruff, pytest UND `npm run build` (Vite). Ein
|
||||||
|
TypeScript-/Build-Fehler = rot = nicht fertig. Nach JEDER umgestellten
|
||||||
|
Komponente lokal `npm run build` grün halten.
|
||||||
|
5. **`package-lock.json` committen**, falls neue Abhängigkeiten dazukommen
|
||||||
|
(die Ampel prüft Reproduzierbarkeit). Neue Runtime-Deps sparsam — das
|
||||||
|
Ziel ist weniger Code, nicht mehr.
|
||||||
|
6. **AGENTS.md gelesen halten** (Repo-Wurzel) — die dortigen Regeln gelten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Datei-Inventar (Fahrplan, Stand 24.07.2026)
|
||||||
|
|
||||||
|
Sortiert nach Aufwand. Zahlen = `theme === 'dark'`-Ternaries in der Datei.
|
||||||
|
|
||||||
|
| Datei | Zeilen | Ternaries | Hinweis |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `pages/Settings.tsx` | 860 | **128** | Größter Brocken; viele Tabs — evtl. in Unterkomponenten pro Tab zerlegen |
|
||||||
|
| `pages/Dashboard.tsx` | 504 | 44 | Enthält `StatusBadge`/`TypeBadge`/`ProgressBar`/`StatCard` → in Primitives heben |
|
||||||
|
| `components/DeviceDiscovery.tsx` | 381 | 43 | Disc-Karte + Laufwerks-Liste; `getTypeColor`/`getStatusColor` zentralisieren |
|
||||||
|
| `components/RipTargetModal.tsx` | 401 | 37 | Modal + Track-Tabelle + Serien-Felder |
|
||||||
|
| `pages/Logs.tsx` | 246 | 34 | `getLevelColor`/`getLevelIcon`/`getLevelLabel` → zentral |
|
||||||
|
| `components/StorageMounts.tsx` | 361 | 33 | Mounts + Ordner-Browser |
|
||||||
|
| `components/JobDetailModal.tsx` | 239 | 28 | Modal, Download-Liste |
|
||||||
|
| `components/FirstRunWizard.tsx` | 192 | 22 | Onboarding |
|
||||||
|
| `components/WorkerVerwaltung.tsx` | 214 | 18 | Worker-Liste + Anbinde-Befehle |
|
||||||
|
| `components/LiveLogSection.tsx` | 147 | 15 | Live-Log-Kachel |
|
||||||
|
| `components/MetadataKorrektur.tsx` | 145 | 10 | Korrektur-Popup |
|
||||||
|
| `pages/Anleitung.tsx` | 180 | 7 | Neu; darf visuell aufgewertet werden |
|
||||||
|
| `components/ConfirmDialog.tsx` | 83 | 6 | Emoji-Icons → lucide |
|
||||||
|
| `App.tsx` | 101 | 2 | Sidebar/Nav/Footer; behält `toggleTheme` |
|
||||||
|
| `context/ThemeContext.tsx` | 52 | 1 | Bleibt im Kern; ggf. `data-theme` synchron setzen, falls CSS-Variablen genutzt werden |
|
||||||
|
| `context/ToastContext.tsx` | 62 | 0 | Nutzt schon `dark:` teils — als Vorbild ansehen |
|
||||||
|
|
||||||
|
**Gesamt: 428 Ternaries.** Bereits `dark:`-Klassen im Code: nur 7 —
|
||||||
|
d. h. `ToastContext` und Reste zeigen das Zielbild.
|
||||||
|
|
||||||
|
**Empfohlene Reihenfolge:** erst Schicht A (Primitives + `design.ts`),
|
||||||
|
dann die kleinen Dateien (`ConfirmDialog`, `App`, `LiveLogSection`) als
|
||||||
|
Kalibrierung, dann die großen. `Settings.tsx` zuletzt (größter Nutzen aus
|
||||||
|
den dann fertigen Primitives).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Arbeitsweise & Branch
|
||||||
|
|
||||||
|
- **Auf einem eigenen Branch arbeiten** (war: `design-2.0`). Ein
|
||||||
|
Feature-Branch lässt die Ampel laufen (Feedback grün/rot), deployt aber
|
||||||
|
nicht — ideal für einen großen Umbau; nach Abnahme nach `main` gemerged.
|
||||||
|
(Hinweis: Seit 24.07.2026 ist `main` der einzige Branch — deployt wird
|
||||||
|
direkt daraus, die Ampel prüft nur noch.)
|
||||||
|
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
||||||
|
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
||||||
|
Deutsch, WAS + WARUM.
|
||||||
|
- **Nach jeder Komponente**: `npm run build` muss grün sein. Nie einen
|
||||||
|
roten Zwischenstand pushen.
|
||||||
|
- Visuell gegenprüfen: hell UND dunkel testen (Theme-Umschalter oben in der
|
||||||
|
Sidebar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verifikation (die exakten Befehle)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/ui
|
||||||
|
npm ci # einmalig
|
||||||
|
npm run build # MUSS grün sein — das prüft auch die Ampel
|
||||||
|
```
|
||||||
|
|
||||||
|
Lokal ansehen (Dev-Server):
|
||||||
|
```bash
|
||||||
|
cd docker/ui && npm run dev
|
||||||
|
```
|
||||||
|
Das UI erwartet die API unter `/api` (nginx-Proxy). Für reine Design-
|
||||||
|
Arbeit reicht der Dev-Server; leere Listen/Fehlerzustände sind normal ohne
|
||||||
|
laufendes Backend. Wer gegen echte Daten testen will: Das Live-System läuft
|
||||||
|
im Heimnetz (Zugang beim Commander erfragen).
|
||||||
|
|
||||||
|
Die **komplette** Ampel (wie in CI) prüft zusätzlich Backend-Tests — die
|
||||||
|
sind von reiner UI-Arbeit nicht betroffen, müssen aber grün bleiben (nicht
|
||||||
|
anfassen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Definition of Done
|
||||||
|
|
||||||
|
- [ ] `src/components/ui/`-Primitives existieren und werden genutzt
|
||||||
|
- [ ] Status/Typ/Level-Farben zentral (`src/lib/design.ts`), keine
|
||||||
|
duplizierten Farb-Mappings mehr
|
||||||
|
- [ ] `grep -rn "theme === 'dark'" src/` ergibt **~0** Treffer (idealerweise
|
||||||
|
nur noch in `ThemeContext`/`App` das Nötigste)
|
||||||
|
- [ ] Tote CSS-Variablen (`[data-theme="dark"]`) und globale `* {
|
||||||
|
transition }` bereinigt
|
||||||
|
- [ ] `ConfirmDialog` nutzt lucide statt Emoji
|
||||||
|
- [ ] Hell- UND Dunkelmodus überall konsistent, keine „weiße Karte im
|
||||||
|
Darkmode"-Ausrutscher
|
||||||
|
- [ ] `npm run build` grün; keine neuen TS-Fehler/Warnungen
|
||||||
|
- [ ] Keine funktionale Änderung (alle API-Calls/Flows identisch)
|
||||||
|
- [ ] Alle deutschen Texte unverändert
|
||||||
|
- [ ] Auf Branch `design-2.0`, sauber committet, bereit für Review/Merge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Copy-Paste-Startprompt für Gemini
|
||||||
|
|
||||||
|
> Du übernimmst „Design 2.0" für das Rippy-Web-UI (React + TypeScript +
|
||||||
|
> Vite + TailwindCSS, `darkMode: 'class'`). Lies zuerst
|
||||||
|
> `docs/DESIGN-2.0-BRIEFING.md` KOMPLETT — dort stehen Auftrag, harte
|
||||||
|
> Regeln, Datei-Inventar, empfohlener Weg und die Definition of Done.
|
||||||
|
> Arbeite ausschließlich in `docker/ui/`, auf dem Branch `design-2.0`.
|
||||||
|
> Kernauftrag: 428 `theme === 'dark' ? …`-Ternaries durch Tailwind-`dark:`-
|
||||||
|
> Klassen + wiederverwendbare UI-Primitives ersetzen und die Optik
|
||||||
|
> modernisieren — OHNE Funktion, API-Aufrufe oder deutsche Texte zu ändern.
|
||||||
|
> Halte nach jeder umgestellten Komponente `npm run build` grün. Beginne
|
||||||
|
> mit den UI-Primitives (`src/components/ui/`) und `src/lib/design.ts`,
|
||||||
|
> dann die kleinen Dateien, dann die großen (`Settings.tsx` zuletzt).
|
||||||
|
> Kommentare und Commit-Texte auf Deutsch.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Ruff-Regelsatz festgepinnt (23.07.2026): identisches Urteil auf jedem
|
||||||
|
# Rechner und in der Ampel. Ohne Pin zog eine lokal aktualisierte Ruff-Version
|
||||||
|
# plötzlich Stilregeln (UP/I/BLE/FURB …) und färbte den ganzen Baum rot —
|
||||||
|
# der Regelsatz hier ist der bewährte Fehler-Fokus (Syntax, Imports, Bugs).
|
||||||
|
[lint]
|
||||||
|
select = ["E4", "E7", "E9", "F"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user