Files
rippy/docker/api/main.py
T
Hitonabi 07be54e62a
Ampel / ampel (push) Failing after 38s
Jikan/MAL-Quelle + Job-Titel + Abbrechen-Knopf + Dark-Hover + Ordner-Verwaltung
Commander-Befunde (Screenshots 23.07. abends):
- Jikan (MyAnimeList, kostenlos OHNE Key) als Anime-Quelle vor OMDb —
  waehlt per Titel-Aehnlichkeit (SequenceMatcher, Schwelle 0.55) statt
  blind Treffer 1; damit trifft "Evangelion 2.22" den exakten Film
- Job-Titel: POST /jobs uebernimmt den erkannten Disc-Titel aus dem
  DISC_CACHE — Schluss mit "Unbekannt" in der Queue
- Kooperativer Job-Abbruch: POST /jobs/{id}/cancel setzt canceling,
  der Worker prueft das Flag bei jedem Fortschritts-Update und killt
  den Encoder-Prozess sauber (RipAbbruch); UI-Knopf "Abbrechen" fuer
  laufende, Status-Badge "Wird abgebrochen"
- Dark-Mode: Job-Zeilen-Hover war hart hell (hover:bg-slate-50)
- Speicherziele: lokaler Ordner-Browser mit "Ordner anlegen"
  (GET /browse + POST /browse/mkdir, nur unter /app/media)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:22:57 +02:00

792 lines
27 KiB
Python

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 os
import shutil
import uuid
import db
import devices as device_discovery
import mounts as mount_verwaltung
from celery_client import celery_client, 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}")
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
def remount():
for meldung in mount_verwaltung.alle_remounten():
db.add_log("info", "mounts", meldung)
await asyncio.to_thread(remount)
asyncio.create_task(disc_watcher())
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
# WAS im Laufwerk liegt (Titel/Jahr/Poster), ohne dass jemand klicken muss.
DISC_CACHE: Dict[str, Dict] = {}
async def _auto_prescan(pfad: str):
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
return
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
try:
prescan = PreScan()
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
DISC_CACHE[pfad] = ergebnis.to_dict()
db.add_log(
"info", "watcher",
f"Disc erkannt: {ergebnis.title}"
+ (f" ({ergebnis.year})" if ergebnis.year else "")
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
)
except Exception as e:
DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}")
async def disc_watcher():
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
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 None:
# Erststart: liegt schon eine Disc drin, direkt erkennen
if status == CDS_DISC_OK:
asyncio.create_task(_auto_prescan(pfad))
elif status != vorher:
if status == CDS_DISC_OK:
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
asyncio.create_task(_auto_prescan(pfad))
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
DISC_CACHE.pop(pfad, None)
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
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
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
target_dir: Optional[str] = None # Ablageziel unter /app/media (frei wählbar)
MEDIA_ROOT = "/app/media"
def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
"""Ziel muss unter /app/media liegen — Pfad-Ausbrüche (..) fliegen raus."""
if not target_dir:
return None
normalisiert = os.path.normpath(target_dir)
if not normalisiert.startswith(MEDIA_ROOT):
raise HTTPException(
status_code=422,
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
)
return normalisiert
@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")
ziel = _validiere_ziel(request.target_dir)
# Titel aus der Disc-Erkennung übernehmen — sonst steht im Job "Unbekannt"
titel = request.title
if not titel:
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
titel = disc.get("title")
job_id = str(uuid.uuid4())
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel)
await asyncio.to_thread(
db.add_log, "info", "api",
f"Job {job_id} angelegt für {device_path}" + (f"{ziel}" if ziel else ""),
)
start_rip(device_path, job_id, ziel)
return {"id": job_id, "status": "pending", "device": device_path, "target_dir": ziel}
@app.get("/storage-targets")
async def storage_targets():
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
NFS/SMB-Shares, die auf der VM unter /srv/rippy/media eingehängt werden,
tauchen hier automatisch auf (rslave-Bind in docker-compose).
"""
def sammle():
ziele = []
try:
eintraege = sorted(os.listdir(MEDIA_ROOT))
except OSError:
return ziele
for name in eintraege:
pfad = os.path.join(MEDIA_ROOT, name)
if not os.path.isdir(pfad):
continue
try:
nutzung = shutil.disk_usage(pfad)
frei_gb = round(nutzung.free / 1024**3, 1)
except OSError:
frei_gb = None
ziele.append({
"name": name,
"path": pfad,
"is_mount": os.path.ismount(pfad),
"free_gb": frei_gb,
})
return ziele
return await asyncio.to_thread(sammle)
@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.post("/jobs/{job_id}/retry-transcode")
async def retry_transcode(job_id: str):
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
Voraussetzung: die Rohdateien liegen noch in /app/temp/raw/<job_id>
(bei Kompressions-Fehlschlägen bleiben sie dort absichtlich erhalten).
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] in ("running", "pending"):
raise HTTPException(status_code=409, detail="Job rippt noch")
raw_dir = f"/app/temp/raw/{job_id}"
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = f"{basis}/{job_id}"
celery_client.send_task(
"worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir],
queue="transcode",
)
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
return {"id": job_id, "status": "transcoding"}
@app.post("/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
Der Worker prüft das Flag bei jedem Fortschritts-Update und beendet den
Encoder-Prozess sauber — kein Celery-Task-ID-Tracking nötig.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] in ("completed", "failed"):
raise HTTPException(status_code=409, detail="Job ist bereits beendet")
await asyncio.to_thread(db.update_job, job_id, status="canceling")
await asyncio.to_thread(db.add_log, "warning", "api", f"Job {job_id}: Abbruch angefordert")
return {"id": job_id, "status": "canceling"}
@app.get("/capabilities")
async def capabilities():
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
Jeder Worker meldet sich beim Start selbst (caps.py) — auch optionale
Remote-GPU-Worker tauchen hier automatisch auf.
"""
return {"workers": await asyncio.to_thread(db.list_workers)}
class MountRequest(BaseModel):
name: str
type: str # nfs | cifs
source: str # host:/export bzw. //host/share
options: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
@app.get("/storage-mounts")
async def get_storage_mounts():
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
eintraege = await asyncio.to_thread(db.list_mounts)
return [
{
"name": e["name"],
"type": e["typ"],
"source": e["quelle"],
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
"has_credentials": bool(e.get("username")),
}
for e in eintraege
]
@app.post("/storage-mounts", status_code=201)
async def create_storage_mount(request: MountRequest):
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start."""
if not mount_verwaltung.validiere_name(request.name):
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
if request.type not in ("nfs", "cifs"):
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
raise HTTPException(status_code=409, detail="Name bereits vergeben")
try:
await asyncio.to_thread(
mount_verwaltung.mounten,
request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.save_mount,
request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "",
)
await asyncio.to_thread(
db.add_log, "success", "mounts",
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}",
)
return {"name": request.name, "mounted": True}
@app.delete("/storage-mounts/{name}")
async def delete_storage_mount(name: str):
"""Hängt ein Netzwerk-Speicherziel aus und entfernt es aus der Konfiguration."""
try:
await asyncio.to_thread(mount_verwaltung.aushaengen, name)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(db.delete_mount, name)
await asyncio.to_thread(db.add_log, "info", "mounts", f"Speicherziel '{name}' entfernt")
return {"status": "removed"}
@app.get("/browse")
async def browse(path: str = MEDIA_ROOT):
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
normalisiert = os.path.normpath(path)
if not normalisiert.startswith(MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
def liste():
try:
eintraege = sorted(os.listdir(normalisiert))
except OSError:
return None
return [
{"name": name, "path": os.path.join(normalisiert, name)}
for name in eintraege
if os.path.isdir(os.path.join(normalisiert, name))
]
ordner = await asyncio.to_thread(liste)
if ordner is None:
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
return {"path": normalisiert, "parent": eltern, "dirs": ordner}
class MkdirRequest(BaseModel):
path: str
name: str
@app.post("/browse/mkdir", status_code=201)
async def browse_mkdir(request: MkdirRequest):
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
basis = os.path.normpath(request.path)
if not basis.startswith(MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
name = request.name.strip()
if not name or "/" in name or "\\" in name or name.startswith("."):
raise HTTPException(status_code=422, detail="Ungültiger Ordnername")
ziel = os.path.join(basis, name)
try:
await asyncio.to_thread(os.makedirs, ziel, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=400, detail=f"Anlegen fehlgeschlagen: {e}")
return {"path": ziel}
@app.get("/setup")
async def setup_status():
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
einstellungen = await asyncio.to_thread(db.get_settings, "setup")
return {"done": bool(einstellungen.get("done"))}
@app.post("/setup/complete")
async def setup_complete():
await asyncio.to_thread(db.save_settings, {"done": True}, "setup")
await asyncio.to_thread(db.add_log, "success", "setup", "Einrichtungs-Assistent abgeschlossen")
return {"done": True}
@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)
disc = DISC_CACHE.get(pfad)
if disc and not disc.get("_laeuft"):
info["disc"] = disc
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")