diff --git a/api/video/detail.py b/api/video/detail.py index 44fec4ac..cec423c0 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -43,3 +43,15 @@ def register_routes(bp): if not data: return jsonify({"error": "not found"}), 404 return jsonify(data) + + @bp.route("/detail/show//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) diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 0e05f40f..6f2b034a 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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) diff --git a/database/video_database.py b/database/video_database.py index 1d3bb6f5..785b00fa 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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: diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 70a5030f..14315dad 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -42,6 +42,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/enrichment//test" in rules assert "/api/video/detail/show/" in rules assert "/api/video/detail/movie/" in rules + assert "/api/video/detail/show//refresh-art" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index c77f5113..723699af 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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": []}) diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 8a6bc4fb..fdddbba7 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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); }