- 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:
+34
-27
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
Vendored
+1
-1
@@ -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:"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from config import settings
|
||||
from cache import get, set
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
"""Validation für Rippy Configuration."""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import ValidationError
|
||||
from config import Settings
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user