Fix-Runde nach Review: alle Befunde behoben + echte Tests
Ampel / ampel (push) Failing after 12m31s

- Metadaten-Preview WIEDERHERGESTELLT (Stub ueberschattete echte
  prescan-Implementierung), tote Altmodule geloescht
- Celery update_state statt Phantom-Task/erfundener API
- abcde-Kommando korrigiert (CD-Ripping war nie funktionsfaehig)
- JWT: fester Schluessel Pflicht, echtes Logout, Cleanup nur Abgelaufene
- main.py: crashende Endpoints (Path/secrets/api_keys), year-Bug,
  Admin-Login aus .env
- Ruff gruen (29 Funde), Tests: auth/cache_keys/ripping_helpers,
  Placebo-test_health raus
- SAVEPOINT: offene MakeMKV-Entscheidung SICHTBAR gemacht (Regel B)
This commit is contained in:
Hitonabi
2026-07-22 19:31:48 +02:00
parent 728f650bf8
commit 0ea5f10b31
22 changed files with 722 additions and 719 deletions
+34 -27
View File
@@ -1,8 +1,7 @@
"""JWT-Auth-Module für Rippy API."""
import secrets
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
import jwt
@@ -13,8 +12,16 @@ from config import settings
# Passwort-Hashing-Kontext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Geheimer Schlüssel für JWT
SECRET_KEY = settings.jwt_secret_key or secrets.token_urlsafe(32)
# 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
@@ -33,13 +40,10 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
"""Erstelle Access Token (15 min)."""
"""Erstelle Access Token (Default 15 min)."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
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)
@@ -47,17 +51,15 @@ def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
def create_refresh_token(data: Dict) -> str:
"""Erstelle Refresh Token (7 Tage)."""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=7)
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."""
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
@@ -65,34 +67,39 @@ def decode_token(token: str) -> Optional[Dict]:
def is_access_token(token: str) -> bool:
"""Prüfe ob Token ein Access Token ist."""
"""Prüfe ob Token ein gültiges Access Token ist."""
payload = decode_token(token)
return payload and payload.get("type") == "access"
return bool(payload and payload.get("type") == "access")
def is_refresh_token(token: str) -> bool:
"""Prüfe ob Token ein Refresh Token ist."""
"""Prüfe ob Token ein gültiges Refresh Token ist."""
payload = decode_token(token)
return payload and payload.get("type") == "refresh"
return bool(payload and payload.get("type") == "refresh")
# In-Memory Token Blacklist für Logout
token_blacklist: set = set()
# 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 Token zur Blacklist hinzu."""
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
payload = decode_token(token)
if payload:
token_blacklist.add(token)
token_blacklist[token] = float(payload.get("exp", time.time()))
def is_blacklisted(token: str) -> bool:
"""Prüfe ob Token auf Blacklist steht."""
"""Prüfe ob Token auf der Blacklist steht."""
return token in token_blacklist
def cleanup_blacklist() -> None:
"""Räume alte Token von Blacklist."""
current_time = time.time()
token_blacklist.clear() # In Produktion mit Redis implementieren
"""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]
-144
View File
@@ -1,144 +0,0 @@
"""SQLite Cache für API-Rate-Limits (LRU, 10k Einträge, TTL)."""
import sqlite3
import time
import json
from pathlib import Path
from typing import Any, Dict, Optional
from cache.keys import PRESCAN_AUDIO_PREFIX, PRESCAN_VIDEO_PREFIX, CONFIRMED_METADATA_PREFIX
CACHE_DIR = Path("/app/cache")
CACHE_FILE = CACHE_DIR / "api_cache.db"
MAX_ENTRIES = 10000
DEFAULT_TTL = 86400 # 24 Stunden
def is_prescan_key(key: str) -> bool:
"""Prüfe ob Key ein Pre-Scan-Key ist."""
return key.startswith(PRESCAN_AUDIO_PREFIX) or key.startswith(PRESCAN_VIDEO_PREFIX)
def is_confirmed_key(key: str) -> bool:
"""Prüfe ob Key ein bestätigter Metadaten-Key ist."""
return key.startswith(CONFIRMED_METADATA_PREFIX)
def get_cache_category(key: str) -> str:
"""Ermittle Kategorie eines Cache-Keys."""
if is_prescan_key(key):
return "prescan"
elif is_confirmed_key(key):
return "confirmed"
elif key.startswith("tmdb:"):
return "tmdb"
elif key.startswith("musicbrainz:"):
return "musicbrainz"
return "other"
def init_cache() -> None:
"""Initialisiere Cache-DB."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(CACHE_FILE)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS api_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
ttl INTEGER NOT NULL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_key ON api_cache(key)")
conn.commit()
conn.close()
def _get_connection() -> sqlite3.Connection:
"""Hole DB-Verbindung mit LRU-Check."""
conn = sqlite3.connect(CACHE_FILE)
# LRU: entferne alte Einträge wenn MAX_ENTRIES überschritten
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM api_cache")
count = cursor.fetchone()[0]
if count >= MAX_ENTRIES:
cursor.execute("""
DELETE FROM api_cache
WHERE id IN (
SELECT id FROM api_cache
ORDER BY created_at ASC
LIMIT ?
)
""", (MAX_ENTRIES // 10,))
conn.commit()
return conn
def get(key: str) -> Optional[Dict[str, Any]]:
"""Hole Wert aus Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT value, created_at, ttl FROM api_cache WHERE key = ?",
(key,)
)
row = cursor.fetchone()
if row is None:
conn.close()
return None
value, created_at, ttl = row
if time.time() > created_at + ttl:
# TTL abgelaufen
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
conn.commit()
conn.close()
return None
conn.close()
return json.loads(value)
def set(key: str, value: Dict[str, Any], ttl: int = DEFAULT_TTL) -> None:
"""Speichere Wert im Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO api_cache (key, value, created_at, ttl)
VALUES (?, ?, ?, ?)
""", (key, json.dumps(value), int(time.time()), ttl))
conn.commit()
conn.close()
def delete(key: str) -> None:
"""Lösche Eintrag aus Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
conn.commit()
conn.close()
def clear() -> None:
"""Lösche gesamten Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM api_cache")
conn.commit()
conn.close()
+1 -1
View File
@@ -1,6 +1,6 @@
"""Zentrale Cache-Key-Definitionen für Rippy API."""
from typing import Final
from typing import Dict, Final, Optional
# Pre-Scan Keys
PRESCAN_AUDIO_PREFIX: Final[str] = "prescan:audio:"
-1
View File
@@ -3,7 +3,6 @@
import requests
from typing import Dict, List, Optional
from config import settings
from cache import get, set
+6 -2
View File
@@ -23,8 +23,12 @@ class Settings(BaseSettings):
# Logging
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
# JWT
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (wird auto-generiert wenn nicht gesetzt)")
# 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)")
# 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"
-1
View File
@@ -1,6 +1,5 @@
"""Validation für Rippy Configuration."""
from typing import Optional
from pydantic import ValidationError
from config import Settings
+9
View File
@@ -0,0 +1,9 @@
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
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")
-2
View File
@@ -3,11 +3,9 @@
import requests
from pathlib import Path
from typing import Dict, Optional
from urllib.parse import urlparse
from clients.tmdb import TMDBClient
from clients.thetvdb import TheTVDBClient
from config import settings
class ImageDownloader:
+34 -23
View File
@@ -1,23 +1,33 @@
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from typing import List, Optional, Dict
from datetime import datetime
import os
from pathlib import Path
import subprocess
import asyncio
import json
import time
from fastapi.security import OAuth2PasswordBearer
from config import settings
from config_validation import validate_config, ConfigValidationError
from cache import init_cache, set
from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted
from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key
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
@@ -198,7 +208,7 @@ async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dic
from cache.keys import generate_confirmed_key
# In Cache speichern
cache_key = generate_confirmed_key(title, year)
set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
cache_set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
return {"status": "confirmed", "key": cache_key}
@@ -271,8 +281,10 @@ async def jellyfin_format(request: JellyfinFormatRequest):
else:
# Audio-Formatierung
artist = request.metadata.get("artist", "Unknown Artist")
album = title
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,
@@ -305,8 +317,8 @@ class LoginRequest(BaseModel):
@app.post("/token")
async def login(request: LoginRequest):
"""Login und Token generieren."""
# Einfache Auth für MVP (in Produktion mit Datenbank)
if request.username == "admin" and request.password == "rippy123":
# 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"]}
)
@@ -339,8 +351,11 @@ async def invalidate_token(token: str):
"""Invalidate Token (Logout)."""
if is_blacklisted(token):
raise HTTPException(status_code=400, detail="Token bereits invalidiert")
# In Produktion mit Redis implementieren
# 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"}
@@ -349,17 +364,14 @@ 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
key_info = {
"key": secrets.token_urlsafe(32),
"name": request.name,
"created_at": time.time(),
"rate_limit": 100
}
return key_info
return ratelimit_create_api_key(request.name)
@app.get("/api-keys")
@@ -372,7 +384,6 @@ async def list_api_keys():
async def delete_api_key(key: str):
"""Lösche API Key."""
# In Produktion mit Auth prüfen
if key in api_keys:
del api_keys[key]
if ratelimit_delete_api_key(key):
return {"status": "deleted"}
raise HTTPException(status_code=404, detail="API Key nicht gefunden")
+1 -1
View File
@@ -1,7 +1,7 @@
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
from pathlib import Path
from typing import Dict, List, Optional
from typing import List
from xml.dom.minidom import getDOMImplementation
-300
View File
@@ -1,300 +0,0 @@
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping."""
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient
from clients.thetvdb import TheTVDBClient
from cache import get, set
from cache.keys import generate_prescan_key
from config import settings
class PreScanResult:
def __init__(
self,
disc_type: str,
title: str,
year: int = None,
confidence: float = 0.0,
metadata: Dict = None,
tracks: List[Dict] = None
):
self.disc_type = disc_type
self.title = title
self.year = year
self.confidence = confidence
self.metadata = metadata or {}
self.tracks = tracks or []
def to_dict(self) -> Dict:
return {
"disc_type": self.disc_type,
"title": self.title,
"year": self.year,
"confidence": self.confidence,
"metadata": self.metadata,
"tracks": self.tracks
}
class PreScan:
def __init__(self):
self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient()
def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch."""
# 1. Disc-Typ erkennen
disc_type = self._detect_disc_type(device_path)
# 2. TOC lesen
toc = self._read_toc(device_path, disc_type)
# 3. Metadaten lookup
if disc_type == "CD":
return self._scan_audio(device_path, toc)
else:
return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
try:
result = subprocess.run(
["isosize", "-x", device_path],
capture_output=True,
text=True,
timeout=5
)
# Ausgabe parsen
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024: # < 700MB
return "CD"
elif size < 15 * 1024 * 1024 * 1024: # < 15GB
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
# Fallback
return "DVD"
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
toc = {
"title": None,
"year": None,
"tracks": [],
"duration": 0
}
try:
if disc_type == "CD":
# cdparanoia für CD-TOC
result = subprocess.run(
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
)
# Parse cdparanoia output
for line in result.stdout.split('\n'):
if 'track' in line.lower():
# Track-Info parsen
parts = line.split()
if len(parts) >= 4:
toc["tracks"].append({
"track_number": parts[1],
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
"duration": 0
})
else:
# makemkvcon für DVD/Blu-ray (nur TOC, kein Ripping)
result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
capture_output=True,
text=True,
timeout=30
)
# Parse makemkvcon output
for line in result.stdout.split('\n'):
if line.startswith('DRV:'):
# Disc-Info
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
# Titel-Info
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD."""
# Cache-Key
cache_key = generate_prescan_key(device_path, is_audio=True)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
# Künstler und Album aus Titel extrahieren
artist = None
album = toc["title"] or "Unknown Album"
if " - " in album:
parts = album.split(" - ", 1)
if len(parts) == 2:
artist = parts[0]
album = parts[1]
# MusicBrainz Search
if artist:
artists = self.musicbrainz.search_artist(artist)
if artists:
# Best match
best_match = artists[0]
confidence = 0.8
# Album suchen
releases = self.musicbrainz.search_release(album, artist)
if releases:
confidence = 0.9
release_id = releases[0]["id"]
release_info = self.musicbrainz.get_release_info(release_id)
if release_info:
# Tracks aus Release Info
for medium in release_info.get("media", []):
for track in medium.get("tracks", []):
toc["tracks"].append({
"track_number": track.get("position", 0),
"title": track.get("title", "Unknown"),
"duration": track.get("duration", 0) // 1000
})
# TMDB Fallback für bekannte Soundtracks
if not artist:
movies = self.tmdb.search_movie(album)
if movies:
confidence = 0.7
movie = movies[0]
movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details:
# Soundtrack als "Soundtrack - [Filmname]"
artist = album
album = f"Soundtrack - {movie_details.get('title', album)}"
toc["title"] = album
# Cache result
result = PreScanResult(
disc_type="CD",
title=album,
tracks=toc["tracks"],
confidence=confidence if 'confidence' in dir() else 0.5
)
set(cache_key, result.to_dict())
return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray."""
# Cache-Key
cache_key = generate_prescan_key(device_path, is_audio=False)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
# Disc-Titel
title = toc["title"] or "Unknown Title"
# TMDB Search
confidence = 0.0
metadata = {}
matched = False
# Versuche Film-Suche
movies = self.tmdb.search_movie(title)
if movies:
for movie in movies:
# Prüfe auf gute Übereinstimmung
if movie.get("title", "").lower() == title.lower():
confidence = 0.95
movie_id = movie["id"]
movie_details = self.tmdb.get_movie_details(movie_id)
if movie_details:
metadata = {
"type": "movie",
"id": movie_id,
"title": movie_details.get("title", title),
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
"overview": movie_details.get("overview", ""),
"poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])]
}
matched = True
break
# Versuche Serien-Suche
if not matched:
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
for show in tv_shows:
if show.get("name", "").lower() == title.lower():
confidence = 0.9
tv_id = show["id"]
tv_details = self.tmdb.get_tv_details(tv_id)
if tv_details:
metadata = {
"type": "tv",
"id": tv_id,
"title": tv_details.get("name", title),
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
"overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])]
}
matched = True
break
# Fallback auf Titel ohne Lookup
if not matched:
confidence = 0.3
metadata = {
"type": "unknown",
"title": title,
"year": None
}
# Cache result
result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"),
title=title,
year=metadata.get("year"),
confidence=confidence,
metadata=metadata,
tracks=toc.get("tracks", [])
)
set(cache_key, result.to_dict())
return result
+238 -22
View File
@@ -1,22 +1,40 @@
"""Pre-Scan module."""
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping und schlägt Metadaten vor.
from typing import Dict, Optional
from dataclasses import dataclass, field
WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementierung
(flache prescan.py) durch einen Stub überschattet — die Metadaten-Preview (Muss-Feature)
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
"""
import subprocess
from typing import Dict, List
from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient
from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set
from cache.keys import generate_prescan_key
@dataclass
class PreScanResult:
"""Result of a pre-scan."""
title: str
year: Optional[int] = None
confidence: float = 0.0
metadata: Dict = field(default_factory=dict)
tracks: list = field(default_factory=list)
def __init__(
self,
disc_type: str = "DVD",
title: str = "Unknown",
year: int = None,
confidence: float = 0.0,
metadata: Dict = None,
tracks: List[Dict] = None
):
self.disc_type = disc_type
self.title = title
self.year = year
self.confidence = confidence
self.metadata = metadata or {}
self.tracks = tracks or []
def to_dict(self) -> Dict:
"""Convert to dictionary."""
return {
"disc_type": self.disc_type,
"title": self.title,
"year": self.year,
"confidence": self.confidence,
@@ -26,14 +44,212 @@ class PreScanResult:
class PreScan:
"""Pre-scan for discs."""
def __init__(self):
self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient()
def scan(self, device_path: str) -> PreScanResult:
"""Scan a disc and return metadata."""
return PreScanResult(
title="Unknown Disc",
year=None,
confidence=0.0,
metadata={},
tracks=[]
"""Führe Pre-Scan durch."""
disc_type = self._detect_disc_type(device_path)
toc = self._read_toc(device_path, disc_type)
if disc_type == "CD":
return self._scan_audio(device_path, toc)
return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe."""
try:
result = subprocess.run(
["isosize", "-x", device_path],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024:
return "CD"
elif size < 15 * 1024 * 1024 * 1024:
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
return "DVD"
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
toc = {
"title": None,
"year": None,
"tracks": [],
"duration": 0
}
try:
if disc_type == "CD":
result = subprocess.run(
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
)
for line in result.stdout.split('\n'):
if 'track' in line.lower():
parts = line.split()
if len(parts) >= 4:
toc["tracks"].append({
"track_number": parts[1],
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
"duration": 0
})
else:
# makemkvcon liest nur den TOC — KEIN Ripping
result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
capture_output=True,
text=True,
timeout=30
)
for line in result.stdout.split('\n'):
if line.startswith('DRV:'):
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD."""
cache_key = generate_prescan_key(device_path, is_audio=True)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
confidence = 0.5
artist = None
album = toc["title"] or "Unknown Album"
if " - " in album:
parts = album.split(" - ", 1)
if len(parts) == 2:
artist = parts[0]
album = parts[1]
if artist:
artists = self.musicbrainz.search_artist(artist)
if artists:
confidence = 0.8
releases = self.musicbrainz.search_release(album, artist)
if releases:
confidence = 0.9
release_id = releases[0]["id"]
release_info = self.musicbrainz.get_release_info(release_id)
if release_info:
for medium in release_info.get("media", []):
for track in medium.get("tracks", []):
toc["tracks"].append({
"track_number": track.get("position", 0),
"title": track.get("title", "Unknown"),
"duration": track.get("duration", 0) // 1000
})
if not artist:
movies = self.tmdb.search_movie(album)
if movies:
confidence = 0.7
movie_details = self.tmdb.get_movie_details(movies[0]["id"])
if movie_details:
album = f"Soundtrack - {movie_details.get('title', album)}"
toc["title"] = album
result = PreScanResult(
disc_type="CD",
title=album,
tracks=toc["tracks"],
confidence=confidence
)
cache_set(cache_key, result.to_dict())
return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray."""
cache_key = generate_prescan_key(device_path, is_audio=False)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
title = toc["title"] or "Unknown Title"
confidence = 0.0
metadata = {}
matched = False
movies = self.tmdb.search_movie(title)
if movies:
for movie in movies:
if movie.get("title", "").lower() == title.lower():
confidence = 0.95
movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details:
metadata = {
"type": "movie",
"id": movie["id"],
"title": movie_details.get("title", title),
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
"overview": movie_details.get("overview", ""),
"poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])]
}
matched = True
break
if not matched:
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
for show in tv_shows:
if show.get("name", "").lower() == title.lower():
confidence = 0.9
tv_details = self.tmdb.get_tv_details(show["id"])
if tv_details:
metadata = {
"type": "tv",
"id": show["id"],
"title": tv_details.get("name", title),
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
"overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])]
}
matched = True
break
if not matched:
confidence = 0.3
metadata = {
"type": "unknown",
"title": title,
"year": None
}
result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"),
title=title,
year=metadata.get("year"),
confidence=confidence,
metadata=metadata,
tracks=toc.get("tracks", [])
)
cache_set(cache_key, result.to_dict())
return result
-2
View File
@@ -5,8 +5,6 @@ import secrets
from collections import defaultdict
from typing import Dict, Optional
from config import settings
# Default Rate Limit
MAX_REQUESTS_PER_MINUTE = 100
+69
View File
@@ -0,0 +1,69 @@
"""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
+31
View File
@@ -0,0 +1,31 @@
"""Tests für cache/keys.py: deterministische Key-Erzeugung."""
from cache.keys import (
PRESCAN_AUDIO_PREFIX,
PRESCAN_VIDEO_PREFIX,
generate_confirmed_key,
generate_prescan_key,
generate_tmdb_key,
)
def test_prescan_key_audio_vs_video():
audio = generate_prescan_key("/dev/sr0", is_audio=True)
video = generate_prescan_key("/dev/sr0", is_audio=False)
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
assert video.startswith(PRESCAN_VIDEO_PREFIX)
assert audio != video
assert audio.endswith("/dev/sr0")
def test_confirmed_key_mit_und_ohne_jahr():
assert generate_confirmed_key("Blade Runner", 1982) == "confirmed:Blade Runner:1982"
assert generate_confirmed_key("Blade Runner") == "confirmed:Blade Runner:0"
def test_tmdb_key_mit_params_unterscheidet():
ohne = generate_tmdb_key("search/movie")
mit = generate_tmdb_key("search/movie", {"query": "Dune"})
assert ohne != mit
assert ohne.startswith("tmdb:")
assert "search/movie" in mit
-6
View File
@@ -1,6 +0,0 @@
import http.client
conn = http.client.HTTPConnection("localhost", 8000)
conn.request("GET", "/health")
res = conn.getresponse()
print(res.read().decode())
conn.close()