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
+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"),
}
+133 -82
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,95 +178,94 @@ 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 = []
try: Der alte Weg (udevadm + /dev/disc-Symlinks) lieferte im Container
# 1. Zuerst Symlinks in /dev/disc/ prüfen prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
result = subprocess.run( der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
["ls", "-la", "/dev/disc/"], """
capture_output=True, geraete = []
text=True, for pfad in device_discovery.list_optical_devices():
timeout=5 info = await asyncio.to_thread(device_discovery.device_info, pfad)
) 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