Ampel / ampel (push) Successful in 54s
Der Commander: "TMDB und OMDB Key sind hinterlegt, das laufwerk wird aber
auch nicht korrekt ausgelesen. Eigentlich sollte er direkt bei TMDB oder OMDB
oder JIKAN anfragen nach metadaten."
Er hat das Symptom gemeldet. Darunter lagen VIER Fehler.
## 1. Ein Phantom-Modul (der schwerste)
clients/tmdb.py:27 from db import get_settings
clients/omdb.py:36 from db import get_settings
clients/thetvdb.py:16 from db import get_settings
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr; main.py schreibt seitdem `from rippy import store as db`. Diese drei
Module haben es nie mitbekommen.
Damit starb JEDE Metadaten-Abfrage schon beim Erzeugen des Clients mit
ModuleNotFoundError -- und `_auto_prescan` verschluckte das in seinem
`except Exception`. Die Disc blieb namenlos, und niemand sah warum.
**Das betrifft die VM genauso.** Sie steht auf 05ab655, also NACH dd1d0b7 --
dort laeuft seit V2-1 dieselbe tote Metadaten-Suche.
## 2. Redis war Pflicht statt Beschleunigung
`cache/cache.py` sprach `redis:6379` an -- den Dienstnamen aus
docker-compose.yml. Auf einem Windows-PC gibt es kein Redis:
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
Das riss den Pre-Scan mit. Ein Zwischenspeicher ist eine Beschleunigung,
keine Voraussetzung: Jetzt faengt jeder Zugriff den Verbindungsfehler ab und
meldet "nicht vorhanden" -- was der Wahrheit entspricht. Gemeldet wird der
Ausfall EINMAL, nicht bei jeder Anfrage.
## 3. Der Klartext-Titel war unter Windows nicht erreichbar
`read_disc_title_via_mount` machte fest `mount -t udf` -- also Linux. Unter
Windows haengt Windows die Disc selbst ein. An der echten Disc gemessen:
Volume-Label (UDF) BD_EVG_D2 <- damit findet keine API etwas
BDMV/META/DL/bdmt_deu.xml Evangelion 2.22
`disc_wurzel()` liefert jetzt je Plattform einen lesbaren Einstieg, und
`titel_aus_bdmt()` ist davon getrennt (und damit ohne Disc pruefbar).
## 4. Modell und Seriennummer fehlten
Im UI stand "Modell: unbekannt, Seriennummer: -". Der Linux-Treiber liest
beides aus /sys; der Windows-Treiber lieferte leere Felder. Die Entsprechung
ist IOCTL_STORAGE_QUERY_PROPERTY. An echter Hardware gemessen:
hersteller 'HL-DT-ST'
modell 'BD-RE BU40N'
fassung '1.03'
seriennummer '0025114C0149'
Gemerkt statt jedes Mal abgefragt: Die Disc-Wache ruft device_info alle drei
Sekunden, und die Angaben eines Laufwerks aendern sich nicht.
## Ergebnis, an der eingelegten Disc gemessen
title 'Neon Genesis Evangelion'
year 1995
disc_type 'Blu-ray'
confidence 0.8
fingerprint 'BD_EVG_D2|48149364736'
(Jikan antwortete waehrend der Messung mit 504 -- deren Ausfall, nicht
unserer. Der Treffer kam von TMDB.)
Zwei Waechter, beide beim Zurueckdrehen rot gesehen: Die Clients muessen sich
OHNE Datenbank und OHNE Redis bauen lassen, und ein `from db import` in
clients/ faellt mechanisch auf.
Ampel lokal: 769 gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
7.2 KiB
Python
182 lines
7.2 KiB
Python
"""TMDB API Client mit Caching."""
|
|
|
|
import requests
|
|
from typing import Dict, List, Optional
|
|
|
|
from config import settings
|
|
from cache import get, set, delete
|
|
|
|
|
|
TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
|
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
|
|
|
|
|
def ist_v4_token(key: str) -> bool:
|
|
"""Pure Funktion (testbar): TMDB hat ZWEI Key-Arten — der v4 Read Access
|
|
Token ist ein langes JWT ('eyJ…', Bearer-Header), der klassische v3-Key
|
|
32 Hex-Zeichen (api_key-Query-Parameter). Befund 24.07.: der Client konnte
|
|
NUR v4 — mit dem üblichen v3-Key aus dem TMDB-Konto war jede Anfrage 401
|
|
und die Suche lieferte still nur OMDb-Treffer."""
|
|
return bool(key) and key.startswith("eyJ")
|
|
|
|
|
|
class TMDBClient:
|
|
def __init__(self):
|
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
|
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
|
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
|
|
#
|
|
# Hier stand "from db import get_settings". docker/api/db.py gibt es
|
|
# seit der Zusammenlegung in V2-1 (dd1d0b7) nicht mehr — main.py
|
|
# schreibt seitdem "from rippy import store as db", aber dieses Modul
|
|
# hat das nie mitbekommen. Die Folge: JEDE Metadaten-Abfrage starb
|
|
# schon beim Erzeugen des Clients mit ModuleNotFoundError, und
|
|
# _auto_prescan verschluckte das in seinem "except Exception". Discs
|
|
# blieben namenlos — auf Windows UND auf der VM.
|
|
#
|
|
# bei_fehler_leer=True: Ohne Datenbank gilt der Schlüssel aus der
|
|
# Umgebung. Ein Metadaten-Client ist kein Grund, warum nichts geht.
|
|
from rippy.store import get_settings
|
|
self.api_key = get_settings(bei_fehler_leer=True).get("tmdbApiKey") or settings.tmdb_api_key
|
|
self.session = requests.Session()
|
|
self.session.headers.update({"Content-Type": "application/json"})
|
|
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
|
# api_key-Parameter, v4-Token als Bearer-Header)
|
|
self._key_params = {}
|
|
if ist_v4_token(self.api_key):
|
|
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
|
elif self.api_key:
|
|
self._key_params = {"api_key": self.api_key}
|
|
|
|
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
|
"""Mache API-Request mit Caching."""
|
|
from cache.keys import generate_tmdb_key
|
|
# Cache-Key OHNE den api_key — der gehört nicht in den Cache
|
|
cache_key = generate_tmdb_key(endpoint, params)
|
|
|
|
cached = get(cache_key)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
response = self.session.get(
|
|
f"{TMDB_BASE_URL}/{endpoint}",
|
|
params={**(params or {}), **self._key_params},
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
# Cache nur für erfolgreiche Requests
|
|
if response.status_code == 200:
|
|
set(cache_key, data)
|
|
|
|
return data
|
|
except Exception as e:
|
|
print(f"TMDB API Error: {e}")
|
|
return None
|
|
|
|
def search_movie(self, title: str, year: int = None) -> List[Dict]:
|
|
"""Suche Filme."""
|
|
params = {"query": title, "language": "de-DE", "include_adult": False}
|
|
if year:
|
|
params["year"] = year
|
|
|
|
result = self._request("search/movie", params)
|
|
if result and "results" in result:
|
|
return result["results"]
|
|
return []
|
|
|
|
def search_tv(self, title: str, year: int = None) -> List[Dict]:
|
|
"""Suche Serien."""
|
|
params = {"query": title, "language": "de-DE", "include_adult": False}
|
|
if year:
|
|
params["first_air_date_year"] = year
|
|
|
|
result = self._request("search/tv", params)
|
|
if result and "results" in result:
|
|
return result["results"]
|
|
return []
|
|
|
|
def get_movie_details(self, movie_id: int) -> Optional[Dict]:
|
|
"""Hole Film-Details."""
|
|
params = {"language": "de-DE"}
|
|
return self._request(f"movie/{movie_id}", params)
|
|
|
|
def get_tv_details(self, tv_id: int) -> Optional[Dict]:
|
|
"""Hole Serien-Details."""
|
|
params = {"language": "de-DE"}
|
|
return self._request(f"tv/{tv_id}", params)
|
|
|
|
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
|
|
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
|
|
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
|
|
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
|
|
|
|
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
|
|
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
|
|
developer.themoviedb.org/reference/find-by-id).
|
|
|
|
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
|
|
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
|
|
"""
|
|
result = self._request(
|
|
f"find/{imdb_id}",
|
|
{"external_source": "imdb_id", "language": "de-DE"},
|
|
)
|
|
if not result:
|
|
return None
|
|
for schluessel, typ, titel_feld, datum_feld in (
|
|
("movie_results", "movie", "title", "release_date"),
|
|
("tv_results", "tv", "name", "first_air_date"),
|
|
):
|
|
eintraege = result.get(schluessel) or []
|
|
if eintraege:
|
|
e = eintraege[0]
|
|
datum = e.get(datum_feld) or ""
|
|
return {
|
|
"type": typ,
|
|
"tmdb_id": e.get("id"),
|
|
"title": e.get(titel_feld) or "",
|
|
"overview": e.get("overview") or "",
|
|
"poster_path": e.get("poster_path") or "",
|
|
"year": int(datum[:4]) if datum[:4].isdigit() else None,
|
|
}
|
|
return None
|
|
|
|
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
|
"""Hole Poster/Fanart URLs."""
|
|
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
|
result = self._request(f"movie/{movie_id}/images", params)
|
|
|
|
if not result or "posters" not in result:
|
|
return {}
|
|
|
|
posters = result["posters"]
|
|
fanart = result.get("backdrops", [])
|
|
|
|
return {
|
|
"poster": f"{TMDB_IMAGE_BASE_URL}/w500{posters[0]['file_path']}" if posters else "",
|
|
"fanart": f"{TMDB_IMAGE_BASE_URL}/w1920{fanart[0]['file_path']}" if fanart else ""
|
|
}
|
|
|
|
def get_tv_images(self, tv_id: int) -> Dict[str, str]:
|
|
"""Hole Serien-Poster/Fanart URLs."""
|
|
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
|
result = self._request(f"tv/{tv_id}/images", params)
|
|
|
|
if not result or "posters" not in result:
|
|
return {}
|
|
|
|
posters = result["posters"]
|
|
fanart = result.get("backdrops", [])
|
|
|
|
return {
|
|
"poster": f"{TMDB_IMAGE_BASE_URL}/w500{posters[0]['file_path']}" if posters else "",
|
|
"fanart": f"{TMDB_IMAGE_BASE_URL}/w1920{fanart[0]['file_path']}" if fanart else ""
|
|
}
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Leere TMDB-Cache."""
|
|
delete("tmdb:*")
|