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:
@@ -1,105 +0,0 @@
|
||||
"""JWT-Auth-Module für Rippy API."""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from config import settings
|
||||
|
||||
# Passwort-Hashing-Kontext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
|
||||
# Review-Fund 22.07.: der frühere Zufalls-Fallback erzeugte PRO PROZESS einen
|
||||
# neuen Schlüssel → jeder Neustart/zweite Worker invalidierte alle Tokens.
|
||||
# Lieber laut scheitern als still kaputt sein.
|
||||
if not settings.jwt_secret_key:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET_KEY ist nicht gesetzt (.env). Ohne festen Schlüssel wären "
|
||||
"alle Tokens nach jedem Neustart ungültig — Start verweigert."
|
||||
)
|
||||
SECRET_KEY = settings.jwt_secret_key
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
# Token-Lifetimes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verifiziere Passwort."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash Passwort."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
|
||||
"""Erstelle Access Token (Default 15 min)."""
|
||||
to_encode = data.copy()
|
||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.now(timezone.utc) + delta
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict) -> str:
|
||||
"""Erstelle Refresh Token (7 Tage)."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict]:
|
||||
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def is_access_token(token: str) -> bool:
|
||||
"""Prüfe ob Token ein gültiges Access Token ist."""
|
||||
payload = decode_token(token)
|
||||
return bool(payload and payload.get("type") == "access")
|
||||
|
||||
|
||||
def is_refresh_token(token: str) -> bool:
|
||||
"""Prüfe ob Token ein gültiges Refresh Token ist."""
|
||||
payload = decode_token(token)
|
||||
return bool(payload and payload.get("type") == "refresh")
|
||||
|
||||
|
||||
# Token-Blacklist für Logout: Token → Ablauf-Zeitstempel (exp).
|
||||
# Bewusste MVP-Grenze: in-memory = pro Prozess (siehe SAVEPOINT.md).
|
||||
# Review-Fund 22.07.: das frühere cleanup löschte die GESAMTE Blacklist —
|
||||
# Logout war ein Placebo. Jetzt fliegen nur abgelaufene Tokens raus
|
||||
# (die sind eh ungültig, decode_token lehnt sie ab).
|
||||
token_blacklist: Dict[str, float] = {}
|
||||
|
||||
|
||||
def add_to_blacklist(token: str) -> None:
|
||||
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
|
||||
payload = decode_token(token)
|
||||
if payload:
|
||||
token_blacklist[token] = float(payload.get("exp", time.time()))
|
||||
|
||||
|
||||
def is_blacklisted(token: str) -> bool:
|
||||
"""Prüfe ob Token auf der Blacklist steht."""
|
||||
return token in token_blacklist
|
||||
|
||||
|
||||
def cleanup_blacklist() -> None:
|
||||
"""Entferne NUR abgelaufene Tokens von der Blacklist."""
|
||||
now = time.time()
|
||||
for token in [t for t, exp in token_blacklist.items() if exp <= now]:
|
||||
del token_blacklist[token]
|
||||
@@ -95,6 +95,42 @@ class TMDBClient:
|
||||
"""Hole Serien-Details."""
|
||||
params = {"language": "de-DE"}
|
||||
return self._request(f"tv/{tv_id}", params)
|
||||
|
||||
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
|
||||
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
|
||||
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
|
||||
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
|
||||
|
||||
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
|
||||
developer.themoviedb.org/reference/find-by-id).
|
||||
|
||||
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
|
||||
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
|
||||
"""
|
||||
result = self._request(
|
||||
f"find/{imdb_id}",
|
||||
{"external_source": "imdb_id", "language": "de-DE"},
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
for schluessel, typ, titel_feld, datum_feld in (
|
||||
("movie_results", "movie", "title", "release_date"),
|
||||
("tv_results", "tv", "name", "first_air_date"),
|
||||
):
|
||||
eintraege = result.get(schluessel) or []
|
||||
if eintraege:
|
||||
e = eintraege[0]
|
||||
datum = e.get(datum_feld) or ""
|
||||
return {
|
||||
"type": typ,
|
||||
"tmdb_id": e.get("id"),
|
||||
"title": e.get(titel_feld) or "",
|
||||
"overview": e.get("overview") or "",
|
||||
"poster_path": e.get("poster_path") or "",
|
||||
"year": int(datum[:4]) if datum[:4].isdigit() else None,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
||||
"""Hole Poster/Fanart URLs."""
|
||||
|
||||
@@ -24,13 +24,9 @@ class Settings(BaseSettings):
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
||||
|
||||
# JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr)
|
||||
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)")
|
||||
# JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
|
||||
# Heimnetz-only, es gab nie einen Login-Flow im UI — siehe KONZEPT §10.
|
||||
|
||||
# Admin-Login (MVP — vorher hartkodiert admin/rippy123 in main.py)
|
||||
admin_username: str = Field(default="admin", description="Admin-Benutzername")
|
||||
admin_password: str = Field(default="rippy123", description="Admin-Passwort (per .env ÄNDERN!)")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
@@ -48,10 +48,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
|
||||
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
||||
)
|
||||
|
||||
# Optional: JWT Secret (wird generiert, wenn nicht gesetzt)
|
||||
if not settings.jwt_secret_key:
|
||||
warnings.append(
|
||||
"JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet."
|
||||
)
|
||||
|
||||
return settings, warnings
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
|
||||
"""Pytest-Setup für API-Tests: flache Modul-Imports."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
|
||||
|
||||
+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.
|
||||
|
||||
@@ -203,7 +203,8 @@ class PreScanResult:
|
||||
year: int = None,
|
||||
confidence: float = 0.0,
|
||||
metadata: Dict = None,
|
||||
tracks: List[Dict] = None
|
||||
tracks: List[Dict] = None,
|
||||
fingerprint: str = ""
|
||||
):
|
||||
self.disc_type = disc_type
|
||||
self.title = title
|
||||
@@ -211,6 +212,9 @@ class PreScanResult:
|
||||
self.confidence = confidence
|
||||
self.metadata = metadata or {}
|
||||
self.tracks = tracks or []
|
||||
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
|
||||
# Duplikat-Warnung („diese Disc wurde schon gerippt")
|
||||
self.fingerprint = fingerprint
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
@@ -219,7 +223,8 @@ class PreScanResult:
|
||||
"year": self.year,
|
||||
"confidence": self.confidence,
|
||||
"metadata": self.metadata,
|
||||
"tracks": self.tracks
|
||||
"tracks": self.tracks,
|
||||
"fingerprint": self.fingerprint
|
||||
}
|
||||
|
||||
|
||||
@@ -361,7 +366,8 @@ class PreScan:
|
||||
disc_type="CD",
|
||||
title=album,
|
||||
tracks=toc["tracks"],
|
||||
confidence=confidence
|
||||
confidence=confidence,
|
||||
fingerprint=toc.get("fingerprint", "")
|
||||
)
|
||||
cache_set(cache_key, result.to_dict())
|
||||
return result
|
||||
@@ -486,6 +492,16 @@ class PreScan:
|
||||
"year": None
|
||||
}
|
||||
|
||||
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
|
||||
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
|
||||
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
|
||||
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
|
||||
deutsch = self.tmdb.find_by_imdb(metadata["id"])
|
||||
if deutsch:
|
||||
for feld in ("title", "overview", "poster_path", "year", "type"):
|
||||
if deutsch.get(feld):
|
||||
metadata[feld] = deutsch[feld]
|
||||
|
||||
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
|
||||
if matched and metadata.get("title"):
|
||||
title = metadata["title"]
|
||||
@@ -496,7 +512,8 @@ class PreScan:
|
||||
year=metadata.get("year"),
|
||||
confidence=confidence,
|
||||
metadata=metadata,
|
||||
tracks=toc.get("tracks", [])
|
||||
tracks=toc.get("tracks", []),
|
||||
fingerprint=toc.get("fingerprint", "")
|
||||
)
|
||||
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
|
||||
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
|
||||
|
||||
+6
-41
@@ -1,9 +1,12 @@
|
||||
"""Rate-Limiting-Modul für Rippy API."""
|
||||
"""Rate-Limiting-Modul für Rippy API (pro Client-IP).
|
||||
|
||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||
"""
|
||||
|
||||
import time
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict
|
||||
|
||||
# Default Rate Limit
|
||||
MAX_REQUESTS_PER_MINUTE = 100
|
||||
@@ -48,41 +51,3 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
||||
def reset_rate_limit(client_id: str) -> None:
|
||||
"""Setze Rate Limit für Client zurück."""
|
||||
rate_limit_store[client_id] = []
|
||||
|
||||
|
||||
# API-Key Store (in Produktion mit Datenbank)
|
||||
api_keys: Dict[str, Dict] = {
|
||||
"example_key": {
|
||||
"key": "example_key",
|
||||
"name": "Beispiel API Key",
|
||||
"created_at": time.time(),
|
||||
"rate_limit": 100
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def validate_api_key(api_key: str) -> Optional[Dict]:
|
||||
"""Validiere API Key."""
|
||||
if api_key in api_keys:
|
||||
return api_keys[api_key]
|
||||
return None
|
||||
|
||||
|
||||
def create_api_key(name: str) -> Dict:
|
||||
"""Erstelle neuer API Key."""
|
||||
key = secrets.token_urlsafe(32)
|
||||
api_keys[key] = {
|
||||
"key": key,
|
||||
"name": name,
|
||||
"created_at": time.time(),
|
||||
"rate_limit": MAX_REQUESTS_PER_MINUTE
|
||||
}
|
||||
return api_keys[key]
|
||||
|
||||
|
||||
def delete_api_key(key: str) -> bool:
|
||||
"""Lösche API Key."""
|
||||
if key in api_keys:
|
||||
del api_keys[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -7,12 +7,7 @@ redis==5.0.4
|
||||
requests==2.32.3
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.2
|
||||
passlib==1.7.4
|
||||
# bcrypt MUSS gepinnt bleiben: passlib 1.7.4 liest bcrypt.__about__ (in
|
||||
# bcrypt >= 4.1 entfernt) — der Backend-Selbsttest crasht dann mit
|
||||
# "password cannot be longer than 72 bytes" und JEDES Hashing schlägt fehl.
|
||||
# Genau das hielt die Ampel ab dem 23.07. rot (stable blieb 10 Commits zurück).
|
||||
bcrypt==4.0.1
|
||||
PyJWT==2.9.0
|
||||
python-multipart==0.0.9
|
||||
# passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
|
||||
# Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
|
||||
# die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
|
||||
aiofiles==24.1.0
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
|
||||
|
||||
import time
|
||||
|
||||
from auth import (
|
||||
add_to_blacklist,
|
||||
cleanup_blacklist,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_hash,
|
||||
is_access_token,
|
||||
is_blacklisted,
|
||||
is_refresh_token,
|
||||
token_blacklist,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_passwort_hash_roundtrip():
|
||||
hashed = get_password_hash("geheim123")
|
||||
assert hashed != "geheim123"
|
||||
assert verify_password("geheim123", hashed) is True
|
||||
assert verify_password("falsch", hashed) is False
|
||||
|
||||
|
||||
def test_access_token_roundtrip():
|
||||
token = create_access_token({"sub": "commander"})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "commander"
|
||||
assert payload["type"] == "access"
|
||||
assert is_access_token(token) is True
|
||||
assert is_refresh_token(token) is False
|
||||
|
||||
|
||||
def test_refresh_token_roundtrip():
|
||||
token = create_refresh_token({"sub": "commander"})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["type"] == "refresh"
|
||||
assert is_refresh_token(token) is True
|
||||
assert is_access_token(token) is False
|
||||
|
||||
|
||||
def test_muell_token_gibt_none_und_false():
|
||||
assert decode_token("kein.echter.token") is None
|
||||
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
|
||||
assert is_access_token("kein.echter.token") is False
|
||||
assert is_refresh_token("kein.echter.token") is False
|
||||
|
||||
|
||||
def test_blacklist_logout_wirkt():
|
||||
token = create_access_token({"sub": "commander"})
|
||||
assert is_blacklisted(token) is False
|
||||
add_to_blacklist(token)
|
||||
assert is_blacklisted(token) is True
|
||||
|
||||
|
||||
def test_cleanup_entfernt_nur_abgelaufene():
|
||||
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
|
||||
frisch = create_access_token({"sub": "commander"})
|
||||
add_to_blacklist(frisch)
|
||||
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
|
||||
|
||||
cleanup_blacklist()
|
||||
|
||||
assert "laengst-abgelaufener-token" not in token_blacklist
|
||||
assert is_blacklisted(frisch) is True
|
||||
Reference in New Issue
Block a user