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"]
|
meta["backdrop_url"] = self.IMG + dr["backdrop_path"]
|
||||||
ext = dr.get("external_ids") or {}
|
ext = dr.get("external_ids") or {}
|
||||||
meta["imdb_id"] = ext.get("imdb_id") or dr.get("imdb_id")
|
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":
|
if kind == "movie":
|
||||||
meta["release_date"] = dr.get("release_date")
|
meta["release_date"] = dr.get("release_date")
|
||||||
|
meta["runtime_minutes"] = dr.get("runtime")
|
||||||
else:
|
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"))
|
meta["tvdb_id"] = _int(ext.get("tvdb_id"))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("TMDB details fetch failed for %s", title or tmdb_id)
|
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"))
|
tvdb_id = _int(top.get("tvdb_id") or top.get("id"))
|
||||||
meta["overview"] = top.get("overview")
|
meta["overview"] = top.get("overview")
|
||||||
else:
|
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:
|
try:
|
||||||
dr = self._authed_get("/series/" + str(tvdb_id))
|
dr = self._authed_get("/series/" + str(tvdb_id) + "/extended")
|
||||||
meta["overview"] = ((dr or {}).get("data") or {}).get("overview")
|
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:
|
except Exception:
|
||||||
logger.exception("TVDB details fetch failed for %s", title or tvdb_id)
|
logger.exception("TVDB details fetch failed for %s", title or tvdb_id)
|
||||||
if tvdb_id is None:
|
if tvdb_id is None:
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,10 @@ _ENRICH = {
|
||||||
# arbitrary keys; backfill semantics applied by the caller).
|
# arbitrary keys; backfill semantics applied by the caller).
|
||||||
_ENRICH_META_COLS = {
|
_ENRICH_META_COLS = {
|
||||||
"movies": {"overview", "backdrop_url", "release_date", "status", "content_rating",
|
"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",
|
"shows": {"overview", "backdrop_url", "status", "network", "content_rating",
|
||||||
|
"tagline", "rating", "first_air_date", "last_air_date",
|
||||||
"imdb_id", "tmdb_id", "tvdb_id"},
|
"imdb_id", "tmdb_id", "tvdb_id"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +224,9 @@ class VideoDatabase:
|
||||||
continue
|
continue
|
||||||
if not include_ids and col in id_cols:
|
if not include_ids and col in id_cols:
|
||||||
continue
|
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(val)
|
||||||
params.append(item_id)
|
params.append(item_id)
|
||||||
return f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params
|
return f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params
|
||||||
|
|
@ -232,12 +236,21 @@ class VideoDatabase:
|
||||||
sql, params = build(True)
|
sql, params = build(True)
|
||||||
try:
|
try:
|
||||||
conn.execute(sql, params)
|
conn.execute(sql, params)
|
||||||
conn.commit()
|
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
sql, params = build(False) # keep existing id, just record status/metadata
|
sql, params = build(False) # keep existing id, just record status/metadata
|
||||||
conn.execute(sql, params)
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,27 @@ def test_enrichment_breakdown_unmatched_retry(db):
|
||||||
assert db.enrichment_breakdown("tmdb")["movie"]["pending"] == 1
|
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):
|
def test_error_status_is_distinct_and_retryable_in_ui(db):
|
||||||
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
|
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
|
||||||
db.enrichment_apply("tmdb", "movie", a, matched=False, error=True)
|
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)
|
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):
|
def test_tmdb_client_raises_on_rate_limit(monkeypatch):
|
||||||
# A 429 must raise (→ worker records 'error'), not silently return no-match.
|
# A 429 must raise (→ worker records 'error'), not silently return no-match.
|
||||||
class _Resp429:
|
class _Resp429:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue