Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s

AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/
python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie
einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt-
Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft
jetzt ganz ohne .env-Pflichtwerte.

Serien-Flow (Etappe-12-Kern, ARM-Wunde #395):
- Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN
  (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im
  Serien-Ordner, bei Staffel 2 nicht ueberschrieben.
- Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan
  ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten
  (GET /metadata/tv/{id}/season/{n}; tv-season-details-API).
  Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei
  uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei
  eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests.

Weitere Punkte:
- Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip
  (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) —
  URL/Key + Test-Knopf in Einstellungen -> Ripping.
- Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses
  + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt',
  Vollautomatik ueberspringt Duplikate.
- 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten
  (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses
  Setting; pro Rip im Dialog uebersteuerbar. Mit Tests.
- OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id,
  de-DE; find-by-id-API).
- Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export
  (GET /jobs/export, Semikolon+BOM fuer deutsches Excel).
- Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl.
  Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und
  /metadata/confirm (schrieb nie gelesenen Cache-Key).
- Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen.
- Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE
  veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports
  jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 14:22:19 +02:00
parent e7a7d5fdb4
commit 8eb5653848
28 changed files with 934 additions and 892 deletions
+157 -151
View File
@@ -17,39 +17,23 @@ 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 cache import init_cache
from ratelimit import check_rate_limit, get_rate_limit_remaining
from prescan import PreScan
from nfo_generator import NFOGenerator
from image_downloader import ImageDownloader
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
# nichts (kein Login-Flow im UI) und waren damit Placebo-Oberfläche.
# Dokumentiert in KONZEPT.md Abschnitt 10.
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():
@@ -76,6 +60,26 @@ async def startup_event():
DISC_CACHE: Dict[str, Dict] = {}
def _duplikat_suchen(fingerprint: str):
"""Wurde eine Disc mit diesem Fingerabdruck schon erfolgreich gerippt?"""
if not fingerprint:
return None
for job in db.list_jobs(200):
if job.get("status") != "completed":
continue
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
continue
if meta.get("fingerprint") == fingerprint:
return {
"job_id": job["id"],
"title": job.get("title"),
"finished_at": job["finished_at"].isoformat() if job.get("finished_at") else None,
}
return None
async def _auto_prescan(pfad: str):
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
@@ -85,12 +89,23 @@ async def _auto_prescan(pfad: str):
prescan = PreScan()
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
DISC_CACHE[pfad] = ergebnis.to_dict()
jahr_text = f" ({ergebnis.year})" if (
ergebnis.year and not ergebnis.title.endswith(f"({ergebnis.year})")
) else ""
db.add_log(
"info", "watcher",
f"Disc erkannt: {ergebnis.title}"
+ (f" ({ergebnis.year})" if ergebnis.year else "")
f"Disc erkannt: {ergebnis.title}{jahr_text}"
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
)
# Duplikat-Warnung: dieselbe Disc (Fingerabdruck) schon fertig gerippt?
dup = await asyncio.to_thread(_duplikat_suchen, ergebnis.fingerprint)
if dup:
DISC_CACHE[pfad]["bereits_gerippt"] = dup
db.add_log(
"warning", "watcher",
f"Diese Disc wurde bereits gerippt ({dup.get('title')}, "
f"Job {dup['job_id'][:8]}…) — Dashboard zeigt den Hinweis.",
)
await _auto_rip_wenn_aktiviert(pfad)
except Exception as e:
DISC_CACHE.pop(pfad, None)
@@ -112,6 +127,13 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
disc = DISC_CACHE.get(pfad) or {}
if disc.get("_laeuft"):
return
if disc.get("bereits_gerippt"):
await asyncio.to_thread(
db.add_log, "info", "api",
"Automatik übersprungen: Disc wurde bereits gerippt — "
'manuell per „Rippen starten" trotzdem möglich.',
)
return
basis = einstellungen.get("outputDir") or MEDIA_ROOT
meta = disc.get("metadata") or {}
@@ -175,19 +197,13 @@ async def disc_watcher():
await asyncio.sleep(3)
# Middleware für Rate-Limiting
# Middleware für Rate-Limiting (pro Client-IP — schützt vor Amok-Skripten,
# nicht vor Angreifern; Rippy ist Heimnetz-only)
@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(
@@ -318,6 +334,9 @@ class JobCreateRequest(BaseModel):
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)
series: Optional[str] = None # Serien-Flow: Ablage <Serie>/Season NN
season: Optional[int] = None
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
MEDIA_ROOT = "/app/media"
@@ -352,18 +371,26 @@ async def create_job(request: JobCreateRequest):
# 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.
# das UI fürs Job-Detail-Popup. Serien-Flow und Hauptfilm-Wahl wandern
# ebenfalls in die Job-Metadaten.
titel = request.title
meta_json = None
meta_dict = {}
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
if not titel:
titel = disc.get("title")
meta_json = json.dumps({
meta_dict = {
"year": disc.get("year"),
"confidence": disc.get("confidence"),
"fingerprint": disc.get("fingerprint"),
**(disc.get("metadata") or {}),
})
}
if request.series and request.series.strip():
meta_dict["series"] = request.series.strip()
meta_dict["season"] = max(1, int(request.season or 1))
if request.main_feature_only is not None:
meta_dict["main_feature_only"] = request.main_feature_only
meta_json = json.dumps(meta_dict) if meta_dict else None
job_id = str(uuid.uuid4())
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
@@ -607,6 +634,93 @@ async def delete_worker(name: str):
return {"status": "deleted"}
@app.get("/metadata/tv/{tv_id}/season/{season}")
async def tv_season_laufzeiten(tv_id: int, season: int):
"""Episoden-Laufzeiten einer Staffel (TMDB) — Basis fürs
Episoden-Matching des Workers (Serien-Flow)."""
def hole():
prescan = PreScan()
daten = prescan.tmdb.get_tv_season(tv_id, season)
if not daten:
return None
return {
"episodes": [
{"episode": e.get("episode_number"), "runtime": e.get("runtime")}
for e in daten.get("episodes", [])
]
}
ergebnis = await asyncio.to_thread(hole)
if ergebnis is None:
raise HTTPException(status_code=404, detail="Staffel bei TMDB nicht gefunden")
return ergebnis
class MediaServerRefreshRequest(BaseModel):
url: str
api_key: str
@app.post("/mediaserver/refresh")
async def mediaserver_refresh(request: MediaServerRefreshRequest):
"""Bibliotheks-Scan von Jellyfin/Emby anstoßen — auch als Verbindungs-Test
aus den Einstellungen (POST /Library/Refresh, Header X-Emby-Token)."""
import requests as _requests
url = request.url.strip().rstrip("/")
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=422, detail="Server-URL muss mit http(s):// beginnen")
def anstossen():
return _requests.post(
url + "/Library/Refresh",
headers={"X-Emby-Token": request.api_key.strip()}, timeout=15,
)
try:
antwort = await asyncio.to_thread(anstossen)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Server nicht erreichbar: {e}")
if antwort.status_code >= 300:
raise HTTPException(
status_code=400,
detail=f"Server antwortete mit HTTP {antwort.status_code} — API-Key prüfen "
"(Jellyfin: Administration → API-Schlüssel)",
)
await asyncio.to_thread(db.add_log, "info", "api", f"Bibliotheks-Refresh angestoßen ({url})")
return {"status": "refreshed"}
@app.get("/jobs/export")
async def export_jobs():
"""Job-Historie als CSV (Semikolon + BOM — öffnet sauber in deutschem Excel)."""
def baue():
import csv
import io
puffer = io.StringIO()
w = csv.writer(puffer, delimiter=";")
w.writerow(["ID", "Titel", "Typ", "Status", "Fortschritt %", "Gerät",
"Start", "Ende", "Ablage", "Fehler"])
for j in db.list_jobs(1000):
w.writerow([
j["id"], j.get("title") or "", j.get("disc_type") or "",
j.get("status") or "", j.get("progress") or 0, j.get("device") or "",
j["created_at"].isoformat() if j.get("created_at") else "",
j["finished_at"].isoformat() if j.get("finished_at") else "",
j.get("output_path") or "",
(j.get("error") or "").replace("\n", " "),
])
return puffer.getvalue()
inhalt = await asyncio.to_thread(baue)
return Response(
content="" + inhalt,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="rippy-jobs.csv"'},
)
@app.get("/metadata/status")
async def metadata_status():
"""Live-Prüfung der Metadaten-Quellen — beantwortet „funktioniert mein
@@ -918,41 +1032,10 @@ async def job_stream():
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}
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
# Metadaten-Seite): lookup scannte ein DUMMY-Device (/dev/dvd — existiert
# nicht) und confirm schrieb in einen Cache-Key, den nie jemand las.
# Die echte Korrektur läuft über /metadata/search + /metadata/override.
@app.get("/metadata/search")
@@ -1154,82 +1237,5 @@ async def jellyfin_format(request: JellyfinFormatRequest):
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")
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
# Heimnetz-only, kein Login-Flow im UI, die Endpoints waren Placebo.