video: lazy season-art backfill on detail view (fixes matched-show art gap)

Root cause: season posters / episode art backfill happen during a show's TMDB
*match*, but already-matched shows never re-run ('Retry all failed' only resets
not_found/error), so existing libraries never got the art.

Fix (Boulder's idea): fetch-on-view + cache. When a show detail opens and any
season lacks a poster, the page calls POST /detail/show/<id>/refresh-art →
engine.refresh_show_art re-fetches /tv/<id> via the TMDB client and backfills
season posters + episode art gap-only, regardless of match status. Cached, so
it's a one-time cost per show; runs once per view; re-renders when done.

Seam tests: refresh_show_art backfills a MATCHED show's seasons, needs TMDB
configured, show_match_info, route registered.
This commit is contained in:
BoulderBadgeDad 2026-06-14 20:56:57 -07:00
parent fcd4af0efd
commit 986059626f
6 changed files with 122 additions and 0 deletions

View file

@ -43,3 +43,15 @@ def register_routes(bp):
if not data:
return jsonify({"error": "not found"}), 404
return jsonify(data)
@bp.route("/detail/show/<int:show_id>/refresh-art", methods=["POST"])
def video_show_refresh_art(show_id):
"""Lazy on-view backfill: pull missing season posters / episode art from
TMDB and cache them. Best-effort never errors the page."""
try:
from core.video.enrichment.engine import get_video_enrichment_engine
res = get_video_enrichment_engine().refresh_show_art(show_id)
except Exception:
logger.exception("refresh-art failed for show %s", show_id)
res = {"ok": False, "reason": "error"}
return jsonify(res)

View file

@ -65,6 +65,34 @@ class VideoEnrichmentEngine:
", ".join(sorted(self._scan_paused)))
self._scan_paused = set()
def refresh_show_art(self, show_id) -> dict:
"""On-demand (lazy) backfill of a show's season posters + episode art from
TMDB, used when the detail page is opened and art is missing. Works
regardless of the show's match status (sidesteps 'already matched, never
re-runs'), and caches the result so it's a one-time cost per show."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return {"ok": False, "reason": "tmdb_not_configured"}
info = self.db.show_match_info(show_id)
if not info:
return {"ok": False, "reason": "not_found"}
try:
result = w.client.match("show", info.get("title"), info.get("year"),
known_id=info.get("tmdb_id"))
except Exception:
logger.exception("refresh_show_art: match failed for show %s", show_id)
return {"ok": False, "reason": "match_error"}
if not result or not result.get("id"):
return {"ok": False, "reason": "no_match"}
# Backfills season posters + show metadata gaps (never clobbers).
self.db.enrichment_apply("tmdb", "show", show_id, matched=True,
external_id=result["id"], metadata=result.get("metadata"))
try:
w._cascade_episodes(show_id, result["id"]) # episode stills too
except Exception:
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
return {"ok": True}
def worker(self, service):
return self.workers.get(service)

View file

@ -297,6 +297,16 @@ class VideoDatabase:
finally:
conn.close()
def show_match_info(self, show_id: int) -> dict | None:
"""Title/year/tmdb_id for one show — for on-demand (lazy) art refresh."""
conn = self._get_connection()
try:
row = conn.execute("SELECT title, year, tmdb_id FROM shows WHERE id=?",
(show_id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()
def show_season_numbers(self, show_id: int) -> list:
conn = self._get_connection()
try:

View file

@ -42,6 +42,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/enrichment/<service>/test" in rules
assert "/api/video/detail/show/<int:show_id>" in rules
assert "/api/video/detail/movie/<int:movie_id>" in rules
assert "/api/video/detail/show/<int:show_id>/refresh-art" in rules
assert any(r.startswith("/api/video/backdrop/") for r in rules)

View file

@ -98,6 +98,45 @@ def test_tmdb_pulls_full_metadata(monkeypatch):
assert m["genres"] == ["Sci-Fi", "Drama"] and m["status"] == "Released" and m["imdb_id"] == "tt1"
def test_refresh_show_art_backfills_seasons_even_when_already_matched(db):
# The exact failing case: a MATCHED show (won't re-run via the queue) still
# has no season posters. Lazy refresh fetches + caches them anyway.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396,
"seasons": [{"season_number": 1, "episodes": [{"episode_number": 1}]}]})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396) # already matched
assert db.show_detail(sid)["seasons"][0]["has_poster"] is False
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
assert known_id == 1396
return {"id": 1396, "metadata": {"seasons": [
{"season_number": 1, "poster_url": "https://img/s1.jpg"}]}}
def season_episodes(self, tv, sn): return None
eng = VideoEnrichmentEngine(db, {"tmdb": C()})
assert eng.refresh_show_art(sid)["ok"] is True
assert db.show_detail(sid)["seasons"][0]["has_poster"] is True
def test_refresh_show_art_needs_tmdb_configured(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
class Off:
enabled = False
def match(self, *a, **k): return None
res = VideoEnrichmentEngine(db, {"tmdb": Off()}).refresh_show_art(sid)
assert res["ok"] is False and res["reason"] == "tmdb_not_configured"
def test_show_match_info(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "year": 2019,
"tmdb_id": 1396, "seasons": []})
assert db.show_match_info(sid) == {"title": "S", "year": 2019, "tmdb_id": 1396}
assert db.show_match_info(999999) is None
def test_enrichment_next_priority_pins_kind_first(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})

View file

@ -30,6 +30,8 @@
var seasonView = 'rail';
var menuOpen = false;
var missingOnly = false;
var currentId = null;
var artAttemptedFor = null; // lazy art refresh runs once per show view
try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ }
@ -286,6 +288,8 @@
function loadShow(id) {
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
showLoading(true);
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
@ -302,10 +306,38 @@
renderViewToggle(); renderSeasonNav(); renderEpisodes();
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
if (sub) sub.scrollTop = 0;
maybeRefreshArt(id);
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
}
// Lazy art: if any season lacks a poster, pull it from TMDB on view and cache
// it (once per show), then re-render. Sidesteps "already matched, never re-runs".
function maybeRefreshArt(id) {
if (artAttemptedFor === id || !data || data.id !== id) return;
if (!(data.seasons || []).some(function (s) { return !s.has_poster; })) return;
artAttemptedFor = id;
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
{ method: 'POST', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) { if (res && res.ok && currentId === id) reloadDetail(id); })
.catch(function () { /* best-effort */ });
}
function reloadDetail(id) {
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || d.error || currentId !== id) return;
data = d;
if (!seasonByNum(selectedSeason)) {
selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null;
}
renderBillboard(d); renderSeasonNav(); renderEpisodes();
})
.catch(function () { /* ignore */ });
}
// ── events ────────────────────────────────────────────────────────────────
function onOpen(e) { if (e && e.detail && e.detail.kind === 'show') loadShow(e.detail.id); }