From 2985514627240d171544dc2b3a392e77335db718 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 17:33:59 -0700 Subject: [PATCH] Wishlist: backfill episode stills for rows added before still-capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'no episode images' was data, not a bug: existing episode rows predate still-capture (81 rows, 0 stills). New /wishlist/backfill-stills fills them cheaply — one cached tmdb_season call per (show, season), updating only rows that lack a still. The show tab fires it once automatically when it sees missing stills, then reloads so the images pop in. DB: wishlist_still_backfill_targets + set_wishlist_still (won't clobber existing). Tests: +2. Suites green. --- api/video/wishlist.py | 25 ++++++++++++++++++++++++ database/video_database.py | 29 ++++++++++++++++++++++++++++ tests/test_video_api.py | 15 ++++++++++++++ tests/test_video_database.py | 13 +++++++++++++ webui/static/video/video-wishlist.js | 19 ++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/api/video/wishlist.py b/api/video/wishlist.py index 87f56f95..dcf56486 100644 --- a/api/video/wishlist.py +++ b/api/video/wishlist.py @@ -108,6 +108,31 @@ def register_routes(bp): logger.exception("Failed to remove from video wishlist") return jsonify({"success": False, "error": "Failed"}), 500 + @bp.route("/wishlist/backfill-stills", methods=["POST"]) + def video_wishlist_backfill_stills(): + """Fill episode stills for rows that predate still-capture. One tmdb_season + call per (show, season); best-effort. Returns how many rows were filled.""" + from . import get_video_db + from core.video.enrichment.engine import get_video_enrichment_engine + db = get_video_db() + eng = get_video_enrichment_engine() + updated = 0 + try: + for grp in db.wishlist_still_backfill_targets(): + try: + se = eng.tmdb_season(grp["tmdb_id"], grp["season_number"]) or {} + except Exception: + continue + for ep in (se.get("episodes") or []): + if ep.get("still_url") and ep.get("episode_number") is not None: + if db.set_wishlist_still(grp["tmdb_id"], grp["season_number"], + ep["episode_number"], ep["still_url"]): + updated += 1 + return jsonify({"success": True, "updated": updated}) + except Exception: + logger.exception("wishlist still backfill failed") + return jsonify({"success": False, "updated": updated}) + @bp.route("/wishlist/check", methods=["POST"]) def video_wishlist_check(): """Hydrate cards/modal. Body: {movie_ids: [...], show_tmdb_id?} → diff --git a/database/video_database.py b/database/video_database.py index 6aba170c..00ed38ba 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1669,6 +1669,35 @@ class VideoDatabase: finally: conn.close() + def wishlist_still_backfill_targets(self) -> list: + """Distinct (show_tmdb_id, season) with episode rows missing a still — one + tmdb_season fetch per group fills them all (cheap backfill for rows added + before still-capture existed).""" + conn = self._get_connection() + try: + rows = conn.execute( + "SELECT DISTINCT tmdb_id, season_number FROM video_wishlist " + "WHERE kind='episode' AND (still_url IS NULL OR still_url='') " + "AND tmdb_id IS NOT NULL AND season_number IS NOT NULL").fetchall() + return [{"tmdb_id": r["tmdb_id"], "season_number": r["season_number"]} for r in rows] + finally: + conn.close() + + def set_wishlist_still(self, show_tmdb_id, season_number, episode_number, still_url) -> bool: + """Fill a single episode's still (only if it doesn't already have one).""" + if not still_url: + return False + conn = self._get_connection() + try: + cur = conn.execute( + "UPDATE video_wishlist SET still_url=? WHERE kind='episode' AND tmdb_id=? " + "AND season_number=? AND episode_number=? AND (still_url IS NULL OR still_url='')", + (still_url, int(show_tmdb_id), int(season_number), int(episode_number))) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + def wishlist_state(self, *, movie_ids=None, show_tmdb_id=None) -> dict: """Hydration: which of ``movie_ids`` are wishlisted, and which episode keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index c80ca99d..b23d0355 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -495,3 +495,18 @@ def test_wishlist_check_by_show(tmp_path): "show": {"tmdb_id": 1396, "title": "BB"}, "episodes": [{"season_number": 1, "episode_number": 1}]}) res = client.post("/api/video/wishlist/check", json={"shows": [1396, 1399]}).get_json() assert res["by_show"]["1396"] == ["1_1"] and "1399" not in res["by_show"] + + +def test_wishlist_backfill_stills_endpoint(tmp_path, monkeypatch): + client, vapi = _make_client(tmp_path) + db = vapi._video_db + db.add_episodes_to_wishlist(1396, "BB", [{"season_number": 1, "episode_number": 1}]) # no still + import core.video.enrichment.engine as eng_mod + + class FakeEng: + def tmdb_season(self, tv, sn): return {"episodes": [{"episode_number": 1, "still_url": "/s.jpg"}]} + monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) + r = client.post("/api/video/wishlist/backfill-stills").get_json() + assert r["success"] and r["updated"] == 1 + ep = db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"][0] + assert ep["still_url"] == "/s.jpg" diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 88db6a55..586b5a9f 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -954,3 +954,16 @@ def test_wishlist_episode_still_roundtrips(db): eps = db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"] by = {e["episode_number"]: e for e in eps} assert by[1]["still_url"] == "https://img/e1.jpg" and by[2]["still_url"] is None + + +def test_wishlist_still_backfill_targets_and_set(db): + db.add_episodes_to_wishlist(1396, "BB", [ + {"season_number": 1, "episode_number": 1, "still_url": "/have.jpg"}, + {"season_number": 1, "episode_number": 2}, + {"season_number": 2, "episode_number": 1}]) + tset = {(t["tmdb_id"], t["season_number"]) for t in db.wishlist_still_backfill_targets()} + assert (1396, 1) in tset and (1396, 2) in tset + assert db.set_wishlist_still(1396, 1, 2, "/new.jpg") is True + assert db.set_wishlist_still(1396, 1, 1, "/x.jpg") is False # won't clobber an existing still + eps = {e["episode_number"]: e for e in db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"]} + assert eps[1]["still_url"] == "/have.jpg" and eps[2]["still_url"] == "/new.jpg" diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index 1b96156d..ce091dae 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -204,10 +204,29 @@ render(d.items || []); updatePagination(p); updateEmpty(p.total_count); + maybeBackfillStills(d.items || []); }) .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); }); } + // Episodes added before still-capture have no image — fetch them once (cheap: + // one tmdb_season call per show/season server-side), then reload to show them. + var stillsBackfilled = false; + function maybeBackfillStills(items) { + if (state.tab !== 'show' || stillsBackfilled) return; + var missing = (items || []).some(function (sh) { + return (sh.seasons || []).some(function (se) { + return (se.episodes || []).some(function (e) { return !e.still_url; }); + }); + }); + if (!missing) return; + stillsBackfilled = true; + fetch('/api/video/wishlist/backfill-stills', { method: 'POST', headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (res) { if (res && res.updated > 0) load(); }) + .catch(function () { /* best-effort */ }); + } + function setTab(tab) { if (tab !== 'movie' && tab !== 'show') return; state.tab = tab; state.page = 1; state.search = '';