- 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:
@@ -68,3 +68,37 @@ rippy-ui-modern - UI Modernisiert & Import-Fixes
|
||||
bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen
|
||||
efa642e - Etappe 6: Sicherheit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v2.0 — Fix-Runde nach externem Code-Review (22.07.2026, Claude)
|
||||
|
||||
**Alle Befunde des Reviews behoben, echte Tests eingeführt, Ampel-CI aktiv.**
|
||||
|
||||
Gefixt:
|
||||
- **Metadaten-Preview WIEDERHERGESTELLT**: Beim SoC-Refactoring hatte ein Stub-Paket
|
||||
die echte prescan-Implementierung überschattet — die Preview lieferte immer
|
||||
„Unknown Disc". Echte Logik liegt jetzt im Paket, tote Altmodule (cache.py,
|
||||
prescan.py flach) gelöscht.
|
||||
- Fortschrittsmeldung: Celery `update_state` statt Aufrufe eines nie existierenden
|
||||
Tasks; `self.send_task` (erfundene API) entfernt.
|
||||
- abcde-Kommando korrigiert (`-o` war doppelt → CD-Ripping war nie funktionsfähig);
|
||||
Zielverzeichnis jetzt via OUTPUTDIR-Config; Kommando-Bau als testbare Funktion.
|
||||
- JWT: fester Schlüssel PFLICHT (kein Zufalls-Fallback pro Prozess mehr);
|
||||
Logout blacklistet wirklich; Cleanup löscht nur Abgelaufene (vorher: alles).
|
||||
- main.py: crashende Endpoints repariert (fehlende Imports Path/secrets,
|
||||
nicht existentes api_keys-Dict → ratelimit-Store), Audio-`year`-Bug,
|
||||
Admin-Zugang aus .env statt hartkodiert.
|
||||
- Ruff komplett grün (29 Funde), package-lock.json committet.
|
||||
- Tests: test_auth, test_cache_keys, test_ripping_helpers (der giftige
|
||||
test_health-Placebo ist raus).
|
||||
|
||||
## ⚠️ OFFENE ENTSCHEIDUNG FÜR DEN COMMANDER (Regel B — keine stille Abweichung)
|
||||
|
||||
**KONZEPT sagt: MakeMKV, verlustfrei (Muss-Feature). Gebaut wurde: HandBrake mit
|
||||
Lossy-Preset „Fast 1080p30".** Deine DVDs/Blu-rays werden aktuell TRANSKODIERT,
|
||||
nicht verlustfrei gesichert. Optionen:
|
||||
1. Auf MakeMKV umbauen (wie geplant — makemkvcon ist im Prescan schon im Einsatz,
|
||||
braucht Beta-Key-Handling im Worker-Container), ODER
|
||||
2. Konzept bewusst ändern („lossy reicht mir") und KONZEPT.md anpassen.
|
||||
Bis zur Entscheidung bleibt HandBrake aktiv — aber jetzt SICHTBAR statt still.
|
||||
|
||||
+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:
|
||||
|
||||
+32
-21
@@ -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,7 +281,9 @@ 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(
|
||||
@@ -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"]}
|
||||
)
|
||||
@@ -340,7 +352,10 @@ async def invalidate_token(token: str):
|
||||
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
|
||||
+236
-20
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Pytest-Setup für Worker-Tests: flache Modul-Imports."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
+147
-151
@@ -1,108 +1,29 @@
|
||||
"""Ripping-Tasks: DVD/Blu-ray via HandBrake, CD via abcde.
|
||||
|
||||
Review-Fixes 22.07.2026:
|
||||
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen
|
||||
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab.
|
||||
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
|
||||
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde
|
||||
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from celery_app import celery_app
|
||||
|
||||
HANDBRAKE_PRESET = "Fast 1080p30"
|
||||
|
||||
|
||||
def check_handbrake_installed() -> bool:
|
||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||
return shutil.which("HandBrakeCLI") is not None
|
||||
|
||||
|
||||
def get_progress_from_line(line: str) -> int:
|
||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe."""
|
||||
match = re.search(r'(\d+\.\d+)%', line)
|
||||
if match:
|
||||
return int(float(match.group(1)))
|
||||
return 0
|
||||
|
||||
|
||||
def run_handbrake(device_path: str, output_path: str) -> dict:
|
||||
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI."""
|
||||
if not check_handbrake_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "HandBrakeCLI ist nicht installiert"
|
||||
}
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
"HandBrakeCLI",
|
||||
"--input", device_path,
|
||||
"--output", output_path,
|
||||
"--all",
|
||||
"--progress",
|
||||
"--preset", "Fast 1080p30"
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
progress = get_progress_from_line(line)
|
||||
if progress > 0:
|
||||
celery_app.send_task(
|
||||
"worker.ripping.update_progress",
|
||||
kwargs={
|
||||
"progress": progress,
|
||||
"status": "ripping",
|
||||
"message": f"Rippe Titel {progress}%"
|
||||
}
|
||||
)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"return_code": process.returncode
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HandBrake failed with code {process.returncode}",
|
||||
"return_code": process.returncode
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Disc-Typ anhand des Gerätepfads."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["file", "-L", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
output = result.stdout.lower()
|
||||
|
||||
if "dvd" in output or "video_ts" in output:
|
||||
return "dvd"
|
||||
elif "bluray" in output or "bdmv" in output:
|
||||
return "bluray"
|
||||
elif "audio" in output or "cda" in output:
|
||||
return "cd"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def check_abcde_installed() -> bool:
|
||||
"""Prüft, ob abcde installiert ist."""
|
||||
return shutil.which("abcde") is not None
|
||||
@@ -113,47 +34,147 @@ def check_cdparanoia_installed() -> bool:
|
||||
return shutil.which("cdparanoia") is not None
|
||||
|
||||
|
||||
def get_progress_from_line(line: str) -> int:
|
||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe."""
|
||||
match = re.search(r'(\d+\.\d+)%', line)
|
||||
if match:
|
||||
return int(float(match.group(1)))
|
||||
return 0
|
||||
|
||||
|
||||
def build_handbrake_cmd(device_path: str, output_path: str) -> list:
|
||||
"""Baut das HandBrake-Kommando (pure Funktion, testbar)."""
|
||||
return [
|
||||
"HandBrakeCLI",
|
||||
"--input", device_path,
|
||||
"--output", output_path,
|
||||
"--all",
|
||||
"--progress",
|
||||
"--preset", HANDBRAKE_PRESET
|
||||
]
|
||||
|
||||
|
||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||
|
||||
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
|
||||
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
|
||||
"""
|
||||
return [
|
||||
"abcde",
|
||||
"-d", device_path,
|
||||
"-o", "flac",
|
||||
"-N",
|
||||
"-x",
|
||||
"-c", config_path
|
||||
]
|
||||
|
||||
|
||||
def write_abcde_config(output_dir: str) -> str:
|
||||
"""Schreibt eine minimale abcde-Config mit dem Zielverzeichnis, gibt Pfad zurück."""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".abcde.conf", delete=False, encoding="utf-8"
|
||||
)
|
||||
tmp.write(f"OUTPUTDIR='{output_dir}'\nINTERACTIVE=n\n")
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict:
|
||||
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback."""
|
||||
if not check_handbrake_installed():
|
||||
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
build_handbrake_cmd(device_path, output_path),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
progress = get_progress_from_line(line)
|
||||
if progress > 0 and progress_cb:
|
||||
progress_cb(progress)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"return_code": process.returncode
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HandBrake failed with code {process.returncode}",
|
||||
"return_code": process.returncode
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Disc-Typ anhand des Gerätepfads."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["file", "-L", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
output = result.stdout.lower()
|
||||
if "dvd" in output or "video_ts" in output:
|
||||
return "dvd"
|
||||
elif "bluray" in output or "bdmv" in output:
|
||||
return "bluray"
|
||||
elif "audio" in output or "cda" in output:
|
||||
return "cd"
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _progress_melder(task):
|
||||
"""Baut einen Fortschritts-Callback, der Celery-Standard update_state nutzt."""
|
||||
def melde(progress: int, message: str = ""):
|
||||
task.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"progress": progress, "status": "ripping", "message": message}
|
||||
)
|
||||
return melde
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_dvd")
|
||||
def rip_dvd(self, device_path: str, disc_id: str) -> dict:
|
||||
"""Rippt eine DVD mit HandBrake."""
|
||||
if not check_handbrake_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "HandBrakeCLI ist nicht installiert"
|
||||
}
|
||||
|
||||
output_dir = f"/output/dvd/{disc_id}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = f"{output_dir}/dvd_{disc_id}.mkv"
|
||||
|
||||
return run_handbrake(device_path, output_path)
|
||||
melde = _progress_melder(self)
|
||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_bluray")
|
||||
def rip_bluray(self, device_path: str, disc_id: str) -> dict:
|
||||
"""Rippt eine Blu-ray mit HandBrake."""
|
||||
if not check_handbrake_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "HandBrakeCLI ist nicht installiert"
|
||||
}
|
||||
|
||||
output_dir = f"/output/bluray/{disc_id}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = f"{output_dir}/bluray_{disc_id}.mkv"
|
||||
|
||||
return run_handbrake(device_path, output_path)
|
||||
melde = _progress_melder(self)
|
||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.ripping.rip_cd")
|
||||
def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
||||
"""Rippt eine CD mit abcde."""
|
||||
"""Rippt eine CD mit abcde (FLAC)."""
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
||||
}
|
||||
|
||||
if not check_cdparanoia_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -162,21 +183,12 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
||||
|
||||
output_dir = f"/output/cd/{disc_id}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
melde = _progress_melder(self)
|
||||
config_path = write_abcde_config(output_dir)
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
"abcde",
|
||||
"-d", device_path,
|
||||
"-o", "flac",
|
||||
"-b",
|
||||
"-t", "1",
|
||||
"-a", "default",
|
||||
"-x",
|
||||
"-o", output_dir
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
build_abcde_cmd(device_path, config_path),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
@@ -184,42 +196,26 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
self.send_task(
|
||||
"worker.ripping.update_progress",
|
||||
kwargs={
|
||||
"progress": 50,
|
||||
"status": "ripping",
|
||||
"message": f"Rippe CD: {line.strip()}"
|
||||
}
|
||||
)
|
||||
melde(50, line.strip()[:200])
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
# MusikBrainz Lookup (via abcde's post-read)
|
||||
self.send_task(
|
||||
"worker.ripping.update_progress",
|
||||
kwargs={
|
||||
"progress": 90,
|
||||
"status": "ripping",
|
||||
"message": "MusikBrainz Lookup läuft..."
|
||||
}
|
||||
)
|
||||
|
||||
melde(90, "MusicBrainz Lookup abgeschlossen")
|
||||
return {
|
||||
"status": "success",
|
||||
"output_dir": output_dir,
|
||||
"return_code": process.returncode
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"abcde failed with code {process.returncode}",
|
||||
"return_code": process.returncode
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
"error": f"abcde failed with code {process.returncode}",
|
||||
"return_code": process.returncode
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
try:
|
||||
os.unlink(config_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from celery_app import celery_app
|
||||
from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests für ripping.py: Fortschritts-Parsing, Kommando-Bau, Disc-Erkennung.
|
||||
|
||||
Deckt genau die Stellen ab, an denen im Review 22.07. erfundene Schnittstellen
|
||||
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import ripping
|
||||
from ripping import (
|
||||
build_abcde_cmd,
|
||||
build_handbrake_cmd,
|
||||
detect_disc_type,
|
||||
get_progress_from_line,
|
||||
write_abcde_config,
|
||||
)
|
||||
|
||||
|
||||
def test_progress_parsing():
|
||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||
assert get_progress_from_line("kein Fortschritt hier") == 0
|
||||
|
||||
|
||||
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
||||
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
||||
cmd = build_abcde_cmd("/dev/sr0", "/tmp/test.abcde.conf")
|
||||
assert cmd.count("-o") == 1
|
||||
assert cmd[cmd.index("-o") + 1] == "flac"
|
||||
assert "-c" in cmd
|
||||
assert cmd[cmd.index("-c") + 1] == "/tmp/test.abcde.conf"
|
||||
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
||||
|
||||
|
||||
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||
pfad = write_abcde_config("/output/cd/test123")
|
||||
try:
|
||||
with open(pfad, encoding="utf-8") as f:
|
||||
inhalt = f.read()
|
||||
assert "OUTPUTDIR='/output/cd/test123'" in inhalt
|
||||
assert "INTERACTIVE=n" in inhalt
|
||||
finally:
|
||||
os.unlink(pfad)
|
||||
|
||||
|
||||
def test_handbrake_cmd_vollstaendig():
|
||||
cmd = build_handbrake_cmd("/dev/sr0", "/output/dvd/x/film.mkv")
|
||||
assert cmd[0] == "HandBrakeCLI"
|
||||
assert cmd[cmd.index("--input") + 1] == "/dev/sr0"
|
||||
assert cmd[cmd.index("--output") + 1] == "/output/dvd/x/film.mkv"
|
||||
assert "--preset" in cmd
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, stdout):
|
||||
self.stdout = stdout
|
||||
|
||||
|
||||
def test_disc_typ_erkennung(monkeypatch):
|
||||
faelle = [
|
||||
("UDF filesystem data 'MEIN_FILM' DVD Video", "dvd"),
|
||||
("data, BDMV bluray structure", "bluray"),
|
||||
("Audio CD, cda tracks", "cd"),
|
||||
("irgendwas anderes", "unknown"),
|
||||
]
|
||||
for ausgabe, erwartet in faelle:
|
||||
monkeypatch.setattr(
|
||||
ripping.subprocess, "run", lambda *a, _out=ausgabe, **k: _FakeResult(_out)
|
||||
)
|
||||
assert detect_disc_type("/dev/sr0") == erwartet
|
||||
|
||||
|
||||
def test_disc_typ_erkennung_fehler_gibt_unknown(monkeypatch):
|
||||
def kaputt(*a, **k):
|
||||
raise OSError("kein Geraet")
|
||||
|
||||
monkeypatch.setattr(ripping.subprocess, "run", kaputt)
|
||||
assert detect_disc_type("/dev/sr0") == "unknown"
|
||||
@@ -9,8 +9,6 @@ import sys
|
||||
import subprocess
|
||||
import redis
|
||||
import json
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
DISC_DEVICE_PATH = "/dev/disc"
|
||||
|
||||
Reference in New Issue
Block a user