API: Die Job-Kette existiert jetzt — POST /jobs, Watcher, Postgres, /logs

Vorher gab es KEINEN Code-Pfad, der je einen Rip ausgelöst hat: kein
POST /jobs, kein udev-Daemon (udev_daemon.py existierte nirgends), GET /jobs
gab hart [] zurück, Postgres lag komplett brach, der SSE-Stream konnte
strukturell nie senden (sse_connections wurde nie befüllt), udevadm lieferte
ohne udevd nichts.

- POST /jobs: legt Job-Zeile an, schickt worker.tasks.rip_disc via Celery
- GET /jobs aus Postgres (running→processing fürs UI)
- Disc-Watcher: 3s-ioctl-Poll statt udev, protokolliert Einwurf/Auswurf
- /devices über /sys (vendor/model) + ioctl-Status — ehrlich statt leer
- /logs + /settings (Settings-Seite sprach vorher gegen 404)
- SSE-Fix, udev aus dem API-Image entfernt, Import-Smoke-Test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 15:02:13 +02:00
parent 780d114fe4
commit 28a12b2e06
7 changed files with 485 additions and 87 deletions
+136 -85
View File
@@ -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")