Metadaten-Fallback OMDb + Pre-Scan repariert + Eject-Endpoint

- clients/omdb.py: OMDb als zweite Quelle (Fallback-Kette: TMDB exakt ->
  OMDb -> bester TMDB-Vorschlag mit niedriger Confidence -> unknown)
- Pre-Scan-Reparatur: Titel kam nie an — makemkvcon existiert nur im
  Worker, isosize war nirgends installiert (fiel still auf "DVD" zurueck).
  Jetzt: ISO-9660-Volume-Label direkt vom Medium + Label-Normalisierung
  (PULP_FICTION -> Pulp Fiction), Disc-Typ ueber zentrale detection.py
- POST /devices/{name}/eject (CDROMEJECT-ioctl) mit Job-Schutz (409 wenn
  auf dem Laufwerk gerade gerippt wird)
- OMDB_API_KEY in compose/.env.example; .env.example komplett ehrlich
  dokumentiert (JWT-Pflicht, MakeMKV-Beta-Key-Rhythmus)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 15:30:09 +02:00
parent 96c6e1fe42
commit 5528e0f652
9 changed files with 270 additions and 43 deletions
+21 -3
View File
@@ -1,4 +1,22 @@
POSTGRES_PASSWORD=rippy123 # Rippy-Umgebung — nach .env kopieren und Werte eintragen.
SECRET_KEY=change-me-in-production # 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= 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=
+1
View File
@@ -15,6 +15,7 @@ services:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- TMDB_API_KEY=${TMDB_API_KEY} - TMDB_API_KEY=${TMDB_API_KEY}
- THETVDB_API_KEY=${THETVDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY}
- OMDB_API_KEY=${OMDB_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
healthcheck: healthcheck:
+76
View File
@@ -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 '20052012' (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
+1
View File
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
# API Keys # API Keys
tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)") 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)") 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)") musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
# Cache # Cache
+12
View File
@@ -96,6 +96,18 @@ def list_jobs(limit: int = 100) -> list:
return [dict(z) for z in zeilen] 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: def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute( conn.execute(
+13
View File
@@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall.
import glob import glob
import os import os
from fcntl import ioctl
from detection import ( from detection import (
CDS_DISC_OK, CDS_DISC_OK,
@@ -17,6 +18,18 @@ from detection import (
drive_status, 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: def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose).""" """Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
+18
View File
@@ -209,6 +209,24 @@ async def create_job(request: JobCreateRequest):
return {"id": job_id, "status": "pending", "device": device_path} 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") @app.get("/logs")
async def get_logs(limit: int = 200): async def get_logs(limit: int = 200):
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker).""" """Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
+98 -40
View File
@@ -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. lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
""" """
import shutil
import subprocess import subprocess
from typing import Dict, List from typing import Dict, List, Optional
import detection
from clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
from clients.omdb import OMDbClient
from clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set from cache import get, set as cache_set
from cache.keys import generate_prescan_key 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: class PreScanResult:
def __init__( def __init__(
self, self,
@@ -48,6 +83,7 @@ class PreScan:
self.tmdb = TMDBClient() self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient() self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient() self.thetvdb = TheTVDBClient()
self.omdb = OMDbClient()
def scan(self, device_path: str) -> PreScanResult: def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch.""" """Führe Pre-Scan durch."""
@@ -59,25 +95,13 @@ class PreScan:
return self._scan_video(device_path, toc) return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str: def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe.""" """Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
try:
result = subprocess.run( Vorher lief hier `isosize` — das Werkzeug war im Container nicht
["isosize", "-x", device_path], installiert, die Erkennung fiel still immer auf "DVD" zurück.
capture_output=True, """
text=True, typ = detection.detect_disc_type(device_path)
timeout=5 return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD")
)
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: def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents).""" """Lese TOC (Table of Contents)."""
@@ -105,25 +129,30 @@ class PreScan:
"duration": 0 "duration": 0
}) })
else: else:
# makemkvcon liest nur den TOC — KEIN Ripping # Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft
result = subprocess.run( # überall. (makemkvcon gibt es NUR im Worker-Container; der
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path], # alte Aufruf hier scheiterte in der API still und der Titel
capture_output=True, # blieb ewig "Unknown Title".)
text=True, label = read_iso_volume_label(device_path)
timeout=30 if label:
) toc["title"] = normalize_disc_label(label)
for line in result.stdout.split('\n'):
if line.startswith('DRV:'): # Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
parts = line.split(',') if shutil.which("makemkvcon"):
if len(parts) >= 4: result = subprocess.run(
toc["title"] = parts[3].strip().strip('"') ["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
elif line.startswith('TINFO:'): capture_output=True,
parts = line.split(',') text=True,
if len(parts) >= 5: timeout=120
toc["tracks"].append({ )
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title", for line in result.stdout.split('\n'):
"duration": int(parts[2]) if len(parts) > 2 else 0 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: except Exception as e:
print(f"Pre-Scan TOC Error: {e}") print(f"Pre-Scan TOC Error: {e}")
return toc return toc
@@ -188,7 +217,7 @@ class PreScan:
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
title = toc["title"] or "Unknown Title" title = normalize_disc_label(toc["title"] or "") or "Unknown Title"
confidence = 0.0 confidence = 0.0
metadata = {} metadata = {}
matched = False matched = False
@@ -235,6 +264,35 @@ class PreScan:
matched = True matched = True
break 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: if not matched:
confidence = 0.3 confidence = 0.3
metadata = { metadata = {
+30
View File
@@ -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("20052012") == 2005 # Serien-Zeitspanne
assert parse_year("N/A") is None
assert parse_year("") is None