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:
parent
fcd4af0efd
commit
986059626f
6 changed files with 122 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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": []})
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue