- Metadaten-Preview WIEDERHERGESTELLT (Stub ueberschattete echte prescan-Implementierung), tote Altmodule geloescht - Celery update_state statt Phantom-Task/erfundener API - abcde-Kommando korrigiert (CD-Ripping war nie funktionsfaehig) - JWT: fester Schluessel Pflicht, echtes Logout, Cleanup nur Abgelaufene - main.py: crashende Endpoints (Path/secrets/api_keys), year-Bug, Admin-Login aus .env - Ruff gruen (29 Funde), Tests: auth/cache_keys/ripping_helpers, Placebo-test_health raus - SAVEPOINT: offene MakeMKV-Entscheidung SICHTBAR gemacht (Regel B)
This commit is contained in:
@@ -68,3 +68,37 @@ rippy-ui-modern - UI Modernisiert & Import-Fixes
|
|||||||
bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen
|
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
@@ -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]
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
"""SQLite Cache für API-Rate-Limits (LRU, 10k Einträge, TTL)."""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from cache.keys import PRESCAN_AUDIO_PREFIX, PRESCAN_VIDEO_PREFIX, CONFIRMED_METADATA_PREFIX
|
|
||||||
|
|
||||||
CACHE_DIR = Path("/app/cache")
|
|
||||||
CACHE_FILE = CACHE_DIR / "api_cache.db"
|
|
||||||
MAX_ENTRIES = 10000
|
|
||||||
DEFAULT_TTL = 86400 # 24 Stunden
|
|
||||||
|
|
||||||
|
|
||||||
def is_prescan_key(key: str) -> bool:
|
|
||||||
"""Prüfe ob Key ein Pre-Scan-Key ist."""
|
|
||||||
return key.startswith(PRESCAN_AUDIO_PREFIX) or key.startswith(PRESCAN_VIDEO_PREFIX)
|
|
||||||
|
|
||||||
|
|
||||||
def is_confirmed_key(key: str) -> bool:
|
|
||||||
"""Prüfe ob Key ein bestätigter Metadaten-Key ist."""
|
|
||||||
return key.startswith(CONFIRMED_METADATA_PREFIX)
|
|
||||||
|
|
||||||
|
|
||||||
def get_cache_category(key: str) -> str:
|
|
||||||
"""Ermittle Kategorie eines Cache-Keys."""
|
|
||||||
if is_prescan_key(key):
|
|
||||||
return "prescan"
|
|
||||||
elif is_confirmed_key(key):
|
|
||||||
return "confirmed"
|
|
||||||
elif key.startswith("tmdb:"):
|
|
||||||
return "tmdb"
|
|
||||||
elif key.startswith("musicbrainz:"):
|
|
||||||
return "musicbrainz"
|
|
||||||
return "other"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def init_cache() -> None:
|
|
||||||
"""Initialisiere Cache-DB."""
|
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(CACHE_FILE)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS api_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT UNIQUE NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
ttl INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_key ON api_cache(key)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_connection() -> sqlite3.Connection:
|
|
||||||
"""Hole DB-Verbindung mit LRU-Check."""
|
|
||||||
conn = sqlite3.connect(CACHE_FILE)
|
|
||||||
|
|
||||||
# LRU: entferne alte Einträge wenn MAX_ENTRIES überschritten
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM api_cache")
|
|
||||||
count = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
if count >= MAX_ENTRIES:
|
|
||||||
cursor.execute("""
|
|
||||||
DELETE FROM api_cache
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT id FROM api_cache
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
|
||||||
""", (MAX_ENTRIES // 10,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def get(key: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Hole Wert aus Cache."""
|
|
||||||
conn = _get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT value, created_at, ttl FROM api_cache WHERE key = ?",
|
|
||||||
(key,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
conn.close()
|
|
||||||
return None
|
|
||||||
|
|
||||||
value, created_at, ttl = row
|
|
||||||
if time.time() > created_at + ttl:
|
|
||||||
# TTL abgelaufen
|
|
||||||
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
return None
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return json.loads(value)
|
|
||||||
|
|
||||||
|
|
||||||
def set(key: str, value: Dict[str, Any], ttl: int = DEFAULT_TTL) -> None:
|
|
||||||
"""Speichere Wert im Cache."""
|
|
||||||
conn = _get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT OR REPLACE INTO api_cache (key, value, created_at, ttl)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""", (key, json.dumps(value), int(time.time()), ttl))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def delete(key: str) -> None:
|
|
||||||
"""Lösche Eintrag aus Cache."""
|
|
||||||
conn = _get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def clear() -> None:
|
|
||||||
"""Lösche gesamten Cache."""
|
|
||||||
conn = _get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("DELETE FROM api_cache")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"""Zentrale Cache-Key-Definitionen für Rippy API."""
|
"""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:"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
|
||||||
|
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
import requests
|
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:
|
||||||
|
|||||||
+34
-23
@@ -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,8 +281,10 @@ 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(
|
||||||
title=album,
|
title=album,
|
||||||
@@ -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"]}
|
||||||
)
|
)
|
||||||
@@ -339,8 +351,11 @@ async def invalidate_token(token: str):
|
|||||||
"""Invalidate Token (Logout)."""
|
"""Invalidate Token (Logout)."""
|
||||||
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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping."""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from clients.tmdb import TMDBClient
|
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
|
||||||
from clients.thetvdb import TheTVDBClient
|
|
||||||
from cache import get, set
|
|
||||||
from cache.keys import generate_prescan_key
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
|
|
||||||
class PreScanResult:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
disc_type: str,
|
|
||||||
title: str,
|
|
||||||
year: int = None,
|
|
||||||
confidence: float = 0.0,
|
|
||||||
metadata: Dict = None,
|
|
||||||
tracks: List[Dict] = None
|
|
||||||
):
|
|
||||||
self.disc_type = disc_type
|
|
||||||
self.title = title
|
|
||||||
self.year = year
|
|
||||||
self.confidence = confidence
|
|
||||||
self.metadata = metadata or {}
|
|
||||||
self.tracks = tracks or []
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
return {
|
|
||||||
"disc_type": self.disc_type,
|
|
||||||
"title": self.title,
|
|
||||||
"year": self.year,
|
|
||||||
"confidence": self.confidence,
|
|
||||||
"metadata": self.metadata,
|
|
||||||
"tracks": self.tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PreScan:
|
|
||||||
def __init__(self):
|
|
||||||
self.tmdb = TMDBClient()
|
|
||||||
self.musicbrainz = MusicBrainzClient()
|
|
||||||
self.thetvdb = TheTVDBClient()
|
|
||||||
|
|
||||||
def scan(self, device_path: str) -> PreScanResult:
|
|
||||||
"""Führe Pre-Scan durch."""
|
|
||||||
# 1. Disc-Typ erkennen
|
|
||||||
disc_type = self._detect_disc_type(device_path)
|
|
||||||
|
|
||||||
# 2. TOC lesen
|
|
||||||
toc = self._read_toc(device_path, disc_type)
|
|
||||||
|
|
||||||
# 3. Metadaten lookup
|
|
||||||
if disc_type == "CD":
|
|
||||||
return self._scan_audio(device_path, toc)
|
|
||||||
else:
|
|
||||||
return self._scan_video(device_path, toc)
|
|
||||||
|
|
||||||
def _detect_disc_type(self, device_path: str) -> str:
|
|
||||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["isosize", "-x", device_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ausgabe parsen
|
|
||||||
if result.returncode == 0:
|
|
||||||
size = int(result.stdout.strip())
|
|
||||||
if size < 700 * 1024 * 1024: # < 700MB
|
|
||||||
return "CD"
|
|
||||||
elif size < 15 * 1024 * 1024 * 1024: # < 15GB
|
|
||||||
return "DVD"
|
|
||||||
else:
|
|
||||||
return "Blu-ray"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback
|
|
||||||
return "DVD"
|
|
||||||
|
|
||||||
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
|
||||||
"""Lese TOC (Table of Contents)."""
|
|
||||||
toc = {
|
|
||||||
"title": None,
|
|
||||||
"year": None,
|
|
||||||
"tracks": [],
|
|
||||||
"duration": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
if disc_type == "CD":
|
|
||||||
# cdparanoia für CD-TOC
|
|
||||||
result = subprocess.run(
|
|
||||||
["cdparanoia", "-Q", device_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse cdparanoia output
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if 'track' in line.lower():
|
|
||||||
# Track-Info parsen
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 4:
|
|
||||||
toc["tracks"].append({
|
|
||||||
"track_number": parts[1],
|
|
||||||
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
|
|
||||||
"duration": 0
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# makemkvcon für DVD/Blu-ray (nur TOC, kein Ripping)
|
|
||||||
result = subprocess.run(
|
|
||||||
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse makemkvcon output
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('DRV:'):
|
|
||||||
# Disc-Info
|
|
||||||
parts = line.split(',')
|
|
||||||
if len(parts) >= 4:
|
|
||||||
toc["title"] = parts[3].strip().strip('"')
|
|
||||||
elif line.startswith('TINFO:'):
|
|
||||||
# Titel-Info
|
|
||||||
parts = line.split(',')
|
|
||||||
if len(parts) >= 5:
|
|
||||||
toc["tracks"].append({
|
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
|
||||||
|
|
||||||
return toc
|
|
||||||
|
|
||||||
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
|
||||||
"""Pre-Scan für Audio-CD."""
|
|
||||||
# Cache-Key
|
|
||||||
cache_key = generate_prescan_key(device_path, is_audio=True)
|
|
||||||
cached = get(cache_key)
|
|
||||||
if cached:
|
|
||||||
return PreScanResult(**cached)
|
|
||||||
|
|
||||||
# Künstler und Album aus Titel extrahieren
|
|
||||||
artist = None
|
|
||||||
album = toc["title"] or "Unknown Album"
|
|
||||||
|
|
||||||
if " - " in album:
|
|
||||||
parts = album.split(" - ", 1)
|
|
||||||
if len(parts) == 2:
|
|
||||||
artist = parts[0]
|
|
||||||
album = parts[1]
|
|
||||||
|
|
||||||
# MusicBrainz Search
|
|
||||||
if artist:
|
|
||||||
artists = self.musicbrainz.search_artist(artist)
|
|
||||||
if artists:
|
|
||||||
# Best match
|
|
||||||
best_match = artists[0]
|
|
||||||
confidence = 0.8
|
|
||||||
|
|
||||||
# Album suchen
|
|
||||||
releases = self.musicbrainz.search_release(album, artist)
|
|
||||||
if releases:
|
|
||||||
confidence = 0.9
|
|
||||||
release_id = releases[0]["id"]
|
|
||||||
release_info = self.musicbrainz.get_release_info(release_id)
|
|
||||||
|
|
||||||
if release_info:
|
|
||||||
# Tracks aus Release Info
|
|
||||||
for medium in release_info.get("media", []):
|
|
||||||
for track in medium.get("tracks", []):
|
|
||||||
toc["tracks"].append({
|
|
||||||
"track_number": track.get("position", 0),
|
|
||||||
"title": track.get("title", "Unknown"),
|
|
||||||
"duration": track.get("duration", 0) // 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
# TMDB Fallback für bekannte Soundtracks
|
|
||||||
if not artist:
|
|
||||||
movies = self.tmdb.search_movie(album)
|
|
||||||
if movies:
|
|
||||||
confidence = 0.7
|
|
||||||
movie = movies[0]
|
|
||||||
movie_details = self.tmdb.get_movie_details(movie["id"])
|
|
||||||
|
|
||||||
if movie_details:
|
|
||||||
# Soundtrack als "Soundtrack - [Filmname]"
|
|
||||||
artist = album
|
|
||||||
album = f"Soundtrack - {movie_details.get('title', album)}"
|
|
||||||
toc["title"] = album
|
|
||||||
|
|
||||||
# Cache result
|
|
||||||
result = PreScanResult(
|
|
||||||
disc_type="CD",
|
|
||||||
title=album,
|
|
||||||
tracks=toc["tracks"],
|
|
||||||
confidence=confidence if 'confidence' in dir() else 0.5
|
|
||||||
)
|
|
||||||
|
|
||||||
set(cache_key, result.to_dict())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
|
||||||
"""Pre-Scan für DVD/Blu-ray."""
|
|
||||||
# Cache-Key
|
|
||||||
cache_key = generate_prescan_key(device_path, is_audio=False)
|
|
||||||
cached = get(cache_key)
|
|
||||||
if cached:
|
|
||||||
return PreScanResult(**cached)
|
|
||||||
|
|
||||||
# Disc-Titel
|
|
||||||
title = toc["title"] or "Unknown Title"
|
|
||||||
|
|
||||||
# TMDB Search
|
|
||||||
confidence = 0.0
|
|
||||||
metadata = {}
|
|
||||||
matched = False
|
|
||||||
|
|
||||||
# Versuche Film-Suche
|
|
||||||
movies = self.tmdb.search_movie(title)
|
|
||||||
if movies:
|
|
||||||
for movie in movies:
|
|
||||||
# Prüfe auf gute Übereinstimmung
|
|
||||||
if movie.get("title", "").lower() == title.lower():
|
|
||||||
confidence = 0.95
|
|
||||||
movie_id = movie["id"]
|
|
||||||
movie_details = self.tmdb.get_movie_details(movie_id)
|
|
||||||
|
|
||||||
if movie_details:
|
|
||||||
metadata = {
|
|
||||||
"type": "movie",
|
|
||||||
"id": movie_id,
|
|
||||||
"title": movie_details.get("title", title),
|
|
||||||
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
|
|
||||||
"overview": movie_details.get("overview", ""),
|
|
||||||
"poster_path": movie_details.get("poster_path", ""),
|
|
||||||
"backdrop_path": movie_details.get("backdrop_path", ""),
|
|
||||||
"runtime": movie_details.get("runtime", 0),
|
|
||||||
"genres": [g["name"] for g in movie_details.get("genres", [])]
|
|
||||||
}
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Versuche Serien-Suche
|
|
||||||
if not matched:
|
|
||||||
tv_shows = self.tmdb.search_tv(title)
|
|
||||||
if tv_shows:
|
|
||||||
for show in tv_shows:
|
|
||||||
if show.get("name", "").lower() == title.lower():
|
|
||||||
confidence = 0.9
|
|
||||||
tv_id = show["id"]
|
|
||||||
tv_details = self.tmdb.get_tv_details(tv_id)
|
|
||||||
|
|
||||||
if tv_details:
|
|
||||||
metadata = {
|
|
||||||
"type": "tv",
|
|
||||||
"id": tv_id,
|
|
||||||
"title": tv_details.get("name", title),
|
|
||||||
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
|
|
||||||
"overview": tv_details.get("overview", ""),
|
|
||||||
"poster_path": tv_details.get("poster_path", ""),
|
|
||||||
"backdrop_path": tv_details.get("backdrop_path", ""),
|
|
||||||
"genres": [g["name"] for g in tv_details.get("genres", [])]
|
|
||||||
}
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback auf Titel ohne Lookup
|
|
||||||
if not matched:
|
|
||||||
confidence = 0.3
|
|
||||||
metadata = {
|
|
||||||
"type": "unknown",
|
|
||||||
"title": title,
|
|
||||||
"year": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache result
|
|
||||||
result = PreScanResult(
|
|
||||||
disc_type=toc.get("disc_type", "DVD"),
|
|
||||||
title=title,
|
|
||||||
year=metadata.get("year"),
|
|
||||||
confidence=confidence,
|
|
||||||
metadata=metadata,
|
|
||||||
tracks=toc.get("tracks", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
set(cache_key, result.to_dict())
|
|
||||||
return result
|
|
||||||
+238
-22
@@ -1,22 +1,40 @@
|
|||||||
"""Pre-Scan module."""
|
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping und schlägt Metadaten vor.
|
||||||
|
|
||||||
from typing import Dict, Optional
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from auth import (
|
||||||
|
add_to_blacklist,
|
||||||
|
cleanup_blacklist,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
get_password_hash,
|
||||||
|
is_access_token,
|
||||||
|
is_blacklisted,
|
||||||
|
is_refresh_token,
|
||||||
|
token_blacklist,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_passwort_hash_roundtrip():
|
||||||
|
hashed = get_password_hash("geheim123")
|
||||||
|
assert hashed != "geheim123"
|
||||||
|
assert verify_password("geheim123", hashed) is True
|
||||||
|
assert verify_password("falsch", hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_roundtrip():
|
||||||
|
token = create_access_token({"sub": "commander"})
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert payload is not None
|
||||||
|
assert payload["sub"] == "commander"
|
||||||
|
assert payload["type"] == "access"
|
||||||
|
assert is_access_token(token) is True
|
||||||
|
assert is_refresh_token(token) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_token_roundtrip():
|
||||||
|
token = create_refresh_token({"sub": "commander"})
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert payload is not None
|
||||||
|
assert payload["type"] == "refresh"
|
||||||
|
assert is_refresh_token(token) is True
|
||||||
|
assert is_access_token(token) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_muell_token_gibt_none_und_false():
|
||||||
|
assert decode_token("kein.echter.token") is None
|
||||||
|
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
|
||||||
|
assert is_access_token("kein.echter.token") is False
|
||||||
|
assert is_refresh_token("kein.echter.token") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_blacklist_logout_wirkt():
|
||||||
|
token = create_access_token({"sub": "commander"})
|
||||||
|
assert is_blacklisted(token) is False
|
||||||
|
add_to_blacklist(token)
|
||||||
|
assert is_blacklisted(token) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_entfernt_nur_abgelaufene():
|
||||||
|
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
|
||||||
|
frisch = create_access_token({"sub": "commander"})
|
||||||
|
add_to_blacklist(frisch)
|
||||||
|
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
|
||||||
|
|
||||||
|
cleanup_blacklist()
|
||||||
|
|
||||||
|
assert "laengst-abgelaufener-token" not in token_blacklist
|
||||||
|
assert is_blacklisted(frisch) is True
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Tests für cache/keys.py: deterministische Key-Erzeugung."""
|
||||||
|
|
||||||
|
from cache.keys import (
|
||||||
|
PRESCAN_AUDIO_PREFIX,
|
||||||
|
PRESCAN_VIDEO_PREFIX,
|
||||||
|
generate_confirmed_key,
|
||||||
|
generate_prescan_key,
|
||||||
|
generate_tmdb_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescan_key_audio_vs_video():
|
||||||
|
audio = generate_prescan_key("/dev/sr0", is_audio=True)
|
||||||
|
video = generate_prescan_key("/dev/sr0", is_audio=False)
|
||||||
|
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
||||||
|
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
||||||
|
assert audio != video
|
||||||
|
assert audio.endswith("/dev/sr0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_key_mit_und_ohne_jahr():
|
||||||
|
assert generate_confirmed_key("Blade Runner", 1982) == "confirmed:Blade Runner:1982"
|
||||||
|
assert generate_confirmed_key("Blade Runner") == "confirmed:Blade Runner:0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_key_mit_params_unterscheidet():
|
||||||
|
ohne = generate_tmdb_key("search/movie")
|
||||||
|
mit = generate_tmdb_key("search/movie", {"query": "Dune"})
|
||||||
|
assert ohne != mit
|
||||||
|
assert ohne.startswith("tmdb:")
|
||||||
|
assert "search/movie" in mit
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import http.client
|
|
||||||
conn = http.client.HTTPConnection("localhost", 8000)
|
|
||||||
conn.request("GET", "/health")
|
|
||||||
res = conn.getresponse()
|
|
||||||
print(res.read().decode())
|
|
||||||
conn.close()
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Pytest-Setup für Worker-Tests: flache Modul-Imports."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
+180
-184
@@ -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,113 +34,188 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.ripping.rip_dvd")
|
def get_progress_from_line(line: str) -> int:
|
||||||
def rip_dvd(self, device_path: str, disc_id: str) -> dict:
|
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe."""
|
||||||
"""Rippt eine DVD mit HandBrake."""
|
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():
|
if not check_handbrake_installed():
|
||||||
return {
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
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_handbrake_cmd(device_path, output_path),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
self.send_task(
|
progress = get_progress_from_line(line)
|
||||||
"worker.ripping.update_progress",
|
if progress > 0 and progress_cb:
|
||||||
kwargs={
|
progress_cb(progress)
|
||||||
"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)
|
return {
|
||||||
self.send_task(
|
"status": "success",
|
||||||
"worker.ripping.update_progress",
|
"output_path": output_path,
|
||||||
kwargs={
|
"return_code": process.returncode
|
||||||
"progress": 90,
|
}
|
||||||
"status": "ripping",
|
return {
|
||||||
"message": "MusikBrainz Lookup läuft..."
|
"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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"return_code": process.returncode
|
"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 {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": str(e)
|
"error": f"abcde failed with code {process.returncode}",
|
||||||
|
"return_code": process.returncode
|
||||||
}
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(config_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
from celery_app import celery_app
|
from 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests für ripping.py: Fortschritts-Parsing, Kommando-Bau, Disc-Erkennung.
|
||||||
|
|
||||||
|
Deckt genau die Stellen ab, an denen im Review 22.07. erfundene Schnittstellen
|
||||||
|
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import ripping
|
||||||
|
from ripping import (
|
||||||
|
build_abcde_cmd,
|
||||||
|
build_handbrake_cmd,
|
||||||
|
detect_disc_type,
|
||||||
|
get_progress_from_line,
|
||||||
|
write_abcde_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_parsing():
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||||
|
assert get_progress_from_line("kein Fortschritt hier") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||||
|
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
||||||
|
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
||||||
|
cmd = build_abcde_cmd("/dev/sr0", "/tmp/test.abcde.conf")
|
||||||
|
assert cmd.count("-o") == 1
|
||||||
|
assert cmd[cmd.index("-o") + 1] == "flac"
|
||||||
|
assert "-c" in cmd
|
||||||
|
assert cmd[cmd.index("-c") + 1] == "/tmp/test.abcde.conf"
|
||||||
|
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||||
|
pfad = write_abcde_config("/output/cd/test123")
|
||||||
|
try:
|
||||||
|
with open(pfad, encoding="utf-8") as f:
|
||||||
|
inhalt = f.read()
|
||||||
|
assert "OUTPUTDIR='/output/cd/test123'" in inhalt
|
||||||
|
assert "INTERACTIVE=n" in inhalt
|
||||||
|
finally:
|
||||||
|
os.unlink(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_cmd_vollstaendig():
|
||||||
|
cmd = build_handbrake_cmd("/dev/sr0", "/output/dvd/x/film.mkv")
|
||||||
|
assert cmd[0] == "HandBrakeCLI"
|
||||||
|
assert cmd[cmd.index("--input") + 1] == "/dev/sr0"
|
||||||
|
assert cmd[cmd.index("--output") + 1] == "/output/dvd/x/film.mkv"
|
||||||
|
assert "--preset" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, stdout):
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_disc_typ_erkennung(monkeypatch):
|
||||||
|
faelle = [
|
||||||
|
("UDF filesystem data 'MEIN_FILM' DVD Video", "dvd"),
|
||||||
|
("data, BDMV bluray structure", "bluray"),
|
||||||
|
("Audio CD, cda tracks", "cd"),
|
||||||
|
("irgendwas anderes", "unknown"),
|
||||||
|
]
|
||||||
|
for ausgabe, erwartet in faelle:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ripping.subprocess, "run", lambda *a, _out=ausgabe, **k: _FakeResult(_out)
|
||||||
|
)
|
||||||
|
assert detect_disc_type("/dev/sr0") == erwartet
|
||||||
|
|
||||||
|
|
||||||
|
def test_disc_typ_erkennung_fehler_gibt_unknown(monkeypatch):
|
||||||
|
def kaputt(*a, **k):
|
||||||
|
raise OSError("kein Geraet")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ripping.subprocess, "run", kaputt)
|
||||||
|
assert detect_disc_type("/dev/sr0") == "unknown"
|
||||||
@@ -9,8 +9,6 @@ import sys
|
|||||||
import subprocess
|
import 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user