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:
BoulderBadgeDad 2026-06-14 17:23:43 -07:00
parent e1e0e29432
commit 2306a5740c
4 changed files with 76 additions and 8 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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)

View file

@ -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: