video enrichment: pull everything TMDB/TVDB offer + backfill gaps only
Enrichment now harvests the full detail payload (same call, no extra requests): - TMDB: tagline, genres, rating (vote_average), runtime, status, first/last air date (shows), release date + runtime (movies) — on top of overview/backdrop/ids. - TVDB: switches to /series/<id>/extended for overview + genres. enrichment_apply now uses BACKFILL semantics: metadata columns are written via COALESCE(NULLIF(col,''), ?) so enrichment only fills fields the media server left empty — it never clobbers server-provided data. Genres backfill to the normalised link tables only when the item has none yet. Whitelist expanded for the new columns. Seam tests: backfill-only (server overview/genres kept, gaps filled), genre backfill when empty, TMDB full-metadata extraction.
This commit is contained in:
parent
e1e0e29432
commit
2306a5740c
4 changed files with 76 additions and 8 deletions
|
|
@ -83,10 +83,24 @@ class TMDBClient:
|
|||
meta["backdrop_url"] = self.IMG + dr["backdrop_path"]
|
||||
ext = dr.get("external_ids") or {}
|
||||
meta["imdb_id"] = ext.get("imdb_id") or dr.get("imdb_id")
|
||||
# Everything TMDB offers (same call) — the worker backfills only the
|
||||
# gaps the server left.
|
||||
meta["tagline"] = dr.get("tagline")
|
||||
meta["status"] = dr.get("status")
|
||||
if dr.get("vote_average"):
|
||||
meta["rating"] = dr.get("vote_average")
|
||||
gs = [g.get("name") for g in (dr.get("genres") or []) if g.get("name")]
|
||||
if gs:
|
||||
meta["genres"] = gs
|
||||
if kind == "movie":
|
||||
meta["release_date"] = dr.get("release_date")
|
||||
meta["runtime_minutes"] = dr.get("runtime")
|
||||
else:
|
||||
meta["status"] = dr.get("status")
|
||||
meta["first_air_date"] = dr.get("first_air_date")
|
||||
meta["last_air_date"] = dr.get("last_air_date")
|
||||
ert = dr.get("episode_run_time") or []
|
||||
if ert:
|
||||
meta["runtime_minutes"] = ert[0]
|
||||
meta["tvdb_id"] = _int(ext.get("tvdb_id"))
|
||||
except Exception:
|
||||
logger.exception("TMDB details fetch failed for %s", title or tmdb_id)
|
||||
|
|
@ -157,10 +171,15 @@ class TVDBClient:
|
|||
tvdb_id = _int(top.get("tvdb_id") or top.get("id"))
|
||||
meta["overview"] = top.get("overview")
|
||||
else:
|
||||
# Known id from the server → fetch the series directly for its overview.
|
||||
# Known id from the server → fetch the extended record (overview +
|
||||
# genres + everything TVDB offers).
|
||||
try:
|
||||
dr = self._authed_get("/series/" + str(tvdb_id))
|
||||
meta["overview"] = ((dr or {}).get("data") or {}).get("overview")
|
||||
dr = self._authed_get("/series/" + str(tvdb_id) + "/extended")
|
||||
sd = (dr or {}).get("data") or {}
|
||||
meta["overview"] = sd.get("overview")
|
||||
gs = [g.get("name") for g in (sd.get("genres") or []) if g.get("name")]
|
||||
if gs:
|
||||
meta["genres"] = gs
|
||||
except Exception:
|
||||
logger.exception("TVDB details fetch failed for %s", title or tvdb_id)
|
||||
if tvdb_id is None:
|
||||
|
|
|
|||
|
|
@ -63,8 +63,10 @@ _ENRICH = {
|
|||
# arbitrary keys; backfill semantics applied by the caller).
|
||||
_ENRICH_META_COLS = {
|
||||
"movies": {"overview", "backdrop_url", "release_date", "status", "content_rating",
|
||||
"runtime_minutes", "studio", "imdb_id", "tmdb_id"},
|
||||
"runtime_minutes", "studio", "tagline", "rating", "rating_critic",
|
||||
"imdb_id", "tmdb_id"},
|
||||
"shows": {"overview", "backdrop_url", "status", "network", "content_rating",
|
||||
"tagline", "rating", "first_air_date", "last_air_date",
|
||||
"imdb_id", "tmdb_id", "tvdb_id"},
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +224,9 @@ class VideoDatabase:
|
|||
continue
|
||||
if not include_ids and col in id_cols:
|
||||
continue
|
||||
sets.append(f"{col}=?")
|
||||
# BACKFILL: only fill a column the server left empty — enrichment
|
||||
# fills gaps, it never clobbers data the media server provided.
|
||||
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
||||
params.append(val)
|
||||
params.append(item_id)
|
||||
return f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params
|
||||
|
|
@ -232,12 +236,21 @@ class VideoDatabase:
|
|||
sql, params = build(True)
|
||||
try:
|
||||
conn.execute(sql, params)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
sql, params = build(False) # keep existing id, just record status/metadata
|
||||
conn.execute(sql, params)
|
||||
conn.commit()
|
||||
# Genres backfill — only when the item has none yet (enrichment fills
|
||||
# the gap the server didn't). Written to the normalised link tables.
|
||||
genres = (metadata or {}).get("genres")
|
||||
link = {"movies": ("movie_genres", "movie_id"),
|
||||
"shows": ("show_genres", "show_id")}.get(tbl)
|
||||
if matched and genres and link:
|
||||
lt, oc = link
|
||||
has = conn.execute(f"SELECT 1 FROM {lt} WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
||||
if not has:
|
||||
self._set_genres(conn, lt, oc, item_id, genres)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -492,6 +492,27 @@ def test_enrichment_breakdown_unmatched_retry(db):
|
|||
assert db.enrichment_breakdown("tmdb")["movie"]["pending"] == 1
|
||||
|
||||
|
||||
def test_enrichment_backfills_only_gaps_never_clobbers_server(db):
|
||||
# Server gave overview + a genre; enrichment must fill the EMPTY fields
|
||||
# (tagline/rating) but leave the server's overview + genres untouched.
|
||||
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune",
|
||||
"overview": "server overview", "genres": ["Sci-Fi"]})
|
||||
db.enrichment_apply("tmdb", "movie", mid, matched=True, external_id=438631, metadata={
|
||||
"overview": "tmdb overview", "tagline": "Fear is the mind-killer",
|
||||
"rating": 8.4, "genres": ["Drama"]})
|
||||
d = db.movie_detail(mid)
|
||||
assert d["overview"] == "server overview" # NOT clobbered
|
||||
assert d["tagline"] == "Fear is the mind-killer" and d["rating"] == 8.4 # gaps filled
|
||||
assert d["genres"] == ["Sci-Fi"] # had genres → enrichment left them
|
||||
|
||||
|
||||
def test_enrichment_backfills_genres_when_item_has_none(db):
|
||||
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "X"}) # no genres
|
||||
db.enrichment_apply("tmdb", "movie", mid, matched=True, external_id=1,
|
||||
metadata={"genres": ["Drama", "Comedy"]})
|
||||
assert db.movie_detail(mid)["genres"] == ["Comedy", "Drama"]
|
||||
|
||||
|
||||
def test_error_status_is_distinct_and_retryable_in_ui(db):
|
||||
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
|
||||
db.enrichment_apply("tmdb", "movie", a, matched=False, error=True)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ def test_tmdb_client_with_known_id_skips_the_search(monkeypatch):
|
|||
assert not any("/search/" in u for u in urls)
|
||||
|
||||
|
||||
def test_tmdb_pulls_full_metadata(monkeypatch):
|
||||
# Everything TMDB offers comes from the one detail call.
|
||||
class _Resp:
|
||||
def __init__(self, b): self._b = b
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return self._b
|
||||
detail = {"overview": "O", "tagline": "Fear", "vote_average": 8.4, "runtime": 155,
|
||||
"genres": [{"name": "Sci-Fi"}, {"name": "Drama"}], "status": "Released",
|
||||
"backdrop_path": "/b.jpg", "external_ids": {"imdb_id": "tt1"}}
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
|
||||
m = TMDBClient("KEY").match("movie", "Dune", 2021, known_id=438631)["metadata"]
|
||||
assert m["tagline"] == "Fear" and m["rating"] == 8.4 and m["runtime_minutes"] == 155
|
||||
assert m["genres"] == ["Sci-Fi", "Drama"] and m["status"] == "Released" and m["imdb_id"] == "tt1"
|
||||
|
||||
|
||||
def test_tmdb_client_raises_on_rate_limit(monkeypatch):
|
||||
# A 429 must raise (→ worker records 'error'), not silently return no-match.
|
||||
class _Resp429:
|
||||
|
|
|
|||
Loading…
Reference in a new issue