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:
+136
-85
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user