From 0ea5f10b314cc313dd75817703351bae0d8759a0 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Wed, 22 Jul 2026 19:31:48 +0200 Subject: [PATCH] Fix-Runde nach Review: alle Befunde behoben + echte Tests - 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) --- SAVEPOINT.md | 34 +++ docker/api/auth.py | 61 +++-- docker/api/cache.py | 144 ---------- docker/api/cache/keys.py | 2 +- docker/api/clients/musicbrainz.py | 1 - docker/api/config.py | 8 +- docker/api/config_validation.py | 1 - docker/api/conftest.py | 9 + docker/api/image_downloader.py | 2 - docker/api/main.py | 57 ++-- docker/api/nfo_generator.py | 2 +- docker/api/prescan.py | 300 --------------------- docker/api/prescan/prescan.py | 260 ++++++++++++++++-- docker/api/ratelimit.py | 2 - docker/api/test_auth.py | 69 +++++ docker/api/test_cache_keys.py | 31 +++ docker/api/test_health.py | 6 - docker/worker/conftest.py | 6 + docker/worker/ripping.py | 364 +++++++++++++------------- docker/worker/tasks.py | 1 - docker/worker/test_ripping_helpers.py | 79 ++++++ udev/udev_daemon.py | 2 - 22 files changed, 722 insertions(+), 719 deletions(-) delete mode 100644 docker/api/cache.py create mode 100644 docker/api/conftest.py delete mode 100644 docker/api/prescan.py create mode 100644 docker/api/test_auth.py create mode 100644 docker/api/test_cache_keys.py delete mode 100644 docker/api/test_health.py create mode 100644 docker/worker/conftest.py create mode 100644 docker/worker/test_ripping_helpers.py diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 05d19dd..c974bf1 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -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. diff --git a/docker/api/auth.py b/docker/api/auth.py index 6c1767f..841fbe5 100644 --- a/docker/api/auth.py +++ b/docker/api/auth.py @@ -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] diff --git a/docker/api/cache.py b/docker/api/cache.py deleted file mode 100644 index 19665f9..0000000 --- a/docker/api/cache.py +++ /dev/null @@ -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() diff --git a/docker/api/cache/keys.py b/docker/api/cache/keys.py index a55d449..cb4691c 100644 --- a/docker/api/cache/keys.py +++ b/docker/api/cache/keys.py @@ -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:" diff --git a/docker/api/clients/musicbrainz.py b/docker/api/clients/musicbrainz.py index 79faf06..b7e5c6d 100644 --- a/docker/api/clients/musicbrainz.py +++ b/docker/api/clients/musicbrainz.py @@ -3,7 +3,6 @@ import requests from typing import Dict, List, Optional -from config import settings from cache import get, set diff --git a/docker/api/config.py b/docker/api/config.py index b0dced5..e930aa7 100644 --- a/docker/api/config.py +++ b/docker/api/config.py @@ -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" diff --git a/docker/api/config_validation.py b/docker/api/config_validation.py index e326d1a..fe4f76d 100644 --- a/docker/api/config_validation.py +++ b/docker/api/config_validation.py @@ -1,6 +1,5 @@ """Validation für Rippy Configuration.""" -from typing import Optional from pydantic import ValidationError from config import Settings diff --git a/docker/api/conftest.py b/docker/api/conftest.py new file mode 100644 index 0000000..850e3ad --- /dev/null +++ b/docker/api/conftest.py @@ -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") diff --git a/docker/api/image_downloader.py b/docker/api/image_downloader.py index c13d369..106d613 100644 --- a/docker/api/image_downloader.py +++ b/docker/api/image_downloader.py @@ -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: diff --git a/docker/api/main.py b/docker/api/main.py index 53a95d6..a04665b 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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") diff --git a/docker/api/nfo_generator.py b/docker/api/nfo_generator.py index 44d0044..d9b5ec3 100644 --- a/docker/api/nfo_generator.py +++ b/docker/api/nfo_generator.py @@ -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 diff --git a/docker/api/prescan.py b/docker/api/prescan.py deleted file mode 100644 index 566cc75..0000000 --- a/docker/api/prescan.py +++ /dev/null @@ -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 diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index e6bac6c..ce9cba9 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -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 diff --git a/docker/api/ratelimit.py b/docker/api/ratelimit.py index 88a3e5d..a497862 100644 --- a/docker/api/ratelimit.py +++ b/docker/api/ratelimit.py @@ -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 diff --git a/docker/api/test_auth.py b/docker/api/test_auth.py new file mode 100644 index 0000000..823d3fe --- /dev/null +++ b/docker/api/test_auth.py @@ -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 diff --git a/docker/api/test_cache_keys.py b/docker/api/test_cache_keys.py new file mode 100644 index 0000000..a060ef3 --- /dev/null +++ b/docker/api/test_cache_keys.py @@ -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 diff --git a/docker/api/test_health.py b/docker/api/test_health.py deleted file mode 100644 index eede680..0000000 --- a/docker/api/test_health.py +++ /dev/null @@ -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() diff --git a/docker/worker/conftest.py b/docker/worker/conftest.py new file mode 100644 index 0000000..559cde6 --- /dev/null +++ b/docker/worker/conftest.py @@ -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__)) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index d48df2f..1d760b9 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -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,113 +34,188 @@ def check_cdparanoia_installed() -> bool: return shutil.which("cdparanoia") is not None -@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.""" +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" - } - - 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) + return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} - -@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) - - -@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.""" - 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", - "error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping." - } - - output_dir = f"/output/cd/{disc_id}" - os.makedirs(output_dir, exist_ok=True) - try: - cmd = [ - "abcde", - "-d", device_path, - "-o", "flac", - "-b", - "-t", "1", - "-a", "default", - "-x", - "-o", output_dir - ] - process = subprocess.Popen( - cmd, + build_handbrake_cmd(device_path, output_path), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) - + for line in process.stdout: - self.send_task( - "worker.ripping.update_progress", - kwargs={ - "progress": 50, - "status": "ripping", - "message": f"Rippe CD: {line.strip()}" - } - ) - + progress = get_progress_from_line(line) + if progress > 0 and progress_cb: + progress_cb(progress) + 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..." - } - ) - + 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.""" + output_dir = f"/output/dvd/{disc_id}" + os.makedirs(output_dir, exist_ok=True) + output_path = f"{output_dir}/dvd_{disc_id}.mkv" + 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.""" + output_dir = f"/output/bluray/{disc_id}" + os.makedirs(output_dir, exist_ok=True) + output_path = f"{output_dir}/bluray_{disc_id}.mkv" + 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 (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", + "error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping." + } + + 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: + process = subprocess.Popen( + build_abcde_cmd(device_path, config_path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + for line in process.stdout: + melde(50, line.strip()[:200]) + + process.wait() + + if process.returncode == 0: + 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 diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index 81135ac..d0d41cc 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -1,4 +1,3 @@ -import os from celery_app import celery_app from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py new file mode 100644 index 0000000..8209fdf --- /dev/null +++ b/docker/worker/test_ripping_helpers.py @@ -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" diff --git a/udev/udev_daemon.py b/udev/udev_daemon.py index d47cc38..80a1ed9 100644 --- a/udev/udev_daemon.py +++ b/udev/udev_daemon.py @@ -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"