diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5aeb5d5 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 545cc55..88bcec7 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/ARCAN-FIX.md b/ARCAN-FIX.md deleted file mode 100644 index 6a4bb53..0000000 --- a/ARCAN-FIX.md +++ /dev/null @@ -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 diff --git a/ARCAN-INTEGRATION.md b/ARCAN-INTEGRATION.md deleted file mode 100644 index 5aa1c95..0000000 --- a/ARCAN-INTEGRATION.md +++ /dev/null @@ -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 diff --git a/ARCAN-PROJECTS-DIR.md b/ARCAN-PROJECTS-DIR.md deleted file mode 100644 index 41f2e32..0000000 --- a/ARCAN-PROJECTS-DIR.md +++ /dev/null @@ -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 diff --git a/ARCANE-DOCKER-COMPOSE-FIX.md b/ARCANE-DOCKER-COMPOSE-FIX.md deleted file mode 100644 index bbcc73e..0000000 --- a/ARCANE-DOCKER-COMPOSE-FIX.md +++ /dev/null @@ -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 diff --git a/ARCANE-VM-SETUP.md b/ARCANE-VM-SETUP.md deleted file mode 100644 index 0046674..0000000 --- a/ARCANE-VM-SETUP.md +++ /dev/null @@ -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= - - JWT_SECRET= - 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 diff --git a/LAUFWERK-AN-VM-HINZUFUEGEN.md b/LAUFWERK-AN-VM-HINZUFUEGEN.md deleted file mode 100644 index 3007689..0000000 --- a/LAUFWERK-AN-VM-HINZUFUEGEN.md +++ /dev/null @@ -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 -``` diff --git a/ROADMAP.md b/ROADMAP.md index 7e2e42f..6fb6789 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/SAVEPOINT.md b/SAVEPOINT.md index bde0b77..388031b 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -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)** diff --git a/arcane-vm-setup-unix.sh b/arcane-vm-setup-unix.sh deleted file mode 100644 index d05012a..0000000 --- a/arcane-vm-setup-unix.sh +++ /dev/null @@ -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" diff --git a/arcane-vm-setup.sh b/arcane-vm-setup.sh deleted file mode 100644 index 6b420c3..0000000 --- a/arcane-vm-setup.sh +++ /dev/null @@ -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" diff --git a/create-docker-compose.sh b/create-docker-compose.sh deleted file mode 100644 index 00c3e8f..0000000 --- a/create-docker-compose.sh +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ea0681b..8134ddf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 26c3144..98045c9 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -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 diff --git a/docker/api/celery_client.py b/docker/api/celery_client.py new file mode 100644 index 0000000..7bad835 --- /dev/null +++ b/docker/api/celery_client.py @@ -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] + ) diff --git a/docker/api/db.py b/docker/api/db.py new file mode 100644 index 0000000..04406e3 --- /dev/null +++ b/docker/api/db.py @@ -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)) diff --git a/docker/api/detection.py b/docker/api/detection.py new file mode 100644 index 0000000..287d37e --- /dev/null +++ b/docker/api/detection.py @@ -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" diff --git a/docker/api/devices.py b/docker/api/devices.py new file mode 100644 index 0000000..553aa0b --- /dev/null +++ b/docker/api/devices.py @@ -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//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"), + } diff --git a/docker/api/main.py b/docker/api/main.py index 21322d0..d181ac3 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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,96 +178,95 @@ 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 = [] - - 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 + """Alle optischen Laufwerke mit ehrlichem Status (leer/bereit + Disc-Typ). + + 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") diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py new file mode 100644 index 0000000..0eba54f --- /dev/null +++ b/docker/api/test_api_smoke.py @@ -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 diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index 21c1e29..c143c6b 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -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"] diff --git a/docker/ui/nginx.conf b/docker/ui/nginx.conf new file mode 100644 index 0000000..6a593df --- /dev/null +++ b/docker/ui/nginx.conf @@ -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; + } +} diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index b7c8a96..195f7ca 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -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([]) 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) diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx index da72d4d..5094679 100644 --- a/docker/ui/src/components/LiveLogSection.tsx +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -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(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) } }, []) diff --git a/docker/ui/src/lib/api.ts b/docker/ui/src/lib/api.ts new file mode 100644 index 0000000..80cc30a --- /dev/null +++ b/docker/ui/src/lib/api.ts @@ -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' }) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 7e9ed10..6cffd5f 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -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 { 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 { async function fetchDevices(): Promise { try { - const response = await axios.get(`${API_URL}/devices`) + const response = await api.get('/devices') return response.data } catch { return [] diff --git a/docker/ui/src/pages/Logs.tsx b/docker/ui/src/pages/Logs.tsx index 6f6c06a..6f3def2 100644 --- a/docker/ui/src/pages/Logs.tsx +++ b/docker/ui/src/pages/Logs.tsx @@ -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 { diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx index e795f21..e5c5283 100644 --- a/docker/ui/src/pages/MetadataPreview.tsx +++ b/docker/ui/src/pages/MetadataPreview.tsx @@ -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('') + const [devices, setDevices] = useState([]) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(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'}`} > - - - - + + {devices.map((d) => ( + + ))}