video enrichment: backfill season posters from TMDB (server usually lacks them)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 17:57:24 -07:00
parent 5e8143dd1d
commit 878e467f69
5 changed files with 61 additions and 0 deletions

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

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