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
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=
+1
View File
@@ -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:
+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
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
+12
View File
@@ -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(
+13
View File
@@ -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)."""
+18
View File
@@ -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)."""
+87 -29
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.
"""
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,19 +129,24 @@ class PreScan:
"duration": 0
})
else:
# makemkvcon liest nur den TOC — KEIN Ripping
# 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", "--minlength=300", "--progress=off", "info", device_path],
["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
capture_output=True,
text=True,
timeout=30
timeout=120
)
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:'):
if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
@@ -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 = {
+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