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

- Metadaten-Preview WIEDERHERGESTELLT (Stub ueberschattete echte
  prescan-Implementierung), tote Altmodule geloescht
- Celery update_state statt Phantom-Task/erfundener API
- abcde-Kommando korrigiert (CD-Ripping war nie funktionsfaehig)
- JWT: fester Schluessel Pflicht, echtes Logout, Cleanup nur Abgelaufene
- main.py: crashende Endpoints (Path/secrets/api_keys), year-Bug,
  Admin-Login aus .env
- Ruff gruen (29 Funde), Tests: auth/cache_keys/ripping_helpers,
  Placebo-test_health raus
- SAVEPOINT: offene MakeMKV-Entscheidung SICHTBAR gemacht (Regel B)
This commit is contained in:
Hitonabi
2026-07-22 19:31:48 +02:00
parent 728f650bf8
commit 0ea5f10b31
22 changed files with 722 additions and 719 deletions
+34
View File
@@ -68,3 +68,37 @@ rippy-ui-modern - UI Modernisiert & Import-Fixes
bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen
efa642e - Etappe 6: Sicherheit 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
View File
@@ -1,8 +1,7 @@
"""JWT-Auth-Module für Rippy API.""" """JWT-Auth-Module für Rippy API."""
import secrets
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Dict, Optional from typing import Dict, Optional
import jwt import jwt
@@ -13,8 +12,16 @@ from config import settings
# Passwort-Hashing-Kontext # Passwort-Hashing-Kontext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Geheimer Schlüssel für JWT # Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
SECRET_KEY = settings.jwt_secret_key or secrets.token_urlsafe(32) # 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" ALGORITHM = "HS256"
# Token-Lifetimes # Token-Lifetimes
@@ -33,13 +40,10 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: Dict, expires_delta: timedelta = None) -> 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() to_encode = data.copy()
if expires_delta: delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.utcnow() + expires_delta expire = datetime.now(timezone.utc) + delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire, "type": "access"}) to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 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: def create_refresh_token(data: Dict) -> str:
"""Erstelle Refresh Token (7 Tage).""" """Erstelle Refresh Token (7 Tage)."""
to_encode = data.copy() 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"}) to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[Dict]: def decode_token(token: str) -> Optional[Dict]:
"""Dekodiere Token.""" """Dekodiere Token (None bei abgelaufen/ungültig)."""
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
return None return None
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
@@ -65,34 +67,39 @@ def decode_token(token: str) -> Optional[Dict]:
def is_access_token(token: str) -> bool: 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) 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: 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) 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 für Logout: Token → Ablauf-Zeitstempel (exp).
token_blacklist: set = set() # 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: 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) payload = decode_token(token)
if payload: if payload:
token_blacklist.add(token) token_blacklist[token] = float(payload.get("exp", time.time()))
def is_blacklisted(token: str) -> bool: 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 return token in token_blacklist
def cleanup_blacklist() -> None: def cleanup_blacklist() -> None:
"""Räume alte Token von Blacklist.""" """Entferne NUR abgelaufene Tokens von der Blacklist."""
current_time = time.time() now = time.time()
token_blacklist.clear() # In Produktion mit Redis implementieren for token in [t for t, exp in token_blacklist.items() if exp <= now]:
del token_blacklist[token]
-144
View File
@@ -1,144 +0,0 @@
"""SQLite Cache für API-Rate-Limits (LRU, 10k Einträge, TTL)."""
import sqlite3
import time
import json
from pathlib import Path
from typing import Any, Dict, Optional
from cache.keys import PRESCAN_AUDIO_PREFIX, PRESCAN_VIDEO_PREFIX, CONFIRMED_METADATA_PREFIX
CACHE_DIR = Path("/app/cache")
CACHE_FILE = CACHE_DIR / "api_cache.db"
MAX_ENTRIES = 10000
DEFAULT_TTL = 86400 # 24 Stunden
def is_prescan_key(key: str) -> bool:
"""Prüfe ob Key ein Pre-Scan-Key ist."""
return key.startswith(PRESCAN_AUDIO_PREFIX) or key.startswith(PRESCAN_VIDEO_PREFIX)
def is_confirmed_key(key: str) -> bool:
"""Prüfe ob Key ein bestätigter Metadaten-Key ist."""
return key.startswith(CONFIRMED_METADATA_PREFIX)
def get_cache_category(key: str) -> str:
"""Ermittle Kategorie eines Cache-Keys."""
if is_prescan_key(key):
return "prescan"
elif is_confirmed_key(key):
return "confirmed"
elif key.startswith("tmdb:"):
return "tmdb"
elif key.startswith("musicbrainz:"):
return "musicbrainz"
return "other"
def init_cache() -> None:
"""Initialisiere Cache-DB."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(CACHE_FILE)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS api_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
ttl INTEGER NOT NULL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_key ON api_cache(key)")
conn.commit()
conn.close()
def _get_connection() -> sqlite3.Connection:
"""Hole DB-Verbindung mit LRU-Check."""
conn = sqlite3.connect(CACHE_FILE)
# LRU: entferne alte Einträge wenn MAX_ENTRIES überschritten
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM api_cache")
count = cursor.fetchone()[0]
if count >= MAX_ENTRIES:
cursor.execute("""
DELETE FROM api_cache
WHERE id IN (
SELECT id FROM api_cache
ORDER BY created_at ASC
LIMIT ?
)
""", (MAX_ENTRIES // 10,))
conn.commit()
return conn
def get(key: str) -> Optional[Dict[str, Any]]:
"""Hole Wert aus Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT value, created_at, ttl FROM api_cache WHERE key = ?",
(key,)
)
row = cursor.fetchone()
if row is None:
conn.close()
return None
value, created_at, ttl = row
if time.time() > created_at + ttl:
# TTL abgelaufen
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
conn.commit()
conn.close()
return None
conn.close()
return json.loads(value)
def set(key: str, value: Dict[str, Any], ttl: int = DEFAULT_TTL) -> None:
"""Speichere Wert im Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO api_cache (key, value, created_at, ttl)
VALUES (?, ?, ?, ?)
""", (key, json.dumps(value), int(time.time()), ttl))
conn.commit()
conn.close()
def delete(key: str) -> None:
"""Lösche Eintrag aus Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
conn.commit()
conn.close()
def clear() -> None:
"""Lösche gesamten Cache."""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM api_cache")
conn.commit()
conn.close()
+1 -1
View File
@@ -1,6 +1,6 @@
"""Zentrale Cache-Key-Definitionen für Rippy API.""" """Zentrale Cache-Key-Definitionen für Rippy API."""
from typing import Final from typing import Dict, Final, Optional
# Pre-Scan Keys # Pre-Scan Keys
PRESCAN_AUDIO_PREFIX: Final[str] = "prescan:audio:" PRESCAN_AUDIO_PREFIX: Final[str] = "prescan:audio:"
-1
View File
@@ -3,7 +3,6 @@
import requests import requests
from typing import Dict, List, Optional from typing import Dict, List, Optional
from config import settings
from cache import get, set from cache import get, set
+6 -2
View File
@@ -23,8 +23,12 @@ class Settings(BaseSettings):
# Logging # Logging
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level") log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
# JWT # JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr)
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (wird auto-generiert wenn nicht gesetzt)") 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: class Config:
env_file = ".env" env_file = ".env"
-1
View File
@@ -1,6 +1,5 @@
"""Validation für Rippy Configuration.""" """Validation für Rippy Configuration."""
from typing import Optional
from pydantic import ValidationError from pydantic import ValidationError
from config import Settings from config import Settings
+9
View File
@@ -0,0 +1,9 @@
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
-2
View File
@@ -3,11 +3,9 @@
import requests import requests
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
from urllib.parse import urlparse
from clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from config import settings
class ImageDownloader: class ImageDownloader:
+32 -21
View File
@@ -1,23 +1,33 @@
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional, Dict from typing import List, Optional, Dict
from datetime import datetime from pathlib import Path
import os
import subprocess import subprocess
import asyncio import asyncio
import json import json
import time
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from config import settings from config import settings
from config_validation import validate_config, ConfigValidationError from config_validation import validate_config, ConfigValidationError
from cache import init_cache, set from cache import init_cache, set as cache_set
from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted from auth import (
from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key 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 prescan import PreScan
from nfo_generator import NFOGenerator from nfo_generator import NFOGenerator
from image_downloader import ImageDownloader 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 from cache.keys import generate_confirmed_key
# In Cache speichern # In Cache speichern
cache_key = generate_confirmed_key(title, year) 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} return {"status": "confirmed", "key": cache_key}
@@ -271,7 +281,9 @@ async def jellyfin_format(request: JellyfinFormatRequest):
else: else:
# Audio-Formatierung # Audio-Formatierung
artist = request.metadata.get("artist", "Unknown Artist") 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
album_nfo = nfo_gen.generate_album_nfo( album_nfo = nfo_gen.generate_album_nfo(
@@ -305,8 +317,8 @@ class LoginRequest(BaseModel):
@app.post("/token") @app.post("/token")
async def login(request: LoginRequest): async def login(request: LoginRequest):
"""Login und Token generieren.""" """Login und Token generieren."""
# Einfache Auth für MVP (in Produktion mit Datenbank) # Einfache Auth für MVP (in Produktion mit Datenbank); Zugangsdaten aus .env
if request.username == "admin" and request.password == "rippy123": if request.username == settings.admin_username and request.password == settings.admin_password:
access_token = create_access_token( access_token = create_access_token(
data={"sub": request.username, "scopes": ["admin"]} data={"sub": request.username, "scopes": ["admin"]}
) )
@@ -340,7 +352,10 @@ async def invalidate_token(token: str):
if is_blacklisted(token): if is_blacklisted(token):
raise HTTPException(status_code=400, detail="Token bereits invalidiert") 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"} return {"status": "invalidated"}
@@ -349,17 +364,14 @@ class APIKeyCreateRequest(BaseModel):
name: str 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") @app.post("/api-keys")
async def create_api_key(request: APIKeyCreateRequest): async def create_api_key(request: APIKeyCreateRequest):
"""Erstelle API Key.""" """Erstelle API Key."""
# In Produktion mit Auth prüfen # In Produktion mit Auth prüfen
key_info = { return ratelimit_create_api_key(request.name)
"key": secrets.token_urlsafe(32),
"name": request.name,
"created_at": time.time(),
"rate_limit": 100
}
return key_info
@app.get("/api-keys") @app.get("/api-keys")
@@ -372,7 +384,6 @@ async def list_api_keys():
async def delete_api_key(key: str): async def delete_api_key(key: str):
"""Lösche API Key.""" """Lösche API Key."""
# In Produktion mit Auth prüfen # In Produktion mit Auth prüfen
if key in api_keys: if ratelimit_delete_api_key(key):
del api_keys[key]
return {"status": "deleted"} return {"status": "deleted"}
raise HTTPException(status_code=404, detail="API Key nicht gefunden") raise HTTPException(status_code=404, detail="API Key nicht gefunden")
+1 -1
View File
@@ -1,7 +1,7 @@
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema).""" """NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import List
from xml.dom.minidom import getDOMImplementation from xml.dom.minidom import getDOMImplementation
-300
View File
@@ -1,300 +0,0 @@
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping."""
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient
from clients.thetvdb import TheTVDBClient
from cache import get, set
from cache.keys import generate_prescan_key
from config import settings
class PreScanResult:
def __init__(
self,
disc_type: str,
title: str,
year: int = None,
confidence: float = 0.0,
metadata: Dict = None,
tracks: List[Dict] = None
):
self.disc_type = disc_type
self.title = title
self.year = year
self.confidence = confidence
self.metadata = metadata or {}
self.tracks = tracks or []
def to_dict(self) -> Dict:
return {
"disc_type": self.disc_type,
"title": self.title,
"year": self.year,
"confidence": self.confidence,
"metadata": self.metadata,
"tracks": self.tracks
}
class PreScan:
def __init__(self):
self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient()
def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch."""
# 1. Disc-Typ erkennen
disc_type = self._detect_disc_type(device_path)
# 2. TOC lesen
toc = self._read_toc(device_path, disc_type)
# 3. Metadaten lookup
if disc_type == "CD":
return self._scan_audio(device_path, toc)
else:
return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
try:
result = subprocess.run(
["isosize", "-x", device_path],
capture_output=True,
text=True,
timeout=5
)
# Ausgabe parsen
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024: # < 700MB
return "CD"
elif size < 15 * 1024 * 1024 * 1024: # < 15GB
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
# Fallback
return "DVD"
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
toc = {
"title": None,
"year": None,
"tracks": [],
"duration": 0
}
try:
if disc_type == "CD":
# cdparanoia für CD-TOC
result = subprocess.run(
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
)
# Parse cdparanoia output
for line in result.stdout.split('\n'):
if 'track' in line.lower():
# Track-Info parsen
parts = line.split()
if len(parts) >= 4:
toc["tracks"].append({
"track_number": parts[1],
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
"duration": 0
})
else:
# makemkvcon für DVD/Blu-ray (nur TOC, kein Ripping)
result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
capture_output=True,
text=True,
timeout=30
)
# Parse makemkvcon output
for line in result.stdout.split('\n'):
if line.startswith('DRV:'):
# Disc-Info
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
# Titel-Info
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD."""
# Cache-Key
cache_key = generate_prescan_key(device_path, is_audio=True)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
# Künstler und Album aus Titel extrahieren
artist = None
album = toc["title"] or "Unknown Album"
if " - " in album:
parts = album.split(" - ", 1)
if len(parts) == 2:
artist = parts[0]
album = parts[1]
# MusicBrainz Search
if artist:
artists = self.musicbrainz.search_artist(artist)
if artists:
# Best match
best_match = artists[0]
confidence = 0.8
# Album suchen
releases = self.musicbrainz.search_release(album, artist)
if releases:
confidence = 0.9
release_id = releases[0]["id"]
release_info = self.musicbrainz.get_release_info(release_id)
if release_info:
# Tracks aus Release Info
for medium in release_info.get("media", []):
for track in medium.get("tracks", []):
toc["tracks"].append({
"track_number": track.get("position", 0),
"title": track.get("title", "Unknown"),
"duration": track.get("duration", 0) // 1000
})
# TMDB Fallback für bekannte Soundtracks
if not artist:
movies = self.tmdb.search_movie(album)
if movies:
confidence = 0.7
movie = movies[0]
movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details:
# Soundtrack als "Soundtrack - [Filmname]"
artist = album
album = f"Soundtrack - {movie_details.get('title', album)}"
toc["title"] = album
# Cache result
result = PreScanResult(
disc_type="CD",
title=album,
tracks=toc["tracks"],
confidence=confidence if 'confidence' in dir() else 0.5
)
set(cache_key, result.to_dict())
return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray."""
# Cache-Key
cache_key = generate_prescan_key(device_path, is_audio=False)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
# Disc-Titel
title = toc["title"] or "Unknown Title"
# TMDB Search
confidence = 0.0
metadata = {}
matched = False
# Versuche Film-Suche
movies = self.tmdb.search_movie(title)
if movies:
for movie in movies:
# Prüfe auf gute Übereinstimmung
if movie.get("title", "").lower() == title.lower():
confidence = 0.95
movie_id = movie["id"]
movie_details = self.tmdb.get_movie_details(movie_id)
if movie_details:
metadata = {
"type": "movie",
"id": movie_id,
"title": movie_details.get("title", title),
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
"overview": movie_details.get("overview", ""),
"poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])]
}
matched = True
break
# Versuche Serien-Suche
if not matched:
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
for show in tv_shows:
if show.get("name", "").lower() == title.lower():
confidence = 0.9
tv_id = show["id"]
tv_details = self.tmdb.get_tv_details(tv_id)
if tv_details:
metadata = {
"type": "tv",
"id": tv_id,
"title": tv_details.get("name", title),
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
"overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])]
}
matched = True
break
# Fallback auf Titel ohne Lookup
if not matched:
confidence = 0.3
metadata = {
"type": "unknown",
"title": title,
"year": None
}
# Cache result
result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"),
title=title,
year=metadata.get("year"),
confidence=confidence,
metadata=metadata,
tracks=toc.get("tracks", [])
)
set(cache_key, result.to_dict())
return result
+236 -20
View File
@@ -1,22 +1,40 @@
"""Pre-Scan module.""" """Pre-Scan-Modul: Liest Disc-TOC ohne Ripping und schlägt Metadaten vor.
from typing import Dict, Optional WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementierung
from dataclasses import dataclass, field (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: class PreScanResult:
"""Result of a pre-scan.""" def __init__(
self,
title: str disc_type: str = "DVD",
year: Optional[int] = None title: str = "Unknown",
confidence: float = 0.0 year: int = None,
metadata: Dict = field(default_factory=dict) confidence: float = 0.0,
tracks: list = field(default_factory=list) 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: def to_dict(self) -> Dict:
"""Convert to dictionary."""
return { return {
"disc_type": self.disc_type,
"title": self.title, "title": self.title,
"year": self.year, "year": self.year,
"confidence": self.confidence, "confidence": self.confidence,
@@ -26,14 +44,212 @@ class PreScanResult:
class PreScan: 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: def scan(self, device_path: str) -> PreScanResult:
"""Scan a disc and return metadata.""" """Führe Pre-Scan durch."""
return PreScanResult( disc_type = self._detect_disc_type(device_path)
title="Unknown Disc", toc = self._read_toc(device_path, disc_type)
year=None,
confidence=0.0, if disc_type == "CD":
metadata={}, return self._scan_audio(device_path, toc)
tracks=[] return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe."""
try:
result = subprocess.run(
["isosize", "-x", device_path],
capture_output=True,
text=True,
timeout=5
) )
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024:
return "CD"
elif size < 15 * 1024 * 1024 * 1024:
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
return "DVD"
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
toc = {
"title": None,
"year": None,
"tracks": [],
"duration": 0
}
try:
if disc_type == "CD":
result = subprocess.run(
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
)
for line in result.stdout.split('\n'):
if 'track' in line.lower():
parts = line.split()
if len(parts) >= 4:
toc["tracks"].append({
"track_number": parts[1],
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
"duration": 0
})
else:
# makemkvcon liest nur den TOC — KEIN Ripping
result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
capture_output=True,
text=True,
timeout=30
)
for line in result.stdout.split('\n'):
if line.startswith('DRV:'):
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD."""
cache_key = generate_prescan_key(device_path, is_audio=True)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
confidence = 0.5
artist = None
album = toc["title"] or "Unknown Album"
if " - " in album:
parts = album.split(" - ", 1)
if len(parts) == 2:
artist = parts[0]
album = parts[1]
if artist:
artists = self.musicbrainz.search_artist(artist)
if artists:
confidence = 0.8
releases = self.musicbrainz.search_release(album, artist)
if releases:
confidence = 0.9
release_id = releases[0]["id"]
release_info = self.musicbrainz.get_release_info(release_id)
if release_info:
for medium in release_info.get("media", []):
for track in medium.get("tracks", []):
toc["tracks"].append({
"track_number": track.get("position", 0),
"title": track.get("title", "Unknown"),
"duration": track.get("duration", 0) // 1000
})
if not artist:
movies = self.tmdb.search_movie(album)
if movies:
confidence = 0.7
movie_details = self.tmdb.get_movie_details(movies[0]["id"])
if movie_details:
album = f"Soundtrack - {movie_details.get('title', album)}"
toc["title"] = album
result = PreScanResult(
disc_type="CD",
title=album,
tracks=toc["tracks"],
confidence=confidence
)
cache_set(cache_key, result.to_dict())
return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray."""
cache_key = generate_prescan_key(device_path, is_audio=False)
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
title = toc["title"] or "Unknown Title"
confidence = 0.0
metadata = {}
matched = False
movies = self.tmdb.search_movie(title)
if movies:
for movie in movies:
if movie.get("title", "").lower() == title.lower():
confidence = 0.95
movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details:
metadata = {
"type": "movie",
"id": movie["id"],
"title": movie_details.get("title", title),
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
"overview": movie_details.get("overview", ""),
"poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])]
}
matched = True
break
if not matched:
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
for show in tv_shows:
if show.get("name", "").lower() == title.lower():
confidence = 0.9
tv_details = self.tmdb.get_tv_details(show["id"])
if tv_details:
metadata = {
"type": "tv",
"id": show["id"],
"title": tv_details.get("name", title),
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
"overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])]
}
matched = True
break
if not matched:
confidence = 0.3
metadata = {
"type": "unknown",
"title": title,
"year": None
}
result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"),
title=title,
year=metadata.get("year"),
confidence=confidence,
metadata=metadata,
tracks=toc.get("tracks", [])
)
cache_set(cache_key, result.to_dict())
return result
-2
View File
@@ -5,8 +5,6 @@ import secrets
from collections import defaultdict from collections import defaultdict
from typing import Dict, Optional from typing import Dict, Optional
from config import settings
# Default Rate Limit # Default Rate Limit
MAX_REQUESTS_PER_MINUTE = 100 MAX_REQUESTS_PER_MINUTE = 100
+69
View File
@@ -0,0 +1,69 @@
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
import time
from auth import (
add_to_blacklist,
cleanup_blacklist,
create_access_token,
create_refresh_token,
decode_token,
get_password_hash,
is_access_token,
is_blacklisted,
is_refresh_token,
token_blacklist,
verify_password,
)
def test_passwort_hash_roundtrip():
hashed = get_password_hash("geheim123")
assert hashed != "geheim123"
assert verify_password("geheim123", hashed) is True
assert verify_password("falsch", hashed) is False
def test_access_token_roundtrip():
token = create_access_token({"sub": "commander"})
payload = decode_token(token)
assert payload is not None
assert payload["sub"] == "commander"
assert payload["type"] == "access"
assert is_access_token(token) is True
assert is_refresh_token(token) is False
def test_refresh_token_roundtrip():
token = create_refresh_token({"sub": "commander"})
payload = decode_token(token)
assert payload is not None
assert payload["type"] == "refresh"
assert is_refresh_token(token) is True
assert is_access_token(token) is False
def test_muell_token_gibt_none_und_false():
assert decode_token("kein.echter.token") is None
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
assert is_access_token("kein.echter.token") is False
assert is_refresh_token("kein.echter.token") is False
def test_blacklist_logout_wirkt():
token = create_access_token({"sub": "commander"})
assert is_blacklisted(token) is False
add_to_blacklist(token)
assert is_blacklisted(token) is True
def test_cleanup_entfernt_nur_abgelaufene():
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
frisch = create_access_token({"sub": "commander"})
add_to_blacklist(frisch)
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
cleanup_blacklist()
assert "laengst-abgelaufener-token" not in token_blacklist
assert is_blacklisted(frisch) is True
+31
View File
@@ -0,0 +1,31 @@
"""Tests für cache/keys.py: deterministische Key-Erzeugung."""
from cache.keys import (
PRESCAN_AUDIO_PREFIX,
PRESCAN_VIDEO_PREFIX,
generate_confirmed_key,
generate_prescan_key,
generate_tmdb_key,
)
def test_prescan_key_audio_vs_video():
audio = generate_prescan_key("/dev/sr0", is_audio=True)
video = generate_prescan_key("/dev/sr0", is_audio=False)
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
assert video.startswith(PRESCAN_VIDEO_PREFIX)
assert audio != video
assert audio.endswith("/dev/sr0")
def test_confirmed_key_mit_und_ohne_jahr():
assert generate_confirmed_key("Blade Runner", 1982) == "confirmed:Blade Runner:1982"
assert generate_confirmed_key("Blade Runner") == "confirmed:Blade Runner:0"
def test_tmdb_key_mit_params_unterscheidet():
ohne = generate_tmdb_key("search/movie")
mit = generate_tmdb_key("search/movie", {"query": "Dune"})
assert ohne != mit
assert ohne.startswith("tmdb:")
assert "search/movie" in mit
-6
View File
@@ -1,6 +0,0 @@
import http.client
conn = http.client.HTTPConnection("localhost", 8000)
conn.request("GET", "/health")
res = conn.getresponse()
print(res.read().decode())
conn.close()
+6
View File
@@ -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__))
+144 -148
View File
@@ -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 os
import subprocess
import re import re
import shutil import shutil
import subprocess
import tempfile
from celery_app import celery_app from celery_app import celery_app
HANDBRAKE_PRESET = "Fast 1080p30"
def check_handbrake_installed() -> bool: def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist.""" """Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") is not None 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: def check_abcde_installed() -> bool:
"""Prüft, ob abcde installiert ist.""" """Prüft, ob abcde installiert ist."""
return shutil.which("abcde") is not None return shutil.which("abcde") is not None
@@ -113,47 +34,147 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None 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") @celery_app.task(bind=True, name="worker.ripping.rip_dvd")
def rip_dvd(self, device_path: str, disc_id: str) -> dict: def rip_dvd(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine DVD mit HandBrake.""" """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}" output_dir = f"/output/dvd/{disc_id}"
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
output_path = f"{output_dir}/dvd_{disc_id}.mkv" output_path = f"{output_dir}/dvd_{disc_id}.mkv"
melde = _progress_melder(self)
return run_handbrake(device_path, output_path) return run_handbrake(device_path, output_path, progress_cb=melde)
@celery_app.task(bind=True, name="worker.ripping.rip_bluray") @celery_app.task(bind=True, name="worker.ripping.rip_bluray")
def rip_bluray(self, device_path: str, disc_id: str) -> dict: def rip_bluray(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine Blu-ray mit HandBrake.""" """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}" output_dir = f"/output/bluray/{disc_id}"
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
output_path = f"{output_dir}/bluray_{disc_id}.mkv" output_path = f"{output_dir}/bluray_{disc_id}.mkv"
melde = _progress_melder(self)
return run_handbrake(device_path, output_path) return run_handbrake(device_path, output_path, progress_cb=melde)
@celery_app.task(bind=True, name="worker.ripping.rip_cd") @celery_app.task(bind=True, name="worker.ripping.rip_cd")
def rip_cd(self, device_path: str, disc_id: str) -> dict: 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(): if not check_abcde_installed():
return { return {
"status": "error", "status": "error",
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping." "error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
} }
if not check_cdparanoia_installed(): if not check_cdparanoia_installed():
return { return {
"status": "error", "status": "error",
@@ -162,21 +183,12 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
output_dir = f"/output/cd/{disc_id}" output_dir = f"/output/cd/{disc_id}"
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
melde = _progress_melder(self)
config_path = write_abcde_config(output_dir)
try: try:
cmd = [
"abcde",
"-d", device_path,
"-o", "flac",
"-b",
"-t", "1",
"-a", "default",
"-x",
"-o", output_dir
]
process = subprocess.Popen( process = subprocess.Popen(
cmd, build_abcde_cmd(device_path, config_path),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
@@ -184,42 +196,26 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
) )
for line in process.stdout: for line in process.stdout:
self.send_task( melde(50, line.strip()[:200])
"worker.ripping.update_progress",
kwargs={
"progress": 50,
"status": "ripping",
"message": f"Rippe CD: {line.strip()}"
}
)
process.wait() process.wait()
if process.returncode == 0: if process.returncode == 0:
# MusikBrainz Lookup (via abcde's post-read) melde(90, "MusicBrainz Lookup abgeschlossen")
self.send_task(
"worker.ripping.update_progress",
kwargs={
"progress": 90,
"status": "ripping",
"message": "MusikBrainz Lookup läuft..."
}
)
return { return {
"status": "success", "status": "success",
"output_dir": output_dir, "output_dir": output_dir,
"return_code": process.returncode "return_code": process.returncode
} }
else:
return { return {
"status": "error", "status": "error",
"error": f"abcde failed with code {process.returncode}", "error": f"abcde failed with code {process.returncode}",
"return_code": process.returncode "return_code": process.returncode
} }
except Exception as e: except Exception as e:
return { return {"status": "error", "error": str(e)}
"status": "error", finally:
"error": str(e) try:
} os.unlink(config_path)
except OSError:
pass
-1
View File
@@ -1,4 +1,3 @@
import os
from celery_app import celery_app from celery_app import celery_app
from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type
+79
View File
@@ -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"
-2
View File
@@ -9,8 +9,6 @@ import sys
import subprocess import subprocess
import redis import redis
import json import json
from pathlib import Path
from time import sleep
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
DISC_DEVICE_PATH = "/dev/disc" DISC_DEVICE_PATH = "/dev/disc"