Merge Kernumbau 23.07.: Etappe 10 (MakeMKV) + Etappe 11 (Job-Kette, UI, Compose)
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:
Hitonabi
2026-07-23 15:06:12 +02:00
44 changed files with 1141 additions and 869 deletions
+3
View File
@@ -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
View File
@@ -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/
-31
View File
@@ -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
-47
View File
@@ -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
-62
View File
@@ -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
-49
View File
@@ -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
-58
View File
@@ -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
-72
View File
@@ -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
View File
@@ -222,10 +222,32 @@
**Ziel:** DVD/Blu-ray-Ripping auf `makemkvcon` umstellen (verlustfrei), wie im **Ziel:** DVD/Blu-ray-Ripping auf `makemkvcon` umstellen (verlustfrei), wie im
KONZEPT als Muss definiert. HandBrake fliegt aus dem Ripp-Pfad (bleibt ggf. als KONZEPT als Muss definiert. HandBrake fliegt aus dem Ripp-Pfad (bleibt ggf. als
optionaler Transcode-Schritt NACH dem verlustfreien Rip). optionaler Transcode-Schritt NACH dem verlustfreien Rip).
- [ ] Worker-Dockerfile: makemkv-oss/makemkv-bin bauen + Beta-Key-Handling (Env/Cron) - [x] Worker-Dockerfile: makemkv-oss/makemkv-bin 1.18.4 multi-stage gebaut,
- [ ] ripping.py: rip_dvd/rip_bluray auf `makemkvcon --all --progress mkv` umstellen Beta-Key via MAKEMKV_APP_KEY-Env (entrypoint.sh) — 23.07.
(Fortschritts-Parsing anpassen, Tests zuerst!) - [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) - [ ] E2E mit echter Disc verifizieren (Commander, Laufwerk nötig)
**Fertig wenn:** eine echte DVD landet verlustfrei als MKV im Output, Ampel grün. **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 **Entstanden aus:** Review 22.07. — HandBrake-lossy war eine stille Abweichung vom
Konzept; Entscheid 22.07. abends: Konzept gilt, Umbau als eigene Etappe. 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
View File
@@ -1,6 +1,34 @@
# SAVEPOINT — Rippy # 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)** **v1.10 — Complete Dark Mode Implementation (22.07.2026)**
-42
View File
@@ -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"
-42
View File
@@ -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"
-26
View File
@@ -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
View File
@@ -1,4 +1,9 @@
# Docker Compose für Rippy # 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: services:
api: api:
@@ -23,13 +28,15 @@ services:
volumes: volumes:
- media:/app/media - media:/app/media
- temp:/app/temp - temp:/app/temp
- /dev/cdrom:/dev/cdrom:ro devices:
- /dev/sr0:/dev/sr0:ro - /dev/sr0:/dev/sr0
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
- postgres postgres:
- redis condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
read_only: false read_only: false
@@ -40,23 +47,25 @@ services:
environment: environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy - DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- TMDB_API_KEY=${TMDB_API_KEY} - RIP_OUTPUT_DIR=/app/media
- THETVDB_API_KEY=${THETVDB_API_KEY} - MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
volumes: volumes:
- media:/app/media - media:/app/media
- temp:/app/temp - temp:/app/temp
- disc:/dev/disc devices:
- /dev/cdrom:/dev/cdrom:ro - /dev/sr0:/dev/sr0
- /dev/sr0:/dev/sr0:ro
networks: networks:
- rippy-net - rippy-net
depends_on: depends_on:
- postgres postgres:
- redis condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped 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: tmpfs:
- /app/tmp - /app/tmp
- /run - /run
@@ -112,6 +121,5 @@ networks:
volumes: volumes:
media: media:
temp: temp:
disc:
postgres-data: postgres-data:
redis-data: redis-data:
+3 -2
View File
@@ -1,8 +1,9 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm
WORKDIR /app 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 . COPY docker/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+20
View File
@@ -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]
)
+140
View File
@@ -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))
+91
View File
@@ -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"
+63
View File
@@ -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"),
}
+136 -85
View File
@@ -4,9 +4,14 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pathlib import Path from pathlib import Path
import subprocess
import asyncio import asyncio
import json 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 from fastapi.security import OAuth2PasswordBearer
@@ -41,20 +46,47 @@ app = FastAPI(
# OAuth2 Scheme # OAuth2 Scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# SSE-Connections
sse_connections: List = []
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialisiere Cache beim Start und validiere Konfiguration.""" """Initialisiere Cache + Datenbank, validiere Konfiguration, starte Disc-Wache."""
init_cache() init_cache()
db.init_db()
try: try:
validate_config() validate_config()
except ConfigValidationError as e: except ConfigValidationError as e:
print(f"⚠️ Konfigurations-Warnung: {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 # Middleware für Rate-Limiting
@app.middleware("http") @app.middleware("http")
@@ -102,6 +134,8 @@ class Job(BaseModel):
startTime: str startTime: str
endTime: Optional[str] = None endTime: Optional[str] = None
progress: int = 0 progress: int = 0
title: Optional[str] = None
error: Optional[str] = None
class Device(BaseModel): class Device(BaseModel):
id: str id: str
@@ -109,6 +143,24 @@ class Device(BaseModel):
type: str type: str
path: str path: str
status: 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") @app.get("/health")
async def health_check(): async def health_check():
@@ -126,96 +178,95 @@ async def root():
@app.get("/jobs", response_model=List[Job]) @app.get("/jobs", response_model=List[Job])
async def get_jobs(): async def get_jobs():
"""Holt alle Jobs.""" """Holt alle Jobs aus der Datenbank (neueste zuerst)."""
return [] 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]) @app.get("/devices", response_model=List[Device])
async def get_devices(): async def get_devices():
"""Holt alle Geräte.""" """Alle optischen Laufwerke mit ehrlichem Status (leer/bereit + Disc-Typ).
devices = []
Der alte Weg (udevadm + /dev/disc-Symlinks) lieferte im Container
try: prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
# 1. Zuerst Symlinks in /dev/disc/ prüfen der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
result = subprocess.run( """
["ls", "-la", "/dev/disc/"], geraete = []
capture_output=True, for pfad in device_discovery.list_optical_devices():
text=True, info = await asyncio.to_thread(device_discovery.device_info, pfad)
timeout=5 geraete.append(Device(**info))
) return geraete
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
# SSE-Stream für Echtzeit-Updates # SSE-Stream für Echtzeit-Updates
@app.get("/stream/jobs") @app.get("/stream/jobs")
async def job_stream(): 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(): async def event_generator():
while True: while True:
if sse_connections: jobs = await get_jobs()
# Job-Status aktualisieren yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
jobs = await get_jobs() await asyncio.sleep(2)
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream") return StreamingResponse(event_generator(), media_type="text/event-stream")
+32
View File
@@ -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
View File
@@ -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 WORKDIR /app
RUN apk add --no-cache curl COPY docker/ui/package.json docker/ui/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY docker/ui/package.json docker/ui/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY docker/ui/ . 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 EXPOSE 80
CMD ["npm", "run", "dev"]
+25
View File
@@ -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;
}
}
+2 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import axios from 'axios'
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
interface Device { interface Device {
@@ -13,8 +13,6 @@ interface Device {
model?: string model?: string
} }
const API_URL = 'http://localhost:8000'
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)
@@ -22,7 +20,7 @@ export default function DeviceDiscovery() {
const refreshDevices = async () => { const refreshDevices = async () => {
try { try {
const response = await axios.get(`${API_URL}/devices`) const response = await api.get('/devices')
setDevices(response.data) setDevices(response.data)
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Geräte:', error) console.error('Fehler beim Laden der Geräte:', error)
+16 -10
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import axios from 'axios' import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
interface LiveLogEntry { interface LiveLogEntry {
@@ -16,12 +16,10 @@ export default function LiveLogSection() {
const [currentJob, setCurrentJob] = useState<any>(null) const [currentJob, setCurrentJob] = useState<any>(null)
const { theme } = useDarkMode() const { theme } = useDarkMode()
const API_URL = 'http://localhost:8000'
useEffect(() => { useEffect(() => {
const fetchJobs = async () => { const fetchJobs = async () => {
try { try {
const response = await axios.get(`${API_URL}/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')
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
@@ -30,15 +28,23 @@ export default function LiveLogSection() {
} }
} }
fetchJobs() const fetchLogs = async () => {
const jobInterval = setInterval(fetchJobs, 5000) 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 fetchJobs()
// const logInterval = setInterval(fetchLogs, 1000) fetchLogs()
const jobInterval = setInterval(fetchJobs, 5000)
const logInterval = setInterval(fetchLogs, 5000)
return () => { return () => {
clearInterval(jobInterval) clearInterval(jobInterval)
// clearInterval(logInterval) clearInterval(logInterval)
} }
}, []) }, [])
+7
View File
@@ -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' })
+3 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import axios from 'axios'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' 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 { useDarkMode } from '../context/ThemeContext'
import DeviceDiscovery from '../components/DeviceDiscovery' import DeviceDiscovery from '../components/DeviceDiscovery'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
@@ -24,11 +24,9 @@ interface Device {
status: 'empty' | 'ready' | 'ripping' status: 'empty' | 'ready' | 'ripping'
} }
const API_URL = 'http://localhost:8000'
async function fetchJobs(): Promise<Job[]> { async function fetchJobs(): Promise<Job[]> {
try { try {
const response = await axios.get(`${API_URL}/jobs`) const response = await api.get('/jobs')
return response.data return response.data
} catch { } catch {
return [] return []
@@ -37,7 +35,7 @@ async function fetchJobs(): Promise<Job[]> {
async function fetchDevices(): Promise<Device[]> { async function fetchDevices(): Promise<Device[]> {
try { try {
const response = await axios.get(`${API_URL}/devices`) const response = await api.get('/devices')
return response.data return response.data
} catch { } catch {
return [] return []
+6 -19
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' 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' import { useDarkMode } from '../context/ThemeContext'
interface LogEntry { interface LogEntry {
@@ -18,25 +19,11 @@ export default function LogsPage() {
const { theme } = useDarkMode() const { theme } = useDarkMode()
const refreshLogs = async () => { 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 { try {
// TODO: API endpoint für Logs implementieren const response = await api.get('/logs', { params: { limit: 200 } })
// const response = await axios.get(`${API_URL}/logs`) setLogs(response.data)
// 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)
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Logs:', error) console.error('Fehler beim Laden der Logs:', error)
} finally { } finally {
+40 -12
View File
@@ -1,10 +1,14 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import axios from 'axios'
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react' import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
const API_URL = 'http://localhost:8000' interface DeviceOption {
path: string
name: string
status: string
}
interface Metadata { interface Metadata {
type: 'movie' | 'tv' | 'music' | 'unknown' type: 'movie' | 'tv' | 'music' | 'unknown'
@@ -35,12 +39,32 @@ interface PrescanResult {
export default function MetadataPreview() { export default function MetadataPreview() {
const [device, setDevice] = useState<string>('') const [device, setDevice] = useState<string>('')
const [devices, setDevices] = useState<DeviceOption[]>([])
const [result, setResult] = useState<PrescanResult | null>(null) const [result, setResult] = useState<PrescanResult | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const { theme } = useDarkMode() 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 () => { const handleScan = async () => {
if (!device) { if (!device) {
setError('Bitte wählen Sie ein Gerät aus.') setError('Bitte wählen Sie ein Gerät aus.')
@@ -51,7 +75,7 @@ export default function MetadataPreview() {
setError(null) setError(null)
try { try {
const response = await axios.post(`${API_URL}/prescan`, { device_path: device }) const response = await api.post('/prescan', { device_path: device })
setResult(response.data) setResult(response.data)
} catch (err) { } catch (err) {
setError('Pre-Scan fehlgeschlagen.') setError('Pre-Scan fehlgeschlagen.')
@@ -66,16 +90,16 @@ export default function MetadataPreview() {
setConfirming(true) setConfirming(true)
try { try {
await axios.post(`${API_URL}/metadata/confirm`, { await api.post('/metadata/confirm', {
title: result.title, title: result.title,
year: result.year, year: result.year,
metadata: result.metadata metadata: result.metadata
}) })
// Job erstellen // Job erstellen — POST /jobs existiert seit 23.07. wirklich
await axios.post(`${API_URL}/jobs`, { await api.post('/jobs', {
device: device, device_path: device,
type: result.disc_type title: result.title
}) })
} catch (err) { } catch (err) {
setError('Bestätigung fehlgeschlagen.') setError('Bestätigung fehlgeschlagen.')
@@ -119,10 +143,14 @@ export default function MetadataPreview() {
onChange={(e) => setDevice(e.target.value)} 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'}`} 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="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
<option value="/dev/cdrom" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>CD-ROM (/dev/cdrom)</option> {devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
<option value="/dev/dvd" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>DVD (/dev/dvd)</option> </option>
<option value="/dev/bluray" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>Blu-ray (/dev/bluray)</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> </select>
<button <button
+5 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import axios from 'axios'
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react' import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
interface SettingsState { interface SettingsState {
@@ -39,8 +39,9 @@ export default function SettingsPage() {
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`) const response = await api.get('/settings')
setSettings(response.data) // Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
setSettings({ ...defaultSettings, ...response.data })
} catch { } catch {
setSettings(defaultSettings) setSettings(defaultSettings)
} finally { } finally {
@@ -58,7 +59,7 @@ export default function SettingsPage() {
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
await axios.post(`${import.meta.env.VITE_API_URL}/settings`, settings) await api.post('/settings', settings)
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 3000) setTimeout(() => setSaved(false), 3000)
} finally { } finally {
+61 -5
View File
@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ build-essential \
makepkg \ pkg-config \
syslinux-utils \ ca-certificates \
curl \
libssl-dev \
libexpat1-dev \
libavcodec-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/* && 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 . COPY docker/worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ . COPY docker/worker/ .
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"] CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
+73
View File
@@ -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)
)
+91
View File
@@ -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"
+11
View File
@@ -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
View File
@@ -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: Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab. nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig. 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 import os
@@ -14,14 +17,12 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
from celery_app import celery_app RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
HANDBRAKE_PRESET = "Fast 1080p30"
def check_handbrake_installed() -> bool: def check_makemkv_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist.""" """Prüft, ob makemkvcon installiert ist."""
return shutil.which("HandBrakeCLI") is not None return shutil.which("makemkvcon") is not None
def check_abcde_installed() -> bool: def check_abcde_installed() -> bool:
@@ -34,31 +35,39 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None return shutil.which("cdparanoia") is not None
def get_progress_from_line(line: str) -> int: def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe. """Baut das MakeMKV-Kommando (pure Funktion, testbar).
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor -r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
dem Prozentzeichen — die alte Regex ohne \\s* hat NIE einen Fortschritt --progress=-same Fortschritt in denselben Stream wie die Meldungen
aus echter Ausgabe geparst. 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 [ return [
"HandBrakeCLI", "makemkvcon",
"--input", device_path, "-r",
"--output", output_path, "--progress=-same",
"--all", "mkv",
"--progress", f"dev:{device_path}",
"--preset", HANDBRAKE_PRESET "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: 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).
@@ -85,95 +94,72 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name return tmp.name
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict: def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback.""" """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
if not check_handbrake_installed(): if not check_makemkv_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} return {"status": "error", "error": "makemkvcon ist nicht installiert"}
os.makedirs(output_dir, exist_ok=True)
try: try:
process = subprocess.Popen( process = subprocess.Popen(
build_handbrake_cmd(device_path, output_path), build_makemkv_cmd(device_path, output_dir),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1 bufsize=1
) )
letzte_meldung = ""
for line in process.stdout: for line in process.stdout:
progress = get_progress_from_line(line) progress = get_progress_from_prgv(line)
if progress > 0 and progress_cb: if progress >= 0 and progress_cb:
progress_cb(progress) 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() 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 { return {
"status": "success", "status": "success",
"output_path": output_path, "output_dir": output_dir,
"return_code": process.returncode "files": mkv_dateien,
"return_code": process.returncode,
} }
return { return {
"status": "error", "status": "error",
"error": f"HandBrake failed with code {process.returncode}", "error": (
"return_code": process.returncode 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: except Exception as e:
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
def detect_disc_type(device_path: str) -> str: def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
"""Erkennt den Disc-Typ anhand des Gerätepfads.""" """Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
try:
result = subprocess.run( Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
["file", "-L", device_path], der hier mit seinem eigenen Fortschritts-Callback durchgreift.
capture_output=True, """
text=True, output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
timeout=10 return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
)
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 _progress_melder(task): def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
"""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:
"""Rippt eine CD mit abcde (FLAC).""" """Rippt eine CD mit abcde (FLAC)."""
if not check_abcde_installed(): if not check_abcde_installed():
return { 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." "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) 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) config_path = write_abcde_config(output_dir)
try: try:
+62 -17
View File
@@ -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 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") @celery_app.task(bind=True, name="worker.tasks.rip_disc")
def rip_disc(self, device_path: str, disc_id: str = None): def rip_disc(self, device_path: str, job_id: str):
"""Rippt eine Disc basierend auf ihrem Typ.""" """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) disc_type = detect_disc_type(device_path)
if not disc_id: if disc_type in ("no_disc", "unknown"):
disc_id = device_path.replace("/", "_") 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": if disc_type == "cd":
return rip_cd(device_path, disc_id) ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
elif disc_type == "dvd":
return rip_dvd(device_path, disc_id)
elif disc_type == "bluray":
return rip_bluray(device_path, disc_id)
else: else:
return { ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
"status": "error", if ergebnis.get("status") == "success":
"message": f"Unbekannter Disc-Typ: {disc_type}", db.update_job(
"disc_type": disc_type 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
+39
View File
@@ -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"
+31 -48
View File
@@ -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 Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt. (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 os
import ripping
from ripping import ( from ripping import (
build_abcde_cmd, build_abcde_cmd,
build_handbrake_cmd, build_makemkv_cmd,
detect_disc_type, get_progress_from_prgv,
get_progress_from_line,
write_abcde_config, write_abcde_config,
) )
def test_progress_parsing(): def test_makemkv_cmd_vollstaendig():
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45 cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100 assert cmd[0] == "makemkvcon"
assert get_progress_from_line("kein Fortschritt hier") == 0 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(): 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(): def test_abcde_config_enthaelt_zielverzeichnis():
pfad = write_abcde_config("/output/cd/test123") pfad = write_abcde_config("/app/media/cd/test123")
try: try:
with open(pfad, encoding="utf-8") as f: with open(pfad, encoding="utf-8") as f:
inhalt = f.read() inhalt = f.read()
assert "OUTPUTDIR='/output/cd/test123'" in inhalt assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
assert "INTERACTIVE=n" in inhalt assert "INTERACTIVE=n" in inhalt
finally: finally:
os.unlink(pfad) 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"
-28
View File
@@ -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"
-44
View File
@@ -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"
-8
View File
@@ -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
-14
View File
@@ -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
-3
View File
@@ -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
-8
View File
@@ -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"