diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 6c1e08f8..3ac47433 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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: diff --git a/database/video_database.py b/database/video_database.py index 483f6bf8..a6ef2a2c 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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() diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 96b30297..c3ac555b 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index dd3405bb..dc17546c 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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: