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:
parent
f7bd15d019
commit
2985514627
5 changed files with 101 additions and 0 deletions
|
|
@ -108,6 +108,31 @@ def register_routes(bp):
|
||||||
logger.exception("Failed to remove from video wishlist")
|
logger.exception("Failed to remove from video wishlist")
|
||||||
return jsonify({"success": False, "error": "Failed"}), 500
|
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"])
|
@bp.route("/wishlist/check", methods=["POST"])
|
||||||
def video_wishlist_check():
|
def video_wishlist_check():
|
||||||
"""Hydrate cards/modal. Body: {movie_ids: [...], show_tmdb_id?} →
|
"""Hydrate cards/modal. Body: {movie_ids: [...], show_tmdb_id?} →
|
||||||
|
|
|
||||||
|
|
@ -1669,6 +1669,35 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def wishlist_state(self, *, movie_ids=None, show_tmdb_id=None) -> dict:
|
||||||
"""Hydration: which of ``movie_ids`` are wishlisted, and which episode
|
"""Hydration: which of ``movie_ids`` are wishlisted, and which episode
|
||||||
keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}."""
|
keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}."""
|
||||||
|
|
|
||||||
|
|
@ -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}]})
|
"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()
|
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"]
|
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"
|
||||||
|
|
|
||||||
|
|
@ -954,3 +954,16 @@ def test_wishlist_episode_still_roundtrips(db):
|
||||||
eps = db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"]
|
eps = db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"]
|
||||||
by = {e["episode_number"]: e for e in eps}
|
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
|
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"
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,29 @@
|
||||||
render(d.items || []);
|
render(d.items || []);
|
||||||
updatePagination(p);
|
updatePagination(p);
|
||||||
updateEmpty(p.total_count);
|
updateEmpty(p.total_count);
|
||||||
|
maybeBackfillStills(d.items || []);
|
||||||
})
|
})
|
||||||
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); });
|
.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) {
|
function setTab(tab) {
|
||||||
if (tab !== 'movie' && tab !== 'show') return;
|
if (tab !== 'movie' && tab !== 'show') return;
|
||||||
state.tab = tab; state.page = 1; state.search = '';
|
state.tab = tab; state.page = 1; state.search = '';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue