Merge Kernumbau 23.07.: Etappe 10 (MakeMKV) + Etappe 11 (Job-Kette, UI, Compose)
Ampel / ampel (push) Successful in 30s
Ampel / ampel (push) Successful in 30s
Ampel auf dem Branch gruen (Lauf 24: 21 Tests, Ruff, Vite-Build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
||||
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
||||
*.sh text eol=lf
|
||||
+4
-2
@@ -18,8 +18,10 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
# Venv-Muster auf Root verankert — das nackte `lib/` verschluckte auch
|
||||
# docker/ui/src/lib/ und ließ api.ts nie in einen Commit (Ampel-Rot 23.07.).
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Arcane Git Sync Fix
|
||||
|
||||
## Problem
|
||||
|
||||
Arcane GitOps sync schlägt fehl mit "Failed to perform GitOps sync" + "agent event ingestion is not configured" (Bug #2978).
|
||||
|
||||
## Lösung 1: Workaround in Arcane (empfohlen)
|
||||
|
||||
1. **Git Repository hinzufügen** (ohne "Sync sibling files")
|
||||
2. **Manuellen Sync starten** → wird erfolgreich sein
|
||||
3. **Deployen** über Arcane UI
|
||||
4. **"Sync sibling files" aktivieren** nach erstem Deployment
|
||||
|
||||
## Lösung 2: Direkt auf LXC 106 per Docker Compose
|
||||
|
||||
Ich erstelle ein Skript für dich:
|
||||
|
||||
```bash
|
||||
# Auf LXC 106 ausführen:
|
||||
cd /opt/docker/rippy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Ich bereite das Skript vor:
|
||||
|
||||
## Status (21.07.2026)
|
||||
|
||||
- ✅ Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`)
|
||||
- ✅ Git Sync in Arcane konfiguriert
|
||||
- ✅ DB-Update mit korrekter `project_id` und `project_name`
|
||||
- ✅ Commit `43dfce5` — Dark Mode Implementation
|
||||
@@ -1,47 +0,0 @@
|
||||
# Arcane-Integration für Rippy
|
||||
|
||||
## Vorbereitung
|
||||
|
||||
Damit Rippy in Arcane (auf LXC 106) importiert werden kann, benötige ich Zugriff auf:
|
||||
|
||||
1. **Das Arcane Projects Directory** auf LXC 106
|
||||
- Standard: `/app/data/projects` (im Arcane-Container)
|
||||
- Oder ein hostseitiges Verzeichnis, das du dafür vorgesehen hast
|
||||
|
||||
2. **Zugangsmethode** (bitte eine wählen):
|
||||
- **Option A (empfohlen):** SSH-Zugang zu LXC 106 → ich kopiere die Dateien per `scp`
|
||||
- **Option B:** Git-Repo sync → ich pushe nach einem privaten Repo, du syncst in Arcane
|
||||
- **Option C:** Datei-Upload per Arcane WebUI → ich erstelle ein Zip mit allen Dateien
|
||||
|
||||
3. **Deine Wunsch-Pfad** im Arcane Projects Directory
|
||||
- Z.B. `/app/data/projects/rippy` oder `/opt/docker/rippy`
|
||||
|
||||
## Empfehlung
|
||||
|
||||
Ich schlage **Option A (SSH)** vor:
|
||||
- Du gibst mir SSH-Zugang zu LXC 106 (IP + Port + Zugangsdaten)
|
||||
- Ich kopiere die Dateien direkt an den richtigen Ort
|
||||
- Du brauchst Arcane nur zu neustarten, damit es die neuen Dateien findet
|
||||
|
||||
**Oder** — wenn du willst, dass ich es über Git mache:
|
||||
- Du erstellst ein privates Repo (z.B. auf git.tobisniceshomelab.ddnsfree.com)
|
||||
- Ich pushe die Rippy-Dateien dorthin
|
||||
- Du syncst in Arcane über "Add Git Repository"
|
||||
|
||||
---
|
||||
|
||||
**Was möchtest du?**
|
||||
1. SSH-Zugang → ich mache den Kopierscript
|
||||
2. Git sync → ich pushe zu deinem Repo
|
||||
3. Arcane WebUI → ich erstelle ein Zip-Package
|
||||
|
||||
Und: **Wo genau** sollen die Dateien auf LXC 106 hin? (Pfad angeben)
|
||||
|
||||
---
|
||||
|
||||
## Status (21.07.2026)
|
||||
|
||||
- ✅ Git Sync in Arcane konfiguriert
|
||||
- ✅ Rippy läuft über Arcane auf VM 192.168.178.162
|
||||
- ✅ Dark Mode implementiert
|
||||
- ✅ Einstellungen mit Tab-Struktur
|
||||
@@ -1,62 +0,0 @@
|
||||
# Arcane Projects Directory Konfiguration
|
||||
|
||||
## Problem
|
||||
Arcane versucht, Project-Verzeichnisse in `/etc/arcane/projects/` zu erstellen (falscher Default).
|
||||
|
||||
## Lösung
|
||||
|
||||
### Methode 1: Arcane UI (empfohlen)
|
||||
|
||||
1. Öffne Arcane WebUI (http://localhost:3552)
|
||||
2. Gehe zu **Settings** → **General**
|
||||
3. Suche nach **Projects Directory**
|
||||
4. Ändere zu: `/app/data/projects`
|
||||
5. Klicke **Save**
|
||||
|
||||
### Methode 2: Docker Compose (wenn Arcane neu gestartet wird)
|
||||
|
||||
Die Arcane Docker Compose Datei auf LXC 106 muss erweitert werden:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
arcane:
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
volumes:
|
||||
- arcane-data:/app/data
|
||||
# Optional: Host-Verzeichnis mounten für persistente Projects
|
||||
- /opt/docker/projects:/app/data/projects
|
||||
```
|
||||
|
||||
### Methode 3: Direkt im Container (temporär)
|
||||
|
||||
```bash
|
||||
# Auf LXC 106:
|
||||
docker exec -it arcane sh
|
||||
# Im Container:
|
||||
mkdir -p /app/data/projects
|
||||
chown 65532:65532 /app/data/projects
|
||||
```
|
||||
|
||||
## Nach der Konfiguration
|
||||
|
||||
1. Projects Directory ist now `/app/data/projects`
|
||||
2. Git Sync erneut versuchen
|
||||
3. Rippy Project sollte sich importieren lassen
|
||||
|
||||
## Empfohlener Pfad für Rippy
|
||||
|
||||
```
|
||||
/opt/docker/rippy
|
||||
```
|
||||
|
||||
Oder wenn du Arcane's Standard nutzt:
|
||||
```
|
||||
/app/data/projects/rippy
|
||||
```
|
||||
|
||||
## Status (21.07.2026)
|
||||
|
||||
- ✅ Arcane Projects Directory konfiguriert auf `/app/data/projects`
|
||||
- ✅ Rippy Git Sync funktioniert
|
||||
- ✅ Doppelte Einträge in DB behoben
|
||||
@@ -1,49 +0,0 @@
|
||||
# Arcane Docker Compose Fix für LXC 106
|
||||
|
||||
## Problem
|
||||
Arcane versucht, Project-Verzeichnisse in `/etc/arcane/projects/` zu erstellen (falscher Default).
|
||||
|
||||
## Lösung
|
||||
|
||||
### Schritt 1: Arcane Docker Compose Datei finden
|
||||
|
||||
Auf LXC 106 folgende Pfade prüfen:
|
||||
- `/opt/docker-compose.yml`
|
||||
- `/opt/docker/docker-compose.yml`
|
||||
- `/opt/arcane/docker-compose.yml`
|
||||
- `/app/docker-compose.yml`
|
||||
|
||||
### Schritt 2: docker-compose.yml editieren
|
||||
|
||||
Füge die Umgebungsvariable hinzu:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
arcane:
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
```
|
||||
|
||||
### Schritt 3: Docker Compose neu starten
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Schritt 4: In Arcane UIProjects Directory prüfen
|
||||
|
||||
Gehe zu Settings → General und prüfe, ob `/app/data/projects` eingestellt ist.
|
||||
|
||||
### Schritt 5: Git Sync erneut starten
|
||||
|
||||
Rippy Project importieren und deployen.
|
||||
|
||||
## Status (21.07.2026)
|
||||
|
||||
- ✅ Arcane Docker Compose konfiguriert
|
||||
- ✅ PROJECTS_DIRECTORY=/app/data/projects
|
||||
- ✅ Git Sync funktioniert
|
||||
- ✅ Doppelte Einträge behoben
|
||||
- ✅ Dark Mode implementiert
|
||||
@@ -1,58 +0,0 @@
|
||||
# Arcane VM Setup - ABGESCHLOSSEN
|
||||
|
||||
## Status
|
||||
- **VM IP:** 192.168.178.162
|
||||
- **Arcane:** Erreichbar unter http://192.168.178.162:3552
|
||||
- **Docker:** Installiert und läuft
|
||||
- **Projects Directory:** /home/arcane/projects
|
||||
- **Rippy UI:** http://192.168.178.162:80
|
||||
- **Rippy API:** http://192.168.178.162:8000
|
||||
- **Git Sync:** Konfiguriert und funktioniert
|
||||
|
||||
## Nächste Schritte in Arcane
|
||||
|
||||
1. Öffne http://192.168.178.162:3552
|
||||
2. Gehe zu **Projects** → **Add Git Repository**
|
||||
3. Repository URL: `https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git`
|
||||
4. Branch: `main`
|
||||
5. Compose File Path: `docker-compose.yml`
|
||||
6. Sync Name: `rippy`
|
||||
7. **WICHTIG:** Deaktiviere "Sync sibling files" (Bug #2978)
|
||||
8. Klicke **Create Sync**
|
||||
9. Git Sync erneut versuchen → wird jetzt erfolgreich sein!
|
||||
|
||||
## Docker Compose Datei
|
||||
|
||||
Die Datei wurde unter `/home/arcane/docker-compose.yml` erstellt.
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
arcane:
|
||||
image: ghcr.io/getarcaneapp/manager:latest
|
||||
container_name: arcane
|
||||
ports:
|
||||
- '3552:3552'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- arcane-data:/app/data
|
||||
- /home/arcane/projects:/app/data/projects
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
- APP_URL=http://192.168.178.162:3552
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- ENCRYPTION_KEY=<generiert>
|
||||
- JWT_SECRET=<generiert>
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
arcane-data:
|
||||
```
|
||||
|
||||
## Status: Arcane läuft, Rippy Import bereit
|
||||
|
||||
## Rippy Features
|
||||
- ✅ Dark Mode mit Theme-Toggle
|
||||
- ✅ React UI mit TailwindCSS
|
||||
- ✅ Einstellungen mit Tab-Struktur
|
||||
- ✅ Sidebar Navigation
|
||||
@@ -1,72 +0,0 @@
|
||||
# Laufwerk zur Arcane VM hinzufügen
|
||||
|
||||
## Status
|
||||
- ✅ Laufwerk am Proxmox Host (192.168.178.108) sichtbar: `/dev/sr0` (block device)
|
||||
- ❌ Laufwerk ist noch **nicht** an die VM (192.168.178.162) durchgereicht
|
||||
|
||||
## Lösung
|
||||
|
||||
### Schritt 1: Laufwerk zur VM-Konfiguration hinzufügen
|
||||
|
||||
Auf dem Proxmox Host (192.168.178.108) als root:
|
||||
|
||||
```bash
|
||||
# VM-Config sichern
|
||||
cp /etc/pve/qemu-server/106.conf /etc/pve/qemu-server/106.conf.backup
|
||||
|
||||
# Laufwerk hinzufügen (als SCSI Device)
|
||||
echo "scsi1: /dev/sr0,media=cdrom" >> /etc/pve/qemu-server/106.conf
|
||||
|
||||
# VM neu starten
|
||||
qm shutdown 106
|
||||
# Warten bis VM gestoppt ist
|
||||
qm start 106
|
||||
```
|
||||
|
||||
### Schritt 2: Nach dem Neustart prüfen
|
||||
|
||||
In der Arcane VM:
|
||||
|
||||
```bash
|
||||
# Laufwerk im Container prüfen
|
||||
docker exec rippy-worker ls -la /dev/sr0
|
||||
docker exec rippy-worker ls -la /dev/cdrom
|
||||
```
|
||||
|
||||
### Schritt 3: Rippy UI prüfen
|
||||
|
||||
Im Rippy Dashboard sollte jetzt das Laufwerk erscheinen:
|
||||
- Gehe zu http://192.168.178.162:80
|
||||
- Dashboard → Laufwerke Section oben
|
||||
|
||||
## Alternative: Proxmox Web-UI
|
||||
|
||||
1. Gehe zu **Node** → **QEMU VM** → **106 (arcane)**
|
||||
2. Klicke auf **Hardware**
|
||||
3. Klicke auf **Add** → **SCSI Device**
|
||||
4. Wähle **"Use existing disk/image"**
|
||||
5. Gib `/dev/sr0` als Device ein
|
||||
6. Stelle sicher **"Media type: CDROM"** ist ausgewählt
|
||||
7. Klicke auf **Add**
|
||||
8. VM neu starten
|
||||
|
||||
## Erklärung
|
||||
|
||||
Die Zeile `scsi1: /dev/sr0,media=cdrom` sagt Proxmox:
|
||||
- `/dev/sr0` vom Host an die VM durchreichen
|
||||
- Als SCSI Device (virtio-scsi)
|
||||
- Media type = CDROM (wichtig für CD/DVD/Blu-ray Erkennung)
|
||||
|
||||
## Nachteile
|
||||
|
||||
- Laufwerk ist dann **exklusiv** an die VM gebunden (nicht mehr am Host nutzbar)
|
||||
- Wenn VM stoppt, ist Laufwerk auch nicht am Host verfügbar
|
||||
|
||||
## Hinweis für Arcane
|
||||
|
||||
Arcane kann das Laufwerk jetzt in den Rippy-Container mounten wie in der `docker-compose.yml` konfiguriert:
|
||||
```yaml
|
||||
volumes:
|
||||
- /dev/cdrom:/dev/cdrom:ro
|
||||
- /dev/sr0:/dev/sr0:ro
|
||||
```
|
||||
+25
-3
@@ -222,10 +222,32 @@
|
||||
**Ziel:** DVD/Blu-ray-Ripping auf `makemkvcon` umstellen (verlustfrei), wie im
|
||||
KONZEPT als Muss definiert. HandBrake fliegt aus dem Ripp-Pfad (bleibt ggf. als
|
||||
optionaler Transcode-Schritt NACH dem verlustfreien Rip).
|
||||
- [ ] Worker-Dockerfile: makemkv-oss/makemkv-bin bauen + Beta-Key-Handling (Env/Cron)
|
||||
- [ ] ripping.py: rip_dvd/rip_bluray auf `makemkvcon --all --progress mkv` umstellen
|
||||
(Fortschritts-Parsing anpassen, Tests zuerst!)
|
||||
- [x] Worker-Dockerfile: makemkv-oss/makemkv-bin 1.18.4 multi-stage gebaut,
|
||||
Beta-Key via MAKEMKV_APP_KEY-Env (entrypoint.sh) — 23.07.
|
||||
- [x] ripping.py: auf `makemkvcon -r --progress=-same mkv dev:… all` umgestellt,
|
||||
PRGV-Fortschritts-Parsing laut makemkv.com/developers/usage.txt, Tests dabei — 23.07.
|
||||
- [ ] E2E mit echter Disc verifizieren (Commander, Laufwerk nötig)
|
||||
**Fertig wenn:** eine echte DVD landet verlustfrei als MKV im Output, Ampel grün.
|
||||
**Entstanden aus:** Review 22.07. — HandBrake-lossy war eine stille Abweichung vom
|
||||
Konzept; Entscheid 22.07. abends: Konzept gilt, Umbau als eigene Etappe.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 11 (23.07.2026): Kernumbau — der Zweck funktioniert erstmals wirklich
|
||||
|
||||
**Befund des Komplett-Audits (Claude):** Der Kern „Disc rein → gerippt raus"
|
||||
hatte NIE einen Code-Pfad: kein POST /jobs, kein udev-Daemon (das referenzierte
|
||||
udev_daemon.py existierte nirgends), kein Ripping-Tool im Worker-Image,
|
||||
Disc-Erkennung strukturell tot (`file -L` auf Block-Device), Geräte als
|
||||
Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf.
|
||||
|
||||
**Gebaut:**
|
||||
- [x] POST /jobs (API → Celery → Worker) + Job-Persistenz in Postgres
|
||||
- [x] Disc-Watcher in der API (ioctl-Poll statt udev — Container-tauglich)
|
||||
- [x] Disc-Erkennung neu über Kernel-ioctls (detection.py, in API+Worker)
|
||||
- [x] /logs-Endpoint + echte Logs-Seite (Mock-Daten raus)
|
||||
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
||||
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
||||
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
|
||||
- [ ] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem Rip)
|
||||
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
|
||||
|
||||
+29
-1
@@ -1,6 +1,34 @@
|
||||
# SAVEPOINT — Rippy
|
||||
|
||||
## Aktueller Stand
|
||||
## Aktueller Stand: v3.0 — Kernumbau (23.07.2026, Claude)
|
||||
|
||||
**Der Zweck existiert jetzt wirklich.** Komplett-Audit ergab: „Disc rein →
|
||||
gerippt raus" hatte nie einen Code-Pfad (Details in ROADMAP Etappe 11).
|
||||
|
||||
Kernpunkte des Umbaus:
|
||||
- **Rip-Kette komplett neu**: POST /jobs → Celery → Worker (MakeMKV 1.18.4
|
||||
verlustfrei, Etappe 10) → Postgres-Job-Status → UI. CD weiter via abcde.
|
||||
- **Disc-Erkennung** über Kernel-ioctls (CDROM_DISC_STATUS + Größe), Watcher
|
||||
pollt statt udev (im Container gibt es kein udev).
|
||||
- **UI**: echter Vite-Build hinter nginx mit /api-Proxy — vorher Dev-Server
|
||||
und hartkodiertes localhost:8000 (deshalb waren nie Laufwerke zu sehen).
|
||||
- **Compose**: Laufwerk als devices: (Bind-Mounts gaben EPERM), Ausgabe auf
|
||||
media-Volume (vorher /output → nicht gemountet + read-only-Falle).
|
||||
- **Placebos entfernt**: Mock-Logs, toter SSE-Stream, udevadm-Discovery,
|
||||
14 Troubleshooting-Altdateien (ARCAN-*.md, Setup-Skripte, udev-Rules).
|
||||
|
||||
**Hardware-Kette (23.07.):** Laufwerk (Verbatim 4K BD RW, BU40N 1.05) hängt am
|
||||
Proxmox-Host; LPM-Quirk `usbcore.quirks=18a5:0428:k` gefixt die Serien-Disconnects
|
||||
(reboot-fest in GRUB). VM 106 bekommt es per USB-Passthrough `host=18a5:0428`.
|
||||
⚠️ Firmware 1.05 kann KEIN UHD-Ripping (verschlüsselt) — Crossflash auf 1.03-MK
|
||||
ist der dokumentierte Weg, separater Faden. DVD/BD normal geht.
|
||||
|
||||
**Offen:** E2E mit echter Disc · Jellyfin-Post-Processing an Worker anbinden ·
|
||||
Auth vor schreibende Endpoints · MAKEMKV_APP_KEY in Arcane-Env pflegen (monatlich).
|
||||
|
||||
---
|
||||
|
||||
## Vorheriger Stand
|
||||
|
||||
**v1.10 — Complete Dark Mode Implementation (22.07.2026)**
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "=== Arcane VM Setup ==="
|
||||
echo "Tu77ceu2zzvx!" | sudo -S apt-get update
|
||||
echo "Tu77ceu2zzvx!" | sudo -S apt-get upgrade -y
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
echo "Tu77ceu2zzvx!" | sudo -S usermod -aG docker arcane
|
||||
mkdir -p /opt/docker/projects
|
||||
chown arcane:arcane /opt/docker/projects
|
||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
curl -SL https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
cat > /opt/docker-compose.yml << COMPOSEEOF
|
||||
version: "3.8"
|
||||
services:
|
||||
arcane:
|
||||
image: ghcr.io/getarcaneapp/manager:latest
|
||||
container_name: arcane
|
||||
ports:
|
||||
- "3552:3552"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- arcane-data:/app/data
|
||||
- /opt/docker/projects:/app/data/projects
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
- APP_URL=http://192.168.178.162:3552
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
- JWT_SECRET=$JWT_SECRET
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
arcane-data:
|
||||
COMPOSEEOF
|
||||
cd /opt
|
||||
docker compose up -d
|
||||
echo "=== Setup abgeschlossen ==="
|
||||
echo "Arcane ist erreichbar unter: http://192.168.178.162:3552"
|
||||
echo "Projects Directory: /opt/docker/projects"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "=== Arcane VM Setup ==="
|
||||
echo "Tu77ceu2zzvx!" | sudo -S apt-get update -y
|
||||
echo "Tu77ceu2zzvx!" | sudo -S apt-get upgrade -y
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
echo "Tu77ceu2zzvx!" | sudo -S usermod -aG docker arcane
|
||||
mkdir -p /home/arcane/projects
|
||||
chown arcane:arcane /home/arcane/projects
|
||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
curl -SL https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
cat > /home/arcane/docker-compose.yml << COMPOSEEOF
|
||||
version: "3.8"
|
||||
services:
|
||||
arcane:
|
||||
image: ghcr.io/getarcaneapp/manager:latest
|
||||
container_name: arcane
|
||||
ports:
|
||||
- "3552:3552"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- arcane-data:/app/data
|
||||
- /home/arcane/projects:/app/data/projects
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
- APP_URL=http://192.168.178.162:3552
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
- JWT_SECRET=$JWT_SECRET
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
arcane-data:
|
||||
COMPOSEEOF
|
||||
cd /home/arcane
|
||||
docker compose up -d
|
||||
echo "=== Setup abgeschlossen ==="
|
||||
echo "Arcane ist erreichbar unter: http://192.168.178.162:3552"
|
||||
echo "Projects Directory: /home/arcane/projects"
|
||||
@@ -1,26 +0,0 @@
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
cat > /home/arcane/docker-compose.yml << EOF
|
||||
version: '3.8'
|
||||
services:
|
||||
arcane:
|
||||
image: ghcr.io/getarcaneapp/manager:latest
|
||||
container_name: arcane
|
||||
ports:
|
||||
- '3552:3552'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- arcane-data:/app/data
|
||||
- /home/arcane/projects:/app/data/projects
|
||||
environment:
|
||||
- PROJECTS_DIRECTORY=/app/data/projects
|
||||
- APP_URL=http://192.168.178.162:3552
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
- JWT_SECRET=$JWT_SECRET
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
arcane-data:
|
||||
EOF
|
||||
cat /home/arcane/docker-compose.yml
|
||||
+22
-14
@@ -1,4 +1,9 @@
|
||||
# Docker Compose für Rippy
|
||||
#
|
||||
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
|
||||
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
|
||||
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
|
||||
# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve).
|
||||
|
||||
services:
|
||||
api:
|
||||
@@ -23,13 +28,15 @@ services:
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- temp:/app/temp
|
||||
- /dev/cdrom:/dev/cdrom:ro
|
||||
- /dev/sr0:/dev/sr0:ro
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
read_only: false
|
||||
|
||||
@@ -40,23 +47,25 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||
- LOG_LEVEL=INFO
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- temp:/app/temp
|
||||
- disc:/dev/disc
|
||||
- /dev/cdrom:/dev/cdrom:ro
|
||||
- /dev/sr0:/dev/sr0:ro
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# read_only bleibt Ziel (Etappe 6), aber MakeMKV/abcde brauchen HOME +
|
||||
# Settings — bis zur Härtung schreibbar, Ausgabe geht ohnehin aufs Volume.
|
||||
read_only: false
|
||||
tmpfs:
|
||||
- /app/tmp
|
||||
- /run
|
||||
@@ -112,6 +121,5 @@ networks:
|
||||
volumes:
|
||||
media:
|
||||
temp:
|
||||
disc:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc udev && rm -rf /var/lib/apt/lists/*
|
||||
# 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.
|
||||
|
||||
COPY docker/api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Dünner Celery-Client: die API SCHICKT Tasks an den Worker, führt sie nie aus.
|
||||
|
||||
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
|
||||
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||
|
||||
|
||||
def start_rip(device_path: str, job_id: str):
|
||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||
return celery_client.send_task(
|
||||
"worker.tasks.rip_disc", args=[device_path, job_id]
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Job-, Log- und Settings-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
||||
|
||||
Die jobs/logs-Tabellendefinition existiert bewusst identisch im Worker
|
||||
(docker/worker/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
||||
|
||||
Bis 23.07. lag Postgres komplett brach: GET /jobs gab hart [] zurück, nichts
|
||||
schrieb je eine Zeile — der Job-Verlauf im UI war ein Placebo.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
select,
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
metadata = MetaData()
|
||||
|
||||
jobs = Table(
|
||||
"jobs",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("disc_type", String(16)),
|
||||
Column("device", String(64)),
|
||||
Column("title", String(255)),
|
||||
Column("status", String(16), nullable=False, server_default="pending"),
|
||||
Column("progress", Integer, nullable=False, server_default="0"),
|
||||
Column("output_path", Text),
|
||||
Column("error", Text),
|
||||
Column("created_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
logs = Table(
|
||||
"logs",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", DateTime(timezone=True)),
|
||||
Column("level", String(16)),
|
||||
Column("source", String(32)),
|
||||
Column("message", Text),
|
||||
)
|
||||
|
||||
settings_table = Table(
|
||||
"settings",
|
||||
metadata,
|
||||
Column("key", String(64), primary_key=True),
|
||||
Column("value", Text),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Legt fehlende Tabellen an (idempotent)."""
|
||||
metadata.create_all(engine)
|
||||
|
||||
|
||||
def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
jobs.insert().values(
|
||||
id=job_id,
|
||||
device=device,
|
||||
disc_type=disc_type,
|
||||
title=title,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_at=utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_jobs(limit: int = 100) -> list:
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def add_log(level: str, source: str, message: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||
)
|
||||
|
||||
|
||||
def list_logs(limit: int = 200) -> list:
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(logs).order_by(logs.c.id.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def get_settings(key: str = "ui") -> dict:
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||
).first()
|
||||
if not zeile or not zeile[0]:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(zeile[0])
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
|
||||
def save_settings(werte: dict, key: str = "ui") -> None:
|
||||
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))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
|
||||
|
||||
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
|
||||
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices — die Ausgabe ist
|
||||
immer nur "block special", die Erkennung lieferte also strukturell IMMER
|
||||
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
|
||||
bewies damit nichts.
|
||||
|
||||
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
|
||||
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
from fcntl import ioctl
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDROM_DISC_STATUS = 0x5327
|
||||
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
CDS_DRIVE_NOT_READY = 3
|
||||
CDS_DISC_OK = 4
|
||||
|
||||
CDS_AUDIO = 100
|
||||
CDS_DATA_1 = 101
|
||||
CDS_DATA_2 = 102
|
||||
CDS_XA_2_1 = 103
|
||||
CDS_XA_2_2 = 104
|
||||
CDS_MIXED = 105
|
||||
|
||||
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
|
||||
BLKGETSIZE64 = 0x80081272
|
||||
|
||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||
|
||||
|
||||
def _open_nonblock(device_path: str) -> int:
|
||||
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
|
||||
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
|
||||
def drive_status(device_path: str) -> int:
|
||||
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_status(device_path: str) -> int:
|
||||
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DISC_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_size_bytes(device_path: str) -> int:
|
||||
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
buf = bytearray(8)
|
||||
ioctl(fd, BLKGETSIZE64, buf)
|
||||
return struct.unpack("Q", bytes(buf))[0]
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||
return "cd"
|
||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
|
||||
try:
|
||||
if drive_status(device_path) != CDS_DISC_OK:
|
||||
return "no_disc"
|
||||
return classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||
except OSError:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Laufwerks-Discovery über /sys und ioctls — ehrlich, ohne udev.
|
||||
|
||||
Warum kein udevadm mehr: Im Container läuft kein udevd, die udev-Datenbank ist
|
||||
leer — `udevadm info` lieferte schlicht nichts (der alte Weg zeigte deshalb nie
|
||||
ein Laufwerk an). /sys/class/block/<name>/device/{vendor,model} kommt dagegen
|
||||
direkt vom Kernel und funktioniert überall.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
from detection import (
|
||||
CDS_DISC_OK,
|
||||
classify,
|
||||
disc_size_bytes,
|
||||
disc_status,
|
||||
drive_status,
|
||||
)
|
||||
|
||||
|
||||
def list_optical_devices() -> list:
|
||||
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
||||
return sorted(glob.glob("/dev/sr[0-9]*"))
|
||||
|
||||
|
||||
def read_sys_attr(device_name: str, attr: str) -> str:
|
||||
pfad = f"/sys/class/block/{device_name}/device/{attr}"
|
||||
try:
|
||||
with open(pfad, encoding="ascii", errors="replace") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def device_info(device_path: str) -> dict:
|
||||
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl."""
|
||||
name = os.path.basename(device_path)
|
||||
vendor = read_sys_attr(name, "vendor")
|
||||
model = read_sys_attr(name, "model")
|
||||
|
||||
try:
|
||||
status_code = drive_status(device_path)
|
||||
except OSError:
|
||||
status_code = -1
|
||||
|
||||
disc_type = "unknown"
|
||||
status = "empty"
|
||||
if status_code == CDS_DISC_OK:
|
||||
status = "ready"
|
||||
try:
|
||||
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||
except OSError:
|
||||
disc_type = "unknown"
|
||||
|
||||
return {
|
||||
"id": name,
|
||||
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
|
||||
"type": disc_type,
|
||||
"path": device_path,
|
||||
"status": status,
|
||||
"model": model,
|
||||
"serial": read_sys_attr(name, "wwid"),
|
||||
}
|
||||
+133
-82
@@ -4,9 +4,14 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import db
|
||||
import devices as device_discovery
|
||||
from celery_client import start_rip
|
||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
@@ -41,20 +46,47 @@ app = FastAPI(
|
||||
# OAuth2 Scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
# SSE-Connections
|
||||
sse_connections: List = []
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialisiere Cache beim Start und validiere Konfiguration."""
|
||||
"""Initialisiere Cache + Datenbank, validiere Konfiguration, starte Disc-Wache."""
|
||||
init_cache()
|
||||
db.init_db()
|
||||
|
||||
try:
|
||||
validate_config()
|
||||
except ConfigValidationError as e:
|
||||
print(f"⚠️ Konfigurations-Warnung: {e}")
|
||||
|
||||
asyncio.create_task(disc_watcher())
|
||||
|
||||
|
||||
async def disc_watcher():
|
||||
"""Disc-Wache: pollt die Laufwerke und protokolliert Einwurf/Auswurf.
|
||||
|
||||
Ersetzt den nie gebauten udev-Daemon aus dem KONZEPT: udev funktioniert im
|
||||
Container nicht sinnvoll (kein udevd) — ein 3-Sekunden-Poll per ioctl ist
|
||||
für den Heim-Use-Case gleichwertig und läuft überall.
|
||||
"""
|
||||
bekannt: Dict[str, int] = {}
|
||||
while True:
|
||||
try:
|
||||
for pfad in device_discovery.list_optical_devices():
|
||||
try:
|
||||
status = await asyncio.to_thread(drive_status, pfad)
|
||||
except OSError:
|
||||
continue
|
||||
vorher = bekannt.get(pfad)
|
||||
if vorher is not None and status != vorher:
|
||||
if status == CDS_DISC_OK:
|
||||
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
|
||||
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
|
||||
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
||||
bekannt[pfad] = status
|
||||
except Exception as e:
|
||||
print(f"Disc-Wache: {e}")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
# Middleware für Rate-Limiting
|
||||
@app.middleware("http")
|
||||
@@ -102,6 +134,8 @@ class Job(BaseModel):
|
||||
startTime: str
|
||||
endTime: Optional[str] = None
|
||||
progress: int = 0
|
||||
title: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
class Device(BaseModel):
|
||||
id: str
|
||||
@@ -109,6 +143,24 @@ class Device(BaseModel):
|
||||
type: str
|
||||
path: str
|
||||
status: str
|
||||
model: Optional[str] = None
|
||||
serial: Optional[str] = None
|
||||
|
||||
|
||||
def _job_row_to_model(zeile: dict) -> Job:
|
||||
"""DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI 'processing')."""
|
||||
status_map = {"running": "processing"}
|
||||
return Job(
|
||||
id=zeile["id"],
|
||||
type=zeile.get("disc_type") or "unknown",
|
||||
status=status_map.get(zeile["status"], zeile["status"]),
|
||||
device=zeile.get("device") or "",
|
||||
startTime=zeile["created_at"].isoformat() if zeile.get("created_at") else "",
|
||||
endTime=zeile["finished_at"].isoformat() if zeile.get("finished_at") else None,
|
||||
progress=zeile.get("progress") or 0,
|
||||
title=zeile.get("title"),
|
||||
error=zeile.get("error"),
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
@@ -126,95 +178,94 @@ async def root():
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
async def get_jobs():
|
||||
"""Holt alle Jobs."""
|
||||
return []
|
||||
"""Holt alle Jobs aus der Datenbank (neueste zuerst)."""
|
||||
zeilen = await asyncio.to_thread(db.list_jobs)
|
||||
return [_job_row_to_model(z) for z in zeilen]
|
||||
|
||||
|
||||
class JobCreateRequest(BaseModel):
|
||||
device_path: Optional[str] = None
|
||||
device: Optional[str] = None # Alias, so schickt es das UI
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/jobs", status_code=201)
|
||||
async def create_job(request: JobCreateRequest):
|
||||
"""Legt einen Rip-Job an und schickt ihn an den Worker.
|
||||
|
||||
Das war DIE fehlende Stelle: bis 23.07. gab es keinerlei Code-Pfad,
|
||||
der je einen Rip ausgelöst hat.
|
||||
"""
|
||||
device_path = request.device_path or request.device
|
||||
if not device_path:
|
||||
raise HTTPException(status_code=422, detail="device_path fehlt")
|
||||
if device_path not in device_discovery.list_optical_devices():
|
||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title)
|
||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} angelegt für {device_path}")
|
||||
start_rip(device_path, job_id)
|
||||
return {"id": job_id, "status": "pending", "device": device_path}
|
||||
|
||||
|
||||
@app.get("/logs")
|
||||
async def get_logs(limit: int = 200):
|
||||
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
|
||||
zeilen = await asyncio.to_thread(db.list_logs, min(limit, 1000))
|
||||
return [
|
||||
{
|
||||
"id": str(z["id"]),
|
||||
"timestamp": z["ts"].isoformat() if z.get("ts") else "",
|
||||
"level": z.get("level") or "info",
|
||||
"source": z.get("source") or "system",
|
||||
"message": z.get("message") or "",
|
||||
}
|
||||
for z in zeilen
|
||||
]
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
"""UI-Einstellungen aus der Datenbank (leeres Objekt = Defaults im UI)."""
|
||||
return await asyncio.to_thread(db.get_settings)
|
||||
|
||||
|
||||
@app.post("/settings")
|
||||
async def save_settings(werte: Dict):
|
||||
"""Speichert die UI-Einstellungen als JSON in der Datenbank."""
|
||||
await asyncio.to_thread(db.save_settings, werte)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@app.get("/devices", response_model=List[Device])
|
||||
async def get_devices():
|
||||
"""Holt alle Geräte."""
|
||||
devices = []
|
||||
"""Alle optischen Laufwerke mit ehrlichem Status (leer/bereit + Disc-Typ).
|
||||
|
||||
try:
|
||||
# 1. Zuerst Symlinks in /dev/disc/ prüfen
|
||||
result = subprocess.run(
|
||||
["ls", "-la", "/dev/disc/"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
for line in result.stdout.strip().split('\n')[1:]:
|
||||
if line and 'total' not in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 9:
|
||||
name = parts[-1]
|
||||
devices.append(Device(
|
||||
id=name,
|
||||
name=f"Laufwerk {name}",
|
||||
type="dvd",
|
||||
path=f"/dev/disc/{name}",
|
||||
status="ready"
|
||||
))
|
||||
|
||||
# 2. Direkte Geräte /dev/sr0, /dev/cdrom prüfen
|
||||
import os
|
||||
direct_devices = ['/dev/sr0', '/dev/cdrom', '/dev/dvd']
|
||||
for device_path in direct_devices:
|
||||
if os.path.exists(device_path):
|
||||
# Prüfen ob es ein block device ist
|
||||
import stat
|
||||
mode = os.stat(device_path).st_mode
|
||||
if stat.S_ISBLK(mode):
|
||||
# Geräte-Info aus udevadm holen
|
||||
result = subprocess.run(
|
||||
["udevadm", "info", "-q", "property", "-n", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
info = {}
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
info[key] = value
|
||||
|
||||
device_type = "dvd"
|
||||
if info.get("ID_CDROM_BD") == "1":
|
||||
device_type = "bluray"
|
||||
elif info.get("ID_CDROM_CD") == "1":
|
||||
device_type = "cd"
|
||||
|
||||
model = info.get("ID_MODEL", "Laufwerk")
|
||||
serial = info.get("ID_SERIAL", "unknown")
|
||||
|
||||
devices.append(Device(
|
||||
id=f"{model}_{serial}".replace(" ", "_"),
|
||||
name=model,
|
||||
type=device_type,
|
||||
path=device_path,
|
||||
status="ready",
|
||||
serial=serial,
|
||||
model=model
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Lesen der Geräte: {e}")
|
||||
pass
|
||||
|
||||
return devices
|
||||
Der alte Weg (udevadm + /dev/disc-Symlinks) lieferte im Container
|
||||
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
|
||||
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
|
||||
"""
|
||||
geraete = []
|
||||
for pfad in device_discovery.list_optical_devices():
|
||||
info = await asyncio.to_thread(device_discovery.device_info, pfad)
|
||||
geraete.append(Device(**info))
|
||||
return geraete
|
||||
|
||||
|
||||
# SSE-Stream für Echtzeit-Updates
|
||||
@app.get("/stream/jobs")
|
||||
async def job_stream():
|
||||
"""SSE-Stream für Job-Updates."""
|
||||
"""SSE-Stream für Job-Updates.
|
||||
|
||||
Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt
|
||||
war — aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo.
|
||||
"""
|
||||
async def event_generator():
|
||||
while True:
|
||||
if sse_connections:
|
||||
# Job-Status aktualisieren
|
||||
jobs = await get_jobs()
|
||||
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
|
||||
await asyncio.sleep(1)
|
||||
jobs = await get_jobs()
|
||||
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Import-Smoke-Test: bricht, wenn main.py kaputte Imports oder Verdrahtung hat.
|
||||
|
||||
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
||||
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
||||
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
||||
wo auch die Ampel läuft.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
||||
|
||||
|
||||
def test_main_importierbar_und_routen_verdrahtet():
|
||||
from main import app
|
||||
|
||||
routen = {route.path for route in app.routes}
|
||||
for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"):
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
|
||||
def test_worker_task_name_passt_zum_celery_client():
|
||||
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
||||
import inspect
|
||||
|
||||
import celery_client
|
||||
|
||||
quelle = inspect.getsource(celery_client.start_rip)
|
||||
assert '"worker.tasks.rip_disc"' in quelle
|
||||
+14
-7
@@ -1,14 +1,21 @@
|
||||
FROM node:22-alpine
|
||||
# UI-Image: Vite-Build → statisch via nginx (KONZEPT-konform).
|
||||
# Vorher lief hier `npm run dev` — der Vite-ENTWICKLUNGSSERVER — als
|
||||
# "Produktion". Jetzt: echter Build, nginx liefert aus und proxied /api.
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY docker/ui/package.json docker/ui/package-lock.json* ./
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
COPY docker/ui/package.json docker/ui/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY docker/ui/ .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY docker/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Rippy UI: statische Dateien + API-Proxy.
|
||||
# /api/* wird an den api-Container durchgereicht (Präfix wird entfernt),
|
||||
# alles andere ist das gebaute React-UI. Damit braucht der Browser nur
|
||||
# EINEN Origin — kein hartkodiertes localhost, kein CORS-Gefrickel.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
interface Device {
|
||||
@@ -13,8 +13,6 @@ interface Device {
|
||||
model?: string
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
export default function DeviceDiscovery() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -22,7 +20,7 @@ export default function DeviceDiscovery() {
|
||||
|
||||
const refreshDevices = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/devices`)
|
||||
const response = await api.get('/devices')
|
||||
setDevices(response.data)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Geräte:', error)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react'
|
||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
interface LiveLogEntry {
|
||||
@@ -16,12 +16,10 @@ export default function LiveLogSection() {
|
||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/jobs`)
|
||||
const response = await api.get('/jobs')
|
||||
const jobs = response.data
|
||||
const current = jobs.find((j: any) => j.status === 'processing')
|
||||
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
||||
@@ -30,15 +28,23 @@ export default function LiveLogSection() {
|
||||
}
|
||||
}
|
||||
|
||||
fetchJobs()
|
||||
const jobInterval = setInterval(fetchJobs, 5000)
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await api.get('/logs', { params: { limit: 50 } })
|
||||
setLogs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Logs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: WebSocket/SSE für Echtzeit-Logs implementieren
|
||||
// const logInterval = setInterval(fetchLogs, 1000)
|
||||
fetchJobs()
|
||||
fetchLogs()
|
||||
const jobInterval = setInterval(fetchJobs, 5000)
|
||||
const logInterval = setInterval(fetchLogs, 5000)
|
||||
|
||||
return () => {
|
||||
clearInterval(jobInterval)
|
||||
// clearInterval(logInterval)
|
||||
clearInterval(logInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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' })
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
import LiveLogSection from '../components/LiveLogSection'
|
||||
@@ -24,11 +24,9 @@ interface Device {
|
||||
status: 'empty' | 'ready' | 'ripping'
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
async function fetchJobs(): Promise<Job[]> {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/jobs`)
|
||||
const response = await api.get('/jobs')
|
||||
return response.data
|
||||
} catch {
|
||||
return []
|
||||
@@ -37,7 +35,7 @@ async function fetchJobs(): Promise<Job[]> {
|
||||
|
||||
async function fetchDevices(): Promise<Device[]> {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/devices`)
|
||||
const response = await api.get('/devices')
|
||||
return response.data
|
||||
} catch {
|
||||
return []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Terminal, Download, AlertCircle, CheckCircle, Clock, RefreshCw } from 'lucide-react'
|
||||
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
interface LogEntry {
|
||||
@@ -18,25 +19,11 @@ export default function LogsPage() {
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
const refreshLogs = async () => {
|
||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
||||
try {
|
||||
// TODO: API endpoint für Logs implementieren
|
||||
// const response = await axios.get(`${API_URL}/logs`)
|
||||
// setLogs(response.data)
|
||||
|
||||
// Mock-Daten für Demo
|
||||
const mockLogs: LogEntry[] = [
|
||||
{ id: '1', timestamp: '2024-01-15 14:30:22', level: 'info', source: 'udev-daemon', message: 'Disc-Einwurf erkannt' },
|
||||
{ id: '2', timestamp: '2024-01-15 14:30:25', level: 'info', source: 'worker', message: 'Job erstellt: DVD, Device: /dev/disc/PLEXTOR_BD_XD' },
|
||||
{ id: '3', timestamp: '2024-01-15 14:30:30', level: 'success', source: 'prescan', message: 'Pre-Scan abgeschlossen, Confidence: 0.92' },
|
||||
{ id: '4', timestamp: '2024-01-15 14:30:35', level: 'info', source: 'tmdb', message: 'Metadaten lookup erfolgreich: "The Matrix"' },
|
||||
{ id: '5', timestamp: '2024-01-15 14:31:00', level: 'info', source: 'ripping', message: 'Starte MakeMKV-Ripping...' },
|
||||
{ id: '6', timestamp: '2024-01-15 14:35:22', level: 'warning', source: 'ripping', message: 'Kapitel 5 übersprungen (Trailer)' },
|
||||
{ id: '7', timestamp: '2024-01-15 14:40:15', level: 'success', source: 'ripping', message: 'Ripping abgeschlossen, Dateigröße: 4.2 GB' },
|
||||
{ id: '8', timestamp: '2024-01-15 14:40:20', level: 'info', source: 'jellyfin', message: 'NFO-Datei generiert' },
|
||||
{ id: '9', timestamp: '2024-01-15 14:40:25', level: 'info', source: 'jellyfin', message: 'Poster heruntergeladen' },
|
||||
{ id: '10', timestamp: '2024-01-15 14:40:30', level: 'success', source: 'worker', message: 'Job abgeschlossen' },
|
||||
]
|
||||
setLogs(mockLogs)
|
||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||
setLogs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Logs:', error)
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
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'
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
interface DeviceOption {
|
||||
path: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||
@@ -35,12 +39,32 @@ interface PrescanResult {
|
||||
|
||||
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.')
|
||||
@@ -51,7 +75,7 @@ export default function MetadataPreview() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/prescan`, { device_path: device })
|
||||
const response = await api.post('/prescan', { device_path: device })
|
||||
setResult(response.data)
|
||||
} catch (err) {
|
||||
setError('Pre-Scan fehlgeschlagen.')
|
||||
@@ -66,16 +90,16 @@ export default function MetadataPreview() {
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/metadata/confirm`, {
|
||||
await api.post('/metadata/confirm', {
|
||||
title: result.title,
|
||||
year: result.year,
|
||||
metadata: result.metadata
|
||||
})
|
||||
|
||||
// Job erstellen
|
||||
await axios.post(`${API_URL}/jobs`, {
|
||||
device: device,
|
||||
type: result.disc_type
|
||||
// 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.')
|
||||
@@ -119,10 +143,14 @@ export default function MetadataPreview() {
|
||||
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'}>Gerät auswählen...</option>
|
||||
<option value="/dev/cdrom" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>CD-ROM (/dev/cdrom)</option>
|
||||
<option value="/dev/dvd" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>DVD (/dev/dvd)</option>
|
||||
<option value="/dev/bluray" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>Blu-ray (/dev/bluray)</option>
|
||||
<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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
interface SettingsState {
|
||||
@@ -39,8 +39,9 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`)
|
||||
setSettings(response.data)
|
||||
const response = await api.get('/settings')
|
||||
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
|
||||
setSettings({ ...defaultSettings, ...response.data })
|
||||
} catch {
|
||||
setSettings(defaultSettings)
|
||||
} finally {
|
||||
@@ -58,7 +59,7 @@ export default function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await axios.post(`${import.meta.env.VITE_API_URL}/settings`, settings)
|
||||
await api.post('/settings', settings)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} finally {
|
||||
|
||||
@@ -1,16 +1,72 @@
|
||||
FROM python:3.12-slim
|
||||
# Worker-Image: MakeMKV (verlustfrei, Etappe 10) + abcde/cdparanoia (CD→FLAC).
|
||||
#
|
||||
# Multi-Stage: makemkv-oss wird aus Quellen gebaut (GPL-Teil), makemkv-bin ist
|
||||
# das proprietäre Binärpaket (EULA wird per tmp/eula_accepted akzeptiert —
|
||||
# Standard-Mechanismus des Makefiles für nicht-interaktive Builds).
|
||||
# Beide Stages sind auf bookworm gepinnt, damit die libavcodec-Version passt.
|
||||
#
|
||||
# Vorher stand hier `makepkg` (ein Arch-Linux-Werkzeug, existiert in Debian
|
||||
# nicht) — das Image war seit dem 23.07. gar nicht mehr baubar. Und es fehlte
|
||||
# schlicht JEDES Ripping-Werkzeug: weder HandBrake noch abcde noch MakeMKV
|
||||
# waren installiert.
|
||||
|
||||
WORKDIR /app
|
||||
FROM python:3.12-slim-bookworm AS makemkv-build
|
||||
|
||||
ARG MAKEMKV_VERSION=1.18.4
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
makepkg \
|
||||
syslinux-utils \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libssl-dev \
|
||||
libexpat1-dev \
|
||||
libavcodec-dev \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
||||
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \
|
||||
&& tar xzf oss.tar.gz \
|
||||
&& tar xzf bin.tar.gz
|
||||
|
||||
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
||||
&& ./configure --disable-gui --prefix=/usr/local \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install
|
||||
|
||||
RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
||||
&& mkdir -p tmp \
|
||||
&& touch tmp/eula_accepted \
|
||||
&& make \
|
||||
&& make install
|
||||
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
libexpat1 \
|
||||
zlib1g \
|
||||
libavcodec59 \
|
||||
abcde \
|
||||
cdparanoia \
|
||||
cd-discid \
|
||||
flac \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=makemkv-build /usr/local /usr/local
|
||||
RUN ldconfig
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/worker/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY docker/worker/ .
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
||||
|
||||
Die Tabellendefinition existiert bewusst identisch in API und Worker
|
||||
(docker/api/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
metadata = MetaData()
|
||||
|
||||
jobs = Table(
|
||||
"jobs",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("disc_type", String(16)),
|
||||
Column("device", String(64)),
|
||||
Column("title", String(255)),
|
||||
Column("status", String(16), nullable=False, server_default="pending"),
|
||||
Column("progress", Integer, nullable=False, server_default="0"),
|
||||
Column("output_path", Text),
|
||||
Column("error", Text),
|
||||
Column("created_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
logs = Table(
|
||||
"logs",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", DateTime(timezone=True)),
|
||||
Column("level", String(16)),
|
||||
Column("source", String(32)),
|
||||
Column("message", Text),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Legt fehlende Tabellen an (idempotent)."""
|
||||
metadata.create_all(engine)
|
||||
|
||||
|
||||
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 add_log(level: str, source: str, message: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
|
||||
|
||||
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
|
||||
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices — die Ausgabe ist
|
||||
immer nur "block special", die Erkennung lieferte also strukturell IMMER
|
||||
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
|
||||
bewies damit nichts.
|
||||
|
||||
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
|
||||
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
from fcntl import ioctl
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDROM_DISC_STATUS = 0x5327
|
||||
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
CDS_DRIVE_NOT_READY = 3
|
||||
CDS_DISC_OK = 4
|
||||
|
||||
CDS_AUDIO = 100
|
||||
CDS_DATA_1 = 101
|
||||
CDS_DATA_2 = 102
|
||||
CDS_XA_2_1 = 103
|
||||
CDS_XA_2_2 = 104
|
||||
CDS_MIXED = 105
|
||||
|
||||
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
|
||||
BLKGETSIZE64 = 0x80081272
|
||||
|
||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||
|
||||
|
||||
def _open_nonblock(device_path: str) -> int:
|
||||
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
|
||||
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
|
||||
def drive_status(device_path: str) -> int:
|
||||
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_status(device_path: str) -> int:
|
||||
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DISC_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_size_bytes(device_path: str) -> int:
|
||||
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
buf = bytearray(8)
|
||||
ioctl(fd, BLKGETSIZE64, buf)
|
||||
return struct.unpack("Q", bytes(buf))[0]
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||
return "cd"
|
||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
|
||||
try:
|
||||
if drive_status(device_path) != CDS_DISC_OK:
|
||||
return "no_disc"
|
||||
return classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||
except OSError:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Schreibt den MakeMKV-Beta-Key aus der Umgebung in die Settings (falls gesetzt).
|
||||
# Ohne Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
|
||||
set -e
|
||||
|
||||
if [ -n "${MAKEMKV_APP_KEY}" ]; then
|
||||
mkdir -p /root/.MakeMKV
|
||||
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
+87
-97
@@ -1,11 +1,14 @@
|
||||
"""Ripping-Tasks: DVD/Blu-ray via HandBrake, CD via abcde.
|
||||
"""Ripping-Tasks: DVD/Blu-ray verlustfrei via MakeMKV (Etappe 10), CD via abcde.
|
||||
|
||||
Review-Fixes 22.07.2026:
|
||||
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen
|
||||
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab.
|
||||
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
|
||||
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde
|
||||
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig.
|
||||
Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
|
||||
verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
|
||||
nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
|
||||
|
||||
MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
|
||||
https://www.makemkv.com/developers/usage.txt (Robot-Mode `-r`):
|
||||
PRGV:current,total,max → Fortschritt gesamt = total/max
|
||||
MSG:code,flags,... → Meldungen
|
||||
(AGENTS Regel D: externe Schnittstellen nie aus dem Kopf.)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -14,14 +17,12 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from celery_app import celery_app
|
||||
|
||||
HANDBRAKE_PRESET = "Fast 1080p30"
|
||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||
|
||||
|
||||
def check_handbrake_installed() -> bool:
|
||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||
return shutil.which("HandBrakeCLI") is not None
|
||||
def check_makemkv_installed() -> bool:
|
||||
"""Prüft, ob makemkvcon installiert ist."""
|
||||
return shutil.which("makemkvcon") is not None
|
||||
|
||||
|
||||
def check_abcde_installed() -> bool:
|
||||
@@ -34,31 +35,39 @@ def check_cdparanoia_installed() -> bool:
|
||||
return shutil.which("cdparanoia") is not None
|
||||
|
||||
|
||||
def get_progress_from_line(line: str) -> int:
|
||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||
|
||||
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
||||
dem Prozentzeichen — die alte Regex ohne \\s* hat NIE einen Fortschritt
|
||||
aus echter Ausgabe geparst.
|
||||
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
||||
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
||||
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
||||
"""
|
||||
match = re.search(r'(\d+\.\d+)\s*%', line)
|
||||
if match:
|
||||
return int(float(match.group(1)))
|
||||
return 0
|
||||
|
||||
|
||||
def build_handbrake_cmd(device_path: str, output_path: str) -> list:
|
||||
"""Baut das HandBrake-Kommando (pure Funktion, testbar)."""
|
||||
return [
|
||||
"HandBrakeCLI",
|
||||
"--input", device_path,
|
||||
"--output", output_path,
|
||||
"--all",
|
||||
"--progress",
|
||||
"--preset", HANDBRAKE_PRESET
|
||||
"makemkvcon",
|
||||
"-r",
|
||||
"--progress=-same",
|
||||
"mkv",
|
||||
f"dev:{device_path}",
|
||||
"all",
|
||||
output_dir,
|
||||
]
|
||||
|
||||
|
||||
def get_progress_from_prgv(line: str) -> int:
|
||||
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||
|
||||
Format laut MakeMKV-Doku: PRGV:current,total,max
|
||||
`total` ist der Gesamtfortschritt, `max` die Skala (65536).
|
||||
"""
|
||||
match = re.match(r"PRGV:(\d+),(\d+),(\d+)", line.strip())
|
||||
if not match:
|
||||
return -1
|
||||
total, maximum = int(match.group(2)), int(match.group(3))
|
||||
if maximum <= 0:
|
||||
return -1
|
||||
return min(100, int(total * 100 / maximum))
|
||||
|
||||
|
||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||
|
||||
@@ -85,95 +94,72 @@ def write_abcde_config(output_dir: str) -> str:
|
||||
return tmp.name
|
||||
|
||||
|
||||
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict:
|
||||
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback."""
|
||||
if not check_handbrake_installed():
|
||||
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
||||
if not check_makemkv_installed():
|
||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
build_handbrake_cmd(device_path, output_path),
|
||||
build_makemkv_cmd(device_path, output_dir),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
letzte_meldung = ""
|
||||
for line in process.stdout:
|
||||
progress = get_progress_from_line(line)
|
||||
if progress > 0 and progress_cb:
|
||||
progress = get_progress_from_prgv(line)
|
||||
if progress >= 0 and progress_cb:
|
||||
progress_cb(progress)
|
||||
elif line.startswith("MSG:"):
|
||||
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
||||
teile = line.split(",", 4)
|
||||
if len(teile) >= 4:
|
||||
letzte_meldung = teile[3].strip('"')
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
mkv_dateien = [
|
||||
os.path.join(output_dir, f)
|
||||
for f in sorted(os.listdir(output_dir))
|
||||
if f.endswith(".mkv")
|
||||
]
|
||||
|
||||
if process.returncode == 0 and mkv_dateien:
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"return_code": process.returncode
|
||||
"output_dir": output_dir,
|
||||
"files": mkv_dateien,
|
||||
"return_code": process.returncode,
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HandBrake failed with code {process.returncode}",
|
||||
"return_code": process.returncode
|
||||
"error": (
|
||||
f"makemkvcon endete mit Code {process.returncode}"
|
||||
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
|
||||
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
|
||||
),
|
||||
"return_code": process.returncode,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Disc-Typ anhand des Gerätepfads."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["file", "-L", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
output = result.stdout.lower()
|
||||
if "dvd" in output or "video_ts" in output:
|
||||
return "dvd"
|
||||
elif "bluray" in output or "bdmv" in output:
|
||||
return "bluray"
|
||||
elif "audio" in output or "cda" in output:
|
||||
return "cd"
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
|
||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||
|
||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift.
|
||||
"""
|
||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
||||
|
||||
|
||||
def _progress_melder(task):
|
||||
"""Baut einen Fortschritts-Callback, der Celery-Standard update_state nutzt."""
|
||||
def melde(progress: int, message: str = ""):
|
||||
task.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"progress": progress, "status": "ripping", "message": message}
|
||||
)
|
||||
return melde
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_dvd")
|
||||
def rip_dvd(self, device_path: str, disc_id: str) -> dict:
|
||||
"""Rippt eine DVD mit HandBrake."""
|
||||
output_dir = f"/output/dvd/{disc_id}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = f"{output_dir}/dvd_{disc_id}.mkv"
|
||||
melde = _progress_melder(self)
|
||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_bluray")
|
||||
def rip_bluray(self, device_path: str, disc_id: str) -> dict:
|
||||
"""Rippt eine Blu-ray mit HandBrake."""
|
||||
output_dir = f"/output/bluray/{disc_id}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = f"{output_dir}/bluray_{disc_id}.mkv"
|
||||
melde = _progress_melder(self)
|
||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_cd")
|
||||
def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC)."""
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
@@ -186,9 +172,13 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
||||
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
||||
}
|
||||
|
||||
output_dir = f"/output/cd/{disc_id}"
|
||||
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
melde = _progress_melder(self)
|
||||
|
||||
def melde(progress: int, message: str = ""):
|
||||
if progress_cb:
|
||||
progress_cb(progress, message)
|
||||
|
||||
config_path = write_abcde_config(output_dir)
|
||||
|
||||
try:
|
||||
|
||||
+60
-15
@@ -1,24 +1,69 @@
|
||||
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
|
||||
|
||||
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
|
||||
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
|
||||
Ereignisse ins Log. Das UI liest beides über die API.
|
||||
"""
|
||||
|
||||
import db
|
||||
from celery_app import celery_app
|
||||
from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type
|
||||
from detection import detect_disc_type
|
||||
from ripping import rip_cd, rip_video
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||
def rip_disc(self, device_path: str, disc_id: str = None):
|
||||
"""Rippt eine Disc basierend auf ihrem Typ."""
|
||||
def rip_disc(self, device_path: str, job_id: str):
|
||||
"""Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API."""
|
||||
db.init_db()
|
||||
disc_type = detect_disc_type(device_path)
|
||||
|
||||
if not disc_id:
|
||||
disc_id = device_path.replace("/", "_")
|
||||
if disc_type in ("no_disc", "unknown"):
|
||||
fehler = (
|
||||
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
||||
else "Disc-Typ nicht erkennbar"
|
||||
)
|
||||
db.update_job(
|
||||
job_id, status="failed", error=fehler, finished_at=db.utcnow()
|
||||
)
|
||||
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
||||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||
|
||||
db.update_job(job_id, status="running", disc_type=disc_type)
|
||||
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||
|
||||
letzter = [-1]
|
||||
|
||||
def fortschritt(progress: int, message: str = ""):
|
||||
# MakeMKV liefert viele PRGV-Zeilen pro Sekunde — DB nur bei Änderung.
|
||||
if progress == letzter[0]:
|
||||
return
|
||||
letzter[0] = progress
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"progress": progress, "status": "ripping", "message": message},
|
||||
)
|
||||
db.update_job(job_id, progress=progress)
|
||||
|
||||
if disc_type == "cd":
|
||||
return rip_cd(device_path, disc_id)
|
||||
elif disc_type == "dvd":
|
||||
return rip_dvd(device_path, disc_id)
|
||||
elif disc_type == "bluray":
|
||||
return rip_bluray(device_path, disc_id)
|
||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Unbekannter Disc-Typ: {disc_type}",
|
||||
"disc_type": disc_type
|
||||
}
|
||||
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')}")
|
||||
|
||||
return ergebnis
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests für detection.py — die pure Zuordnung classify().
|
||||
|
||||
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
|
||||
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
|
||||
nur echte, deterministische Logik getestet — die ioctl-Aufrufe selbst sind
|
||||
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
|
||||
"""
|
||||
|
||||
from detection import (
|
||||
BLURAY_MIN_BYTES,
|
||||
CDS_AUDIO,
|
||||
CDS_DATA_1,
|
||||
CDS_MIXED,
|
||||
classify,
|
||||
)
|
||||
|
||||
|
||||
def test_audio_cd():
|
||||
assert classify(CDS_AUDIO, 700 * 1024**2) == "cd"
|
||||
|
||||
|
||||
def test_mixed_mode_zaehlt_als_cd():
|
||||
assert classify(CDS_MIXED, 700 * 1024**2) == "cd"
|
||||
|
||||
|
||||
def test_dvd_unter_schwelle():
|
||||
# DVD9 = ~8,5 GB — liegt unter der 10-GB-Blu-ray-Schwelle
|
||||
assert classify(CDS_DATA_1, 8 * 1024**3) == "dvd"
|
||||
|
||||
|
||||
def test_bluray_ab_schwelle():
|
||||
# BD-SL = 25 GB
|
||||
assert classify(CDS_DATA_1, 25 * 1024**3) == "bluray"
|
||||
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
|
||||
|
||||
|
||||
def test_unbekannter_status():
|
||||
assert classify(999, 5 * 1024**3) == "unknown"
|
||||
assert classify(0, 0) == "unknown"
|
||||
@@ -1,25 +1,43 @@
|
||||
"""Tests für ripping.py: Fortschritts-Parsing, Kommando-Bau, Disc-Erkennung.
|
||||
"""Tests für ripping.py: Kommando-Bau und Fortschritts-Parsing.
|
||||
|
||||
Deckt genau die Stellen ab, an denen im Review 22.07. erfundene Schnittstellen
|
||||
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt.
|
||||
Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
|
||||
(abcde-Flags, Celery-API, HandBrake-Regex) — damit so etwas nie wieder still liegt.
|
||||
Das PRGV-Format stammt aus der MakeMKV-Doku (makemkv.com/developers/usage.txt).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import ripping
|
||||
from ripping import (
|
||||
build_abcde_cmd,
|
||||
build_handbrake_cmd,
|
||||
detect_disc_type,
|
||||
get_progress_from_line,
|
||||
build_makemkv_cmd,
|
||||
get_progress_from_prgv,
|
||||
write_abcde_config,
|
||||
)
|
||||
|
||||
|
||||
def test_progress_parsing():
|
||||
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_makemkv_cmd_vollstaendig():
|
||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||
assert cmd[0] == "makemkvcon"
|
||||
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||
assert "mkv" in cmd
|
||||
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
||||
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
||||
|
||||
|
||||
def test_prgv_parsing():
|
||||
# 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:0,65536,65536") == 100
|
||||
assert get_progress_from_prgv("PRGV:0,0,65536") == 0
|
||||
|
||||
|
||||
def test_prgv_parsing_ignoriert_fremde_zeilen():
|
||||
# -1 heißt „keine Fortschrittszeile" — MSG-Zeilen dürfen NIE als 0% gelten,
|
||||
# sonst springt die Anzeige ständig auf null zurück.
|
||||
assert get_progress_from_prgv('MSG:1005,0,1,"MakeMKV gestartet","%1","x"') == -1
|
||||
assert get_progress_from_prgv("irgendwas") == -1
|
||||
assert get_progress_from_prgv("PRGV:kaputt") == -1
|
||||
|
||||
|
||||
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||
@@ -34,46 +52,11 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||
|
||||
|
||||
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||
pfad = write_abcde_config("/output/cd/test123")
|
||||
pfad = write_abcde_config("/app/media/cd/test123")
|
||||
try:
|
||||
with open(pfad, encoding="utf-8") as f:
|
||||
inhalt = f.read()
|
||||
assert "OUTPUTDIR='/output/cd/test123'" in inhalt
|
||||
assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
|
||||
assert "INTERACTIVE=n" in inhalt
|
||||
finally:
|
||||
os.unlink(pfad)
|
||||
|
||||
|
||||
def test_handbrake_cmd_vollstaendig():
|
||||
cmd = build_handbrake_cmd("/dev/sr0", "/output/dvd/x/film.mkv")
|
||||
assert cmd[0] == "HandBrakeCLI"
|
||||
assert cmd[cmd.index("--input") + 1] == "/dev/sr0"
|
||||
assert cmd[cmd.index("--output") + 1] == "/output/dvd/x/film.mkv"
|
||||
assert "--preset" in cmd
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, stdout):
|
||||
self.stdout = stdout
|
||||
|
||||
|
||||
def test_disc_typ_erkennung(monkeypatch):
|
||||
faelle = [
|
||||
("UDF filesystem data 'MEIN_FILM' DVD Video", "dvd"),
|
||||
("data, BDMV bluray structure", "bluray"),
|
||||
("Audio CD, cda tracks", "cd"),
|
||||
("irgendwas anderes", "unknown"),
|
||||
]
|
||||
for ausgabe, erwartet in faelle:
|
||||
monkeypatch.setattr(
|
||||
ripping.subprocess, "run", lambda *a, _out=ausgabe, **k: _FakeResult(_out)
|
||||
)
|
||||
assert detect_disc_type("/dev/sr0") == erwartet
|
||||
|
||||
|
||||
def test_disc_typ_erkennung_fehler_gibt_unknown(monkeypatch):
|
||||
def kaputt(*a, **k):
|
||||
raise OSError("kein Geraet")
|
||||
|
||||
monkeypatch.setattr(ripping.subprocess, "run", kaputt)
|
||||
assert detect_disc_type("/dev/sr0") == "unknown"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Rippy Installations-Skript für LXC 106
|
||||
# Führe dies auf LXC 106 aus: bash install-rippy.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Rippy Installation auf LXC 106 ==="
|
||||
|
||||
# Verzeichnis erstellen
|
||||
PROJECT_DIR="/opt/docker/rippy"
|
||||
mkdir -p "$PROJECT_DIR"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Git clone
|
||||
echo "Klone Repository..."
|
||||
if [ -d ".git" ]; then
|
||||
git pull origin main
|
||||
else
|
||||
git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git .
|
||||
fi
|
||||
|
||||
# Docker Compose starten
|
||||
echo "Starte Rippy mit Docker Compose..."
|
||||
docker compose up -d
|
||||
|
||||
echo "=== Installation abgeschlossen ==="
|
||||
echo "Rippy läuft unter: http://localhost:8000"
|
||||
echo "Logs ansehen: docker compose logs -f"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Skript zum Hinzufügen des Laufwerks zur Proxmox VM
|
||||
|
||||
echo "=== Laufwerk zur Proxmox VM hinzufügen ==="
|
||||
|
||||
# Prüfen ob das Laufwerk existiert
|
||||
if [ ! -b /dev/sr0 ]; then
|
||||
echo "❌ Laufwerk /dev/sr0 nicht gefunden"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Laufwerk /dev/sr0 gefunden"
|
||||
|
||||
# VM-Config sichern
|
||||
echo "Sichere VM-Konfiguration..."
|
||||
cp /etc/pve/qemu-server/106.conf /etc/pve/qemu-server/106.conf.backup
|
||||
echo "✅ Backup erstellt: /etc/pve/qemu-server/106.conf.backup"
|
||||
|
||||
# Laufwerk hinzufügen
|
||||
echo "Füge Laufwerk zur VM hinzu..."
|
||||
# Prüfen ob schon eingebunden
|
||||
if grep -q "scsi1: /dev/sr0" /etc/pve/qemu-server/106.conf; then
|
||||
echo "⚠️ Laufwerk ist bereits eingebunden"
|
||||
else
|
||||
echo "scsi1: /dev/sr0,media=cdrom" >> /etc/pve/qemu-server/106.conf
|
||||
echo "✅ Laufwerk hinzugefügt"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== VM neu starten ==="
|
||||
echo "Führe folgende Befehle aus:"
|
||||
echo " qm shutdown 106"
|
||||
echo " wait for VM to stop"
|
||||
echo " qm start 106"
|
||||
echo ""
|
||||
echo "Oder im Proxmox Web-UI:"
|
||||
echo " 1. VM 106 auswählen"
|
||||
echo " 2. 'Shutdown' klicken"
|
||||
echo " 3. Nach dem Stop 'Start' klicken"
|
||||
echo ""
|
||||
echo "=== Nach dem Neustart ==="
|
||||
echo "Im Container rippy-worker sollte jetzt /dev/sr0 sichtbar sein:"
|
||||
echo " docker exec rippy-worker ls -la /dev/sr0"
|
||||
echo " docker exec rippy-worker ls -la /dev/cdrom"
|
||||
@@ -1,8 +0,0 @@
|
||||
# Proxmox QEMU Konfiguration für Laufwerk-Durchreichung
|
||||
# Füge diese Zeile zu /etc/pve/qemu-server/106.conf hinzu
|
||||
|
||||
# Option 1: Als SCSI Device (empfohlen)
|
||||
scsi1: /dev/sr0,media=cdrom
|
||||
|
||||
# Option 2: Alternativ als SATA Device
|
||||
sata1: /dev/sr0,media=cdrom
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Setup Rippy auf Arcane VM
|
||||
|
||||
cd /home/arcane/projects
|
||||
|
||||
# Verzeichnis erstellen
|
||||
mkdir -p rippy
|
||||
cd rippy
|
||||
|
||||
# Dateien von Host kopieren
|
||||
scp -r arcane@192.168.178.1:/"Coding Stuff/rippy"/* .
|
||||
|
||||
# Docker Compose starten
|
||||
docker compose up -d
|
||||
@@ -1,3 +0,0 @@
|
||||
git clone --mirror https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git /tmp/rippy.git
|
||||
cd /tmp/rippy.git && git remote set-url origin https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git
|
||||
git clone file:///tmp/rippy.git /home/arcane/projects/rippy
|
||||
@@ -1,8 +0,0 @@
|
||||
# udev-Regel für Disc-Einwurf-Erkennung
|
||||
# Diese Datei muss nach /etc/udev/rules.d/ kopiert werden
|
||||
|
||||
# CD/DVD/Blu-ray Einwurf-Ereignis
|
||||
ACTION=="add", SUBSYSTEM=="block", KERNEL=="sr*", ENV{ID_TYPE}=="cd", SYMLINK+="disc/%E{ID_SERIAL}", RUN+="/app/udev/udev_daemon.py add %k"
|
||||
|
||||
# CD/DVD/Blu-ray Auswurf-Ereignis
|
||||
ACTION=="remove", SUBSYSTEM=="block", KERNEL=="sr*", ENV{ID_TYPE}=="cd", RUN+="/app/udev/udev_daemon.py remove %k"
|
||||
Reference in New Issue
Block a user