From 878e467f698db600d20815bf798c611bc93c31e7 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 17:57:24 -0700 Subject: [PATCH] video enrichment: backfill season posters from TMDB (server usually lacks them) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media server rarely has distinct per-season art, so season cards fell back to a gradient. TMDB's show detail carries a poster_path per season — the show worker now returns those, and enrichment_apply backfills seasons.poster_url for seasons the server left without art (gap-only, never clobbers server art). The image proxy streams a stored full URL (TMDB) directly vs. proxying a server path. Seam tests: TMDB returns season posters, backfill fills only missing seasons. --- api/video/poster.py | 11 +++++++++++ core/video/enrichment/clients.py | 9 +++++++++ database/video_database.py | 11 +++++++++++ tests/test_video_database.py | 16 ++++++++++++++++ tests/test_video_enrichment.py | 14 ++++++++++++++ 5 files changed, 61 insertions(+) diff --git a/api/video/poster.py b/api/video/poster.py index 37d31e17..a9a1bb11 100644 --- a/api/video/poster.py +++ b/api/video/poster.py @@ -23,6 +23,17 @@ def register_routes(bp): try: import requests from config.settings import config_manager + path = ref["poster_url"] + # Enrichment can store a full external URL (e.g. a TMDB season poster) — + # stream it directly; otherwise it's a server path needing the token. + if path.startswith("http://") or path.startswith("https://"): + upstream = requests.get(path, timeout=15, stream=True) + if upstream.status_code != 200: + abort(404) + resp = Response(upstream.iter_content(8192), + content_type=upstream.headers.get("Content-Type", "image/jpeg")) + resp.headers["Cache-Control"] = "public, max-age=86400" + return resp source = ref.get("server_source") if source == "plex": cfg = config_manager.get_plex_config() or {} diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 3ac47433..96477249 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -102,6 +102,15 @@ class TMDBClient: if ert: meta["runtime_minutes"] = ert[0] meta["tvdb_id"] = _int(ext.get("tvdb_id")) + # Per-season posters — the reliable source of distinct season art + # (the media server usually lacks it). Backfilled into seasons. + seasons = [] + for s in (dr.get("seasons") or []): + pp, sn = s.get("poster_path"), s.get("season_number") + if pp and sn is not None: + seasons.append({"season_number": sn, "poster_url": self.IMG + pp}) + if seasons: + meta["seasons"] = seasons except Exception: logger.exception("TMDB details fetch failed for %s", title or tmdb_id) return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}} diff --git a/database/video_database.py b/database/video_database.py index ce02fa7b..f5a8cd9e 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -250,6 +250,17 @@ class VideoDatabase: 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) + # Per-season poster backfill (TMDB) — fills only seasons the server + # left without art. + seasons_meta = (metadata or {}).get("seasons") + if matched and seasons_meta and tbl == "shows": + for s in seasons_meta: + sn, purl = s.get("season_number"), s.get("poster_url") + if sn is None or not purl: + continue + conn.execute( + "UPDATE seasons SET poster_url=COALESCE(NULLIF(poster_url, ''), ?) " + "WHERE show_id=? AND season_number=?", (purl, item_id, sn)) conn.commit() finally: conn.close() diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 267a194c..83e35d15 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -542,6 +542,22 @@ def test_enrichment_backfills_genres_when_item_has_none(db): assert db.movie_detail(mid)["genres"] == ["Comedy", "Drama"] +def test_enrichment_backfills_season_posters_only_when_missing(db): + # Season 1 has no art (server didn't provide it); season 2 has server art. + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1}]}, + {"season_number": 2, "poster_url": "/server.jpg", "episodes": [{"episode_number": 1}]}]}) + db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, metadata={"seasons": [ + {"season_number": 1, "poster_url": "https://image.tmdb.org/s1.jpg"}, + {"season_number": 2, "poster_url": "https://image.tmdb.org/s2.jpg"}]}) + with db.connect() as c: + rows = {r["season_number"]: r["poster_url"] for r in c.execute( + "SELECT season_number, poster_url FROM seasons WHERE show_id=?", (sid,)).fetchall()} + assert rows[1] == "https://image.tmdb.org/s1.jpg" # gap filled from TMDB + assert rows[2] == "/server.jpg" # server art kept, not clobbered + assert db.show_detail(sid)["seasons"][0]["has_poster"] is True + + 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 dc17546c..94663e62 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -98,6 +98,20 @@ def test_tmdb_pulls_full_metadata(monkeypatch): assert m["genres"] == ["Sci-Fi", "Drama"] and m["status"] == "Released" and m["imdb_id"] == "tt1" +def test_tmdb_show_returns_season_posters(monkeypatch): + class _Resp: + def __init__(self, b): self._b = b + def raise_for_status(self): pass + def json(self): return self._b + detail = {"overview": "O", "external_ids": {}, "seasons": [ + {"season_number": 1, "poster_path": "/a.jpg"}, + {"season_number": 2, "poster_path": None}]} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail))) + m = TMDBClient("KEY").match("show", "Show", 2020, known_id=1396)["metadata"] + assert m["seasons"] == [{"season_number": 1, + "poster_url": "https://image.tmdb.org/t/p/original/a.jpg"}] + + def test_tmdb_client_raises_on_rate_limit(monkeypatch): # A 429 must raise (→ worker records 'error'), not silently return no-match. class _Resp429: