diff --git a/.env.example b/.env.example index 6bab77c..0d4a1b2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,22 @@ -POSTGRES_PASSWORD=rippy123 -SECRET_KEY=change-me-in-production +# Rippy-Umgebung — nach .env kopieren und Werte eintragen. +# Die .env liegt NUR auf der VM (gitignored), nie im Repo. + +# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt) +POSTGRES_PASSWORD=rippy + +# JWT-Signierschlüssel — PFLICHT, API startet sonst nicht. +# Erzeugen: openssl rand -hex 32 +JWT_SECRET_KEY= + +# Metadaten-APIs +# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org TMDB_API_KEY= -THEtvdb_API_KEY= +# TVDb (optional, Serien-Fallback) +THETVDB_API_KEY= +# OMDb (optional, Fallback-Quelle): kostenloser Key auf omdbapi.com/apikey.aspx +OMDB_API_KEY= + +# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im +# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta". +# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen. +MAKEMKV_APP_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 1613b12..be66548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - REDIS_URL=redis://redis:6379/0 - TMDB_API_KEY=${TMDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY} + - OMDB_API_KEY=${OMDB_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} - LOG_LEVEL=INFO healthcheck: diff --git a/docker/api/clients/omdb.py b/docker/api/clients/omdb.py new file mode 100644 index 0000000..48cfecc --- /dev/null +++ b/docker/api/clients/omdb.py @@ -0,0 +1,76 @@ +"""OMDb API Client — Fallback-Quelle für die Metadaten-Erkennung. + +Kommt zum Zug, wenn TMDB nichts (Sicheres) findet. Kostenloser Key von +https://www.omdbapi.com/apikey.aspx (1000 Anfragen/Tag reichen fürs Heimlab). +""" + +from typing import Dict, Optional + +import requests + +from cache import get, set as cache_set +from config import settings + +OMDB_BASE_URL = "https://www.omdbapi.com/" + + +def parse_runtime_minutes(runtime: str) -> int: + """OMDb liefert '142 min' als Text → Minuten als Zahl (pure Funktion, testbar).""" + if not runtime: + return 0 + teil = runtime.strip().split(" ")[0] + return int(teil) if teil.isdigit() else 0 + + +def parse_year(year: str) -> Optional[int]: + """OMDb-Jahr kann '1999' oder '2005–2012' (Serie) sein → erstes Jahr.""" + if not year: + return None + erste = year.strip()[:4] + return int(erste) if erste.isdigit() else None + + +class OMDbClient: + def __init__(self): + self.api_key = settings.omdb_api_key + self.session = requests.Session() + + def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]: + """Sucht per Titel (t=), liefert normalisierte Metadaten oder None.""" + if not self.api_key: + return None + + cache_key = f"omdb:{title.lower()}:{year or ''}" + cached = get(cache_key) + if cached: + return cached + + params = {"apikey": self.api_key, "t": title, "r": "json"} + if year: + params["y"] = str(year) + + try: + response = self.session.get(OMDB_BASE_URL, params=params, timeout=10) + response.raise_for_status() + data = response.json() + except Exception as e: + print(f"OMDb API Error: {e}") + return None + + if data.get("Response") != "True": + return None + + ergebnis = { + "type": "tv" if data.get("Type") == "series" else "movie", + "id": data.get("imdbID", ""), + "title": data.get("Title", title), + "year": parse_year(data.get("Year", "")), + "overview": data.get("Plot", ""), + "poster_path": data.get("Poster", "") if data.get("Poster") != "N/A" else "", + "backdrop_path": "", + "runtime": parse_runtime_minutes(data.get("Runtime", "")), + "genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip()], + "source": "omdb", + } + cache_set(cache_key, ergebnis) + return ergebnis diff --git a/docker/api/config.py b/docker/api/config.py index e930aa7..e023b38 100644 --- a/docker/api/config.py +++ b/docker/api/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): # API Keys tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)") thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)") + omdb_api_key: Optional[str] = Field(default=None, description="OMDb API Key (Fallback-Quelle, optional)") musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)") # Cache diff --git a/docker/api/db.py b/docker/api/db.py index 04406e3..ac67465 100644 --- a/docker/api/db.py +++ b/docker/api/db.py @@ -96,6 +96,18 @@ def list_jobs(limit: int = 100) -> list: return [dict(z) for z in zeilen] +def has_active_job(device: str) -> bool: + """True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz).""" + with engine.connect() as conn: + zeile = conn.execute( + select(jobs.c.id) + .where(jobs.c.device == device) + .where(jobs.c.status.in_(("pending", "running"))) + .limit(1) + ).first() + return zeile is not None + + def add_log(level: str, source: str, message: str) -> None: with engine.begin() as conn: conn.execute( diff --git a/docker/api/devices.py b/docker/api/devices.py index 553aa0b..6d16cc3 100644 --- a/docker/api/devices.py +++ b/docker/api/devices.py @@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall. import glob import os +from fcntl import ioctl from detection import ( CDS_DISC_OK, @@ -17,6 +18,18 @@ from detection import ( drive_status, ) +# include/uapi/linux/cdrom.h +CDROMEJECT = 0x5309 + + +def eject(device_path: str) -> None: + """Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern.""" + fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK) + try: + ioctl(fd, CDROMEJECT, 0) + finally: + os.close(fd) + def list_optical_devices() -> list: """Alle optischen Laufwerke, die der Container sieht (devices: in compose).""" diff --git a/docker/api/main.py b/docker/api/main.py index d181ac3..6c3ca55 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -209,6 +209,24 @@ async def create_job(request: JobCreateRequest): return {"id": job_id, "status": "pending", "device": device_path} +@app.post("/devices/{name}/eject") +async def eject_device(name: str): + """Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft.""" + device_path = f"/dev/{name}" + if device_path not in device_discovery.list_optical_devices(): + raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") + if await asyncio.to_thread(db.has_active_job, device_path): + raise HTTPException( + status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job" + ) + try: + await asyncio.to_thread(device_discovery.eject, device_path) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Auswurf fehlgeschlagen: {e}") + await asyncio.to_thread(db.add_log, "info", "api", f"Disc ausgeworfen: {device_path}") + return {"status": "ejected", "device": device_path} + + @app.get("/logs") async def get_logs(limit: int = 200): """Echte Ereignisse aus der Datenbank (Watcher, API, Worker).""" diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index ce9cba9..f803e14 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -5,16 +5,51 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt. """ +import shutil import subprocess -from typing import Dict, List +from typing import Dict, List, Optional +import detection from clients.tmdb import TMDBClient from clients.musicbrainz import MusicBrainzClient +from clients.omdb import OMDbClient from clients.thetvdb import TheTVDBClient from cache import get, set as cache_set from cache.keys import generate_prescan_key +def normalize_disc_label(label: str) -> str: + """Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion). + + Volume-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN — so findet keine + Metadaten-API etwas. Unterstriche zu Leerzeichen, Titel-Schreibung. + """ + if not label: + return "" + bereinigt = " ".join(label.replace("_", " ").replace(".", " ").split()) + if bereinigt.isupper(): + bereinigt = bereinigt.title() + return bereinigt + + +def read_iso_volume_label(device_path: str) -> Optional[str]: + """Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor. + + Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für + DVDs und die meisten Blu-rays (UDF-Bridge) — ganz ohne Zusatzwerkzeuge. + """ + try: + with open(device_path, "rb") as f: + f.seek(32768) + pvd = f.read(2048) + if len(pvd) < 72 or pvd[1:6] != b"CD001": + return None + label = pvd[40:72].decode("ascii", errors="replace").strip() + return label or None + except OSError: + return None + + class PreScanResult: def __init__( self, @@ -48,6 +83,7 @@ class PreScan: self.tmdb = TMDBClient() self.musicbrainz = MusicBrainzClient() self.thetvdb = TheTVDBClient() + self.omdb = OMDbClient() def scan(self, device_path: str) -> PreScanResult: """Führe Pre-Scan durch.""" @@ -59,25 +95,13 @@ class PreScan: 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" + """Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py). + + Vorher lief hier `isosize` — das Werkzeug war im Container nicht + installiert, die Erkennung fiel still immer auf "DVD" zurück. + """ + typ = detection.detect_disc_type(device_path) + return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD") def _read_toc(self, device_path: str, disc_type: str) -> Dict: """Lese TOC (Table of Contents).""" @@ -105,25 +129,30 @@ class PreScan: "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 - }) + # Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft + # überall. (makemkvcon gibt es NUR im Worker-Container; der + # alte Aufruf hier scheiterte in der API still und der Titel + # blieb ewig "Unknown Title".) + label = read_iso_volume_label(device_path) + if label: + toc["title"] = normalize_disc_label(label) + + # Titel-Quelle 2 (optional, falls makemkvcon doch da ist): + if shutil.which("makemkvcon"): + result = subprocess.run( + ["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"], + capture_output=True, + text=True, + timeout=120 + ) + for line in result.stdout.split('\n'): + if 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 @@ -188,7 +217,7 @@ class PreScan: if cached: return PreScanResult(**cached) - title = toc["title"] or "Unknown Title" + title = normalize_disc_label(toc["title"] or "") or "Unknown Title" confidence = 0.0 metadata = {} matched = False @@ -235,6 +264,35 @@ class PreScan: matched = True break + # Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht + # exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst) + if not matched: + omdb_treffer = self.omdb.lookup(title) + if omdb_treffer: + confidence = 0.8 + metadata = omdb_treffer + title = omdb_treffer.get("title", title) + matched = True + + # Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als + # VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert) + if not matched and movies: + vorschlag = self.tmdb.get_movie_details(movies[0]["id"]) + if vorschlag: + confidence = 0.6 + metadata = { + "type": "movie", + "id": movies[0]["id"], + "title": vorschlag.get("title", title), + "year": int(vorschlag.get("release_date", "0")[:4]) if vorschlag.get("release_date") else None, + "overview": vorschlag.get("overview", ""), + "poster_path": vorschlag.get("poster_path", ""), + "backdrop_path": vorschlag.get("backdrop_path", ""), + "runtime": vorschlag.get("runtime", 0), + "genres": [g["name"] for g in vorschlag.get("genres", [])] + } + matched = True + if not matched: confidence = 0.3 metadata = { diff --git a/docker/api/test_prescan_helpers.py b/docker/api/test_prescan_helpers.py new file mode 100644 index 0000000..fcd0836 --- /dev/null +++ b/docker/api/test_prescan_helpers.py @@ -0,0 +1,30 @@ +"""Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing).""" + +from clients.omdb import parse_runtime_minutes, parse_year +from prescan.prescan import normalize_disc_label + + +def test_label_normalisierung(): + # Disc-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN + assert normalize_disc_label("PULP_FICTION") == "Pulp Fiction" + assert normalize_disc_label("DIE_HARD_4.0_DE") == "Die Hard 4 0 De" + assert normalize_disc_label(" doppel raum ") == "doppel raum" + assert normalize_disc_label("") == "" + + +def test_label_mit_gemischter_schreibung_bleibt(): + # Wer schon sauber benannt hat, wird nicht plattgewalzt + assert normalize_disc_label("Inception") == "Inception" + + +def test_omdb_runtime_parsing(): + assert parse_runtime_minutes("142 min") == 142 + assert parse_runtime_minutes("N/A") == 0 + assert parse_runtime_minutes("") == 0 + + +def test_omdb_jahr_parsing(): + assert parse_year("1999") == 1999 + assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne + assert parse_year("N/A") is None + assert parse_year("") is None