Files
rippy/docker/api/main.py
T
Hitonabi 5778ac4645
Ampel / ampel (push) Successful in 29s
Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Zwei bewiesene Bug-Fixes:
- SMB-Mount 'Unable to apply new capability set': mount.cifs hebt
  CAP_DAC_READ_SEARCH an, die in Dockers Default-Caps fehlt (auf der VM
  reproduziert: Bounding-Set a82425fb ohne Bit 2; mit der Capability
  verschwindet der Fehler). Fix: cap_add DAC_READ_SEARCH fuer den
  api-Container.
- TMDB fiel still aus: Client konnte nur v4-Bearer-Tokens, der uebliche
  32-Hex-v3-Key bekam 401 und die Suche lieferte nur OMDb. Jetzt beide
  Key-Arten (ist_v4_token + api_key-Query-Param lt.
  developer.themoviedb.org, mit Tests) — damit kommen auch die deutschen
  Texte an (language=de-DE war ueberall schon gesetzt). Neu:
  GET /metadata/status + 'Verbindung pruefen' in Einstellungen -> APIs
  (Live-Check am Cache vorbei).

Features aus dem Commander-Feedback:
- 4K UHD als eigener Disc-Typ: classify >= 55 GiB (BD-66/BD-100; BD-50
  bleibt bluray), beide detection.py + Tests, eigene Badge-Farbe in
  Dashboard/Laufwerken, Prescan-Label '4K UHD'. UHD-Rip-Fehler 'Failed to
  open disc' (Code 11) bekommt Klartext: LibreDrive-Firmware noetig.
- Vollautomatik (Setting autoRipStart): Disc erkannt -> Rip startet ohne
  Popup in den Schnellwahl-Ordner (Serie->Serien, sonst Filme, CD->Musik).
- Job-Verwaltung: 'Neu komprimieren' nur noch mit can_retry (Rohdaten
  liegen wirklich da), DELETE /jobs/{id} + 'Erledigte aufraeumen'
  (Dateien bleiben immer), 'Alle herunterladen' im Job-Detail
  (gestaffelte Einzel-Downloads statt Server-Zip von 40-GB-Dateien).
- Worker zuordenbar: WORKER_NAME-Env als stabiler Anzeigename (fixt auch
  die Offline-Leichen nach Rebuilds), info.hostname/ip gemeldet,
  Online-Abgleich ueber hostname, DELETE /workers/{name} + Papierkorb im
  UI, Quelle-Anzeige an der Disc-Karte ('Quelle: TMDB - 99 % sicher').
- Ripping-Tab nach Medium gegliedert (Video/Audio-CD/Allgemein) +
  Untertitel-Klartext (--all-audio/--all-subtitles bleiben komplett),
  CD -> Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung beschriftet
  und standardmaessig eingeklappt.
- Docs: SAVEPOINT v3.3, ROADMAP Etappe 14 + Ideen (nativer
  Windows-Worker, deutsche OMDb-Texte via TMDB-Find), README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:29:10 +02:00

1236 lines
44 KiB
Python

