Wishlist: backfill episode stills for rows added before still-capture

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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 17:33:59 -07:00
parent f7bd15d019
commit 2985514627
5 changed files with 101 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = '';