from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import List, Optional, Dict from pathlib import Path 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 from config import settings from config_validation import validate_config, ConfigValidationError from cache import init_cache, set as cache_set from auth import ( create_access_token, create_refresh_token, decode_token, is_blacklisted, add_to_blacklist, ) from ratelimit import ( check_rate_limit, get_rate_limit_remaining, validate_api_key, api_keys, create_api_key as ratelimit_create_api_key, delete_api_key as ratelimit_delete_api_key, ) from prescan import PreScan from nfo_generator import NFOGenerator from image_downloader import ImageDownloader app = FastAPI( title="Rippy API", description="API für das automatische Ripping-System", version="1.0.0" ) # OAuth2 Scheme oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @app.on_event("startup") async def startup_event(): """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") async def rate_limit_middleware(request: Request, call_next): """Rate-Limiting Middleware.""" client_ip = request.client.host api_key = request.headers.get("X-API-Key") # Prüfe API Key if api_key: key_info = validate_api_key(api_key) if not key_info: raise HTTPException(status_code=401, detail="Ungültiger API Key") # Rate Limit prüfen if not check_rate_limit(client_ip): return Response( content=json.dumps({"error": "Rate limit exceeded"}), status_code=429, media_type="application/json" ) response = await call_next(request) # Füge Rate-Limit Header hinzu remaining = get_rate_limit_remaining(client_ip) response.headers["X-RateLimit-Remaining"] = str(remaining) return response # CORS hinzufügen app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class Job(BaseModel): id: str type: str status: str device: str startTime: str endTime: Optional[str] = None progress: int = 0 title: Optional[str] = None error: Optional[str] = None class Device(BaseModel): id: str name: str 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(): return {"status": "ok", "service": "api"} @app.get("/") async def root(): return { "name": "Rippy", "version": "1.0.0", "description": "Automatisches Ripping-System für CD, DVD und Blu-ray" } @app.get("/jobs", response_model=List[Job]) async def get_jobs(): """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.post("/devices/{name}/eject") async def eject_device(name: str): """Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft.""" device_path = f"/dev/{name}" if device_path not in device_discovery.list_optical_devices(): raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") if await asyncio.to_thread(db.has_active_job, device_path): raise HTTPException( status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job" ) try: await asyncio.to_thread(device_discovery.eject, device_path) except OSError as e: raise HTTPException(status_code=500, detail=f"Auswurf fehlgeschlagen: {e}") await asyncio.to_thread(db.add_log, "info", "api", f"Disc ausgeworfen: {device_path}") return {"status": "ejected", "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(): """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. 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: 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") # Metadaten-Lookup Endpoints class MetadataLookupRequest(BaseModel): title: str year: Optional[int] = None disc_type: str = "dvd" @app.post("/metadata/lookup") async def lookup_metadata(request: MetadataLookupRequest): """Suche Metadaten für Disc.""" prescan = PreScan() # Dummy device für Pre-Scan device = "/dev/dvd" if request.disc_type in ["dvd", "bluray"] else "/dev/cdrom" result = prescan.scan(device) return { "title": result.title, "year": result.year, "confidence": result.confidence, "metadata": result.metadata, "tracks": result.tracks } @app.post("/metadata/confirm") async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None): """Bestätige Metadaten.""" from cache.keys import generate_confirmed_key # In Cache speichern cache_key = generate_confirmed_key(title, year) cache_set(cache_key, {"title": title, "year": year, "metadata": metadata or {}}) return {"status": "confirmed", "key": cache_key} # Pre-Scan Endpoint class PreScanRequest(BaseModel): device_path: str @app.post("/prescan") async def run_prescan(request: PreScanRequest): """Führe Pre-Scan durch.""" try: prescan = PreScan() result = prescan.scan(request.device_path) return result.to_dict() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # Jellyfin-Formatierung Endpoints class JellyfinFormatRequest(BaseModel): title: str year: Optional[int] metadata: Dict disc_type: str output_dir: str @app.post("/jellyfin/format") async def jellyfin_format(request: JellyfinFormatRequest): """Formatiere für Jellyfin (NFO + Images).""" try: nfo_gen = NFOGenerator() img_downloader = ImageDownloader() # Ordnerstruktur erstellen output_path = Path(request.output_dir) if request.disc_type in ["dvd", "bluray"]: # Film-Formatierung title = request.metadata.get("title", request.title) year = request.year or request.metadata.get("year") # movie.nfo movie_nfo = nfo_gen.generate_movie_nfo( title=title, year=year or 2000, overview=request.metadata.get("overview", ""), rating=request.metadata.get("rating", 0), runtime=request.metadata.get("runtime", 0), genres=request.metadata.get("genres", []), director=request.metadata.get("director", ""), actors=request.metadata.get("actors", []) ) nfo_path = output_path / "movie.nfo" nfo_gen.save_nfo(movie_nfo, nfo_path) # Poster und Fanart img_downloader.download_poster(title, output_path, 500) img_downloader.download_fanart(title, output_path, 1920) return { "status": "formatted", "nfo_path": str(nfo_path), "poster_path": str(output_path / "poster.jpg"), "fanart_path": str(output_path / "fanart.jpg") } else: # Audio-Formatierung artist = request.metadata.get("artist", "Unknown Artist") album = request.metadata.get("title", request.title) # Review-Fix 22.07.: `year` war hier undefiniert (existierte nur im Film-Zweig) year = request.year or request.metadata.get("year") # album.nfo album_nfo = nfo_gen.generate_album_nfo( title=album, artist=artist, year=year or 2000, genres=request.metadata.get("genres", []) ) nfo_path = output_path / "album.nfo" nfo_gen.save_nfo(album_nfo, nfo_path) # Album-Cover img_downloader.download_music_images(artist, album, output_path) return { "status": "formatted", "nfo_path": str(nfo_path), "album_cover_path": str(output_path / "album.jpg") } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # Auth Endpoints class LoginRequest(BaseModel): username: str password: str @app.post("/token") async def login(request: LoginRequest): """Login und Token generieren.""" # Einfache Auth für MVP (in Produktion mit Datenbank); Zugangsdaten aus .env if request.username == settings.admin_username and request.password == settings.admin_password: access_token = create_access_token( data={"sub": request.username, "scopes": ["admin"]} ) refresh_token = create_refresh_token( data={"sub": request.username} ) return { "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer" } raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") @app.post("/token/refresh") async def refresh_token(refresh_token: str): """Refresh Access Token.""" payload = decode_token(refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="Ungültiges Refresh Token") access_token = create_access_token( data={"sub": payload.get("sub"), "scopes": payload.get("scopes", [])} ) return {"access_token": access_token, "token_type": "bearer"} @app.post("/token/invalidate") async def invalidate_token(token: str): """Invalidate Token (Logout).""" if is_blacklisted(token): raise HTTPException(status_code=400, detail="Token bereits invalidiert") # Review-Fix 22.07.: vorher wurde hier NICHTS geblacklistet (Placebo-Logout) add_to_blacklist(token) if not is_blacklisted(token): raise HTTPException(status_code=400, detail="Ungültiger Token") return {"status": "invalidated"} # API Key Endpoints class APIKeyCreateRequest(BaseModel): name: str # Review-Fix 22.07.: diese Endpoints nutzten `secrets` und `api_keys`, die in # diesem Modul NIE existierten (Crash bei jedem Aufruf) — der echte Key-Store # lebt in ratelimit.py und wird jetzt benutzt. @app.post("/api-keys") async def create_api_key(request: APIKeyCreateRequest): """Erstelle API Key.""" # In Produktion mit Auth prüfen return ratelimit_create_api_key(request.name) @app.get("/api-keys") async def list_api_keys(): """Liste API Keys.""" return list(api_keys.values()) @app.delete("/api-keys/{key}") async def delete_api_key(key: str): """Lösche API Key.""" # In Produktion mit Auth prüfen if ratelimit_delete_api_key(key): return {"status": "deleted"} raise HTTPException(status_code=404, detail="API Key nicht gefunden")