from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, 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
import notify
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}",
)
await _auto_rip_wenn_aktiviert(pfad)
except Exception as e:
DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}")
async def _auto_rip_wenn_aktiviert(pfad: str):
"""Vollautomatik (Setting autoRipStart): Disc erkannt → Rip startet sofort.
Commander-Wunsch 24.07.: wahlweise Popup ODER Automatik. Ziel-Ordner
kommt aus den Schnellwahl-Einstellungen (Serie → seriesDir, sonst
movieDir; CD → musicDir) — genau wie ein Klick im Dialog.
"""
einstellungen = await asyncio.to_thread(db.get_settings)
if not einstellungen.get("autoRipStart"):
return
if await asyncio.to_thread(db.has_active_job, pfad):
return
disc = DISC_CACHE.get(pfad) or {}
if disc.get("_laeuft"):
return
basis = einstellungen.get("outputDir") or MEDIA_ROOT
meta = disc.get("metadata") or {}
if disc.get("disc_type") == "CD":
unterordner = einstellungen.get("musicDir") or "music"
elif meta.get("type") == "tv":
unterordner = einstellungen.get("seriesDir") or "series"
else:
unterordner = einstellungen.get("movieDir") or "movies"
ziel = os.path.normpath(os.path.join(basis, unterordner))
if not ziel.startswith(MEDIA_ROOT):
ziel = None
job_id = str(uuid.uuid4())
meta_json = json.dumps({
"year": disc.get("year"),
"confidence": disc.get("confidence"),
**meta,
})
await asyncio.to_thread(
db.insert_job, job_id, pfad, None, disc.get("title"), ziel, meta_json
)
await asyncio.to_thread(
db.add_log, "info", "api",
f'Automatik: Rip für „{disc.get("title")}" gestartet ({pfad}{ziel})',
)
start_rip(pfad, job_id, ziel)
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
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
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"
}
def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
"""Nur wenn Rohdaten wirklich noch daliegen — der „Neu komprimieren"-Knopf
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.)."""
if job.get("status") != "failed":
return False
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
return True
return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
@app.get("/jobs", response_model=List[Job])
async def get_jobs():
"""Holt alle Jobs aus der Datenbank (neueste zuerst)."""
def sammle():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
modelle = []
for z in db.list_jobs():
modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modelle.append(modell)
return modelle
return await asyncio.to_thread(sammle)
@app.delete("/jobs/{job_id}")
async def delete_job(job_id: str):
"""Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen)."""
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"] not in ("completed", "failed"):
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
await asyncio.to_thread(db.delete_job, job_id)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt")
return {"status": "deleted"}
@app.delete("/jobs")
async def delete_finished_jobs():
"""Räumt ALLE erledigten Jobs (fertig + fehlgeschlagen) aus der Liste."""
anzahl = await asyncio.to_thread(db.delete_finished_jobs)
await asyncio.to_thread(
db.add_log, "info", "api", f"Job-Liste aufgeräumt ({anzahl} erledigte Einträge entfernt)"
)
return {"deleted": anzahl}
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 + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
# sie für den Ordnernamen und die Media-Server-Aufbereitung (NFO/Poster),
# das UI fürs Job-Detail-Popup.
titel = request.title
meta_json = None
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
if not titel:
titel = disc.get("title")
meta_json = json.dumps({
"year": disc.get("year"),
"confidence": disc.get("confidence"),
**(disc.get("metadata") or {}),
})
job_id = str(uuid.uuid4())
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
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("/jobs/{job_id}/detail")
async def get_job_detail(job_id: str):
"""Alles zu EINEM Job — fürs Klick-Popup auf den Titel in „Neueste Jobs":
Metadaten (Poster/Jahr/Beschreibung), Ziel, Ausgabepfad, Fehler."""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
detail = _job_row_to_model(job).dict()
detail["target_dir"] = job.get("target_dir")
detail["output_path"] = job.get("output_path")
try:
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
except ValueError:
detail["meta"] = None
return detail
def _sicherer_dateiname(name: str) -> bool:
"""Pure Funktion (testbar): nur nackte Dateinamen, keine Pfad-Tricks."""
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
def _job_ausgabeordner(job: dict) -> str:
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
ausgabe = os.path.normpath(job.get("output_path") or "")
if not ausgabe.startswith(MEDIA_ROOT):
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
return ausgabe
@app.get("/jobs/{job_id}/files")
async def list_job_files(job_id: str):
"""Dateien eines fertigen Jobs — fürs Download-Menü im Dashboard.
Vorher kam man an fertige MKVs nur per scp auf die VM.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
ausgabe = _job_ausgabeordner(job)
def liste():
try:
eintraege = sorted(os.listdir(ausgabe))
except OSError:
return None
dateien = []
for name in eintraege:
pfad = os.path.join(ausgabe, name)
if os.path.isfile(pfad):
try:
groesse_mb = round(os.path.getsize(pfad) / 1024**2, 1)
except OSError:
groesse_mb = None
dateien.append({"name": name, "size_mb": groesse_mb})
return dateien
dateien = await asyncio.to_thread(liste)
if dateien is None:
raise HTTPException(
status_code=404,
detail="Ausgabeordner nicht lesbar — Job noch nicht fertig oder Ziel ausgehängt?",
)
return {"job_id": job_id, "output_path": ausgabe, "files": dateien}
@app.get("/jobs/{job_id}/files/{dateiname}")
async def download_job_file(job_id: str, dateiname: str):
"""Streamt EINE Datei eines Jobs zum Browser (Download-Knopf).
Pfad-Validierung strikt: nackter Dateiname, realpath muss unter
/app/media bleiben (kein ..-Ausbruch, kein Symlink nach draußen).
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
ausgabe = _job_ausgabeordner(job)
if not _sicherer_dateiname(dateiname):
raise HTTPException(status_code=422, detail="Ungültiger Dateiname")
pfad = os.path.join(ausgabe, dateiname)
def pruefe():
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT)
if not await asyncio.to_thread(pruefe):
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
return FileResponse(pfad, filename=dateiname, media_type="application/octet-stream")
@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")
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
raw_dir = f"{raw_basis}/{job_id}"
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = job.get("output_path") or 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 — inkl.
Live-Erreichbarkeit (Celery-Ping + Herzschlag-Alter).
Jeder Worker meldet sich selbst (caps.py, minütlich) — auch optionale
Remote-GPU-Worker tauchen hier automatisch auf.
"""
def sammle():
zeilen = db.list_workers()
try:
antworten = celery_client.control.ping(timeout=1.0) or []
online_namen = {
knoten.split("@", 1)[-1]
for antwort in antworten
for knoten in antwort.keys()
}
except Exception:
online_namen = set()
for zeile in zeilen:
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info.
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
zeile["online"] = hostname in online_namen
return zeilen
return {"workers": await asyncio.to_thread(sammle)}
@app.delete("/workers/{name}")
async def delete_worker(name: str):
"""Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds).
Ein AKTIVER Worker meldet sich binnen einer Minute einfach wieder an —
löschen ist also immer gefahrlos."""
await asyncio.to_thread(db.delete_worker, name)
await asyncio.to_thread(db.add_log, "info", "api", f"Worker-Eintrag '{name}' entfernt")
return {"status": "deleted"}
@app.get("/metadata/status")
async def metadata_status():
"""Live-Prüfung der Metadaten-Quellen — beantwortet „funktioniert mein
Key?" sofort statt durch stilles Wegfallen einer Quelle."""
def pruefe():
from clients.omdb import OMDB_BASE_URL
from clients.tmdb import TMDB_BASE_URL
status = {}
prescan = PreScan()
# Bewusst am Cache VORBEI — ein alter Treffer soll keinen kaputten
# Key als "ok" tarnen. /configuration ist der kleinste Auth-Aufruf.
if not prescan.tmdb.api_key:
status["tmdb"] = "kein_key"
else:
try:
antwort = prescan.tmdb.session.get(
f"{TMDB_BASE_URL}/configuration",
params=prescan.tmdb._key_params, timeout=10,
)
status["tmdb"] = "ok" if antwort.status_code == 200 else "fehler"
except Exception:
status["tmdb"] = "fehler"
if not prescan.omdb.api_key:
status["omdb"] = "kein_key"
else:
try:
antwort = prescan.omdb.session.get(
OMDB_BASE_URL,
params={"apikey": prescan.omdb.api_key, "t": "Inception"},
timeout=10,
).json()
status["omdb"] = "ok" if antwort.get("Response") == "True" else "fehler"
except Exception:
status["omdb"] = "fehler"
status["jikan"] = "ok" # keyless — fällt nur bei Netzproblemen aus
return status
return await asyncio.to_thread(pruefe)
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:
schreibbar = 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" if schreibbar else "warning", "mounts",
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}"
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
)
return {"name": request.name, "mounted": True, "writable": schreibbar}
@app.get("/storage-mounts/shares")
async def list_shares(host: str, username: str = "", password: str = ""):
"""SMB-Freigaben eines Rechners auflisten (PC/NAS per Klick wählen)."""
try:
freigaben = await asyncio.to_thread(
mount_verwaltung.liste_smb_freigaben, host, username, password
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"host": host, "shares": freigaben}
@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
ordner, dateien = [], []
for name in eintraege:
voll = os.path.join(normalisiert, name)
if os.path.isdir(voll):
ordner.append({"name": name, "path": voll})
else:
# Dateien MIT anzeigen (Befund 24.07.: der Browser wirkte
# „leer", weil er nur Ordner listete — die MKVs im
# bluray-Ordner waren unsichtbar).
try:
groesse_mb = round(os.path.getsize(voll) / 1024**2, 1)
except OSError:
groesse_mb = None
dateien.append({"name": name, "size_mb": groesse_mb})
return ordner, dateien
ergebnis = await asyncio.to_thread(liste)
if ergebnis is None:
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
ordner, dateien = ergebnis
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien}
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("/system/info")
async def system_info():
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status."""
def sammle():
info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()}
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
try:
nutzung = shutil.disk_usage(pfad)
info["plaetze"].append({
"name": name,
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
except OSError:
pass
einstellungen = db.get_settings()
info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip())
info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip())
return info
return await asyncio.to_thread(sammle)
class NotificationTestRequest(BaseModel):
url: str
@app.post("/notifications/test")
async def notification_test(request: NotificationTestRequest):
"""Test-Nachricht an den Webhook — beweist die Anbindung SOFORT statt
erst beim ersten Job-Ende."""
url = request.url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=422, detail="Webhook-URL muss mit http(s):// beginnen")
try:
await asyncio.to_thread(
notify.sende, url,
"🔔 Rippy: Test-Benachrichtigung",
"Wenn du das liest, funktioniert die Anbindung. Rippy meldet sich "
"hier, sobald ein Job fertig ist oder fehlschlägt.",
"info",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.add_log, "info", "notify", f"Test-Benachrichtigung gesendet ({notify.erkenne_webhook_typ(url)})"
)
return {"status": "sent", "typ": notify.erkenne_webhook_typ(url)}
@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}
@app.get("/metadata/search")
async def metadata_search(q: str):
"""Manuelle Korrektur: Titel-Kandidaten aus ALLEN Quellen (TMDB/Jikan/OMDb).
KONZEPT Schritt 5: „Commander bestätigt oder korrigiert manuell" — das
hier ist der Korrektur-Teil, wenn die Automatik danebenliegt.
"""
def sammle():
prescan = PreScan()
ergebnisse = []
for movie in (prescan.tmdb.search_movie(q) or [])[:4]:
ergebnisse.append({
"title": movie.get("title", ""),
"year": int(movie["release_date"][:4]) if movie.get("release_date") else None,
"poster": f"https://image.tmdb.org/t/p/w342{movie['poster_path']}" if movie.get("poster_path") else "",
"overview": (movie.get("overview") or "")[:200],
"type": "movie", "source": "tmdb", "id": str(movie.get("id", "")),
})
for show in (prescan.tmdb.search_tv(q) or [])[:3]:
ergebnisse.append({
"title": show.get("name", ""),
"year": int(show["first_air_date"][:4]) if show.get("first_air_date") else None,
"poster": f"https://image.tmdb.org/t/p/w342{show['poster_path']}" if show.get("poster_path") else "",
"overview": (show.get("overview") or "")[:200],
"type": "tv", "source": "tmdb", "id": str(show.get("id", "")),
})
ergebnisse += prescan.jikan.suche(q)
ergebnisse += prescan.omdb.suche(q)
# Mit Poster zuerst, Duplikate (Titel+Jahr) raus
gesehen, dedup = set(), []
for e in sorted(ergebnisse, key=lambda x: 0 if x.get("poster") else 1):
schluessel = ((e.get("title") or "").lower(), e.get("year"))
if schluessel in gesehen:
continue
gesehen.add(schluessel)
dedup.append(e)
return dedup[:12]
return await asyncio.to_thread(sammle)
class MetadataOverride(BaseModel):
device_path: str
title: str
year: Optional[int] = None
poster: Optional[str] = None
overview: Optional[str] = None
type: Optional[str] = "movie"
source: Optional[str] = None
id: Optional[str] = None
@app.post("/metadata/override")
async def metadata_override(request: MetadataOverride):
"""Nutzer-Wahl für DIESE Disc merken: Karte, Jobs und Cache (30 Tage).
Der Disc-Fingerabdruck (Label+Größe) macht die Korrektur wiedererkennbar —
dieselbe Disc wird beim nächsten Einlegen sofort richtig angezeigt.
"""
if request.device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail="Laufwerk nicht gefunden")
def speichere():
from cache import set as cache_setter
from cache.keys import generate_prescan_key
from prescan.prescan import disc_fingerprint
ergebnis = {
"disc_type": (DISC_CACHE.get(request.device_path) or {}).get("disc_type", "Blu-ray"),
"title": request.title,
"year": request.year,
"confidence": 0.99,
"metadata": {
"type": request.type or "movie",
"id": request.id or "",
"title": request.title,
"year": request.year,
"overview": request.overview or "",
"poster_path": request.poster or "",
"backdrop_path": "",
"runtime": 0,
"genres": [],
"source": request.source or "manuell",
},
"tracks": [],
}
abdruck = disc_fingerprint(request.device_path)
cache_setter(
generate_prescan_key(request.device_path, False, abdruck),
ergebnis, expire=30 * 86400,
)
DISC_CACHE[request.device_path] = ergebnis
return ergebnis
ergebnis = await asyncio.to_thread(speichere)
await asyncio.to_thread(
db.add_log, "success", "api",
f"Metadaten manuell festgelegt: {request.title}"
+ (f" ({request.year})" if request.year else ""),
)
return ergebnis
# 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")