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:
+21
-3
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user