Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s
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:
+157
-151
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user