Wishlist: real season posters (not the show poster) + extend backfill

Season tiles showed the show poster because the wishlist had no season art. Now
stored + used: new video_wishlist.season_poster_url (SCHEMA_VERSION 11 + migration);
the get-modal captures each season's poster at add-time (owned -> /poster/season
proxy, tmdb -> direct); query_wishlist exposes season.poster_url; tiles render the
real season poster (falling back to show poster, then placeholder).

Backfill generalized: /wishlist/backfill-stills -> /wishlist/backfill-art fills
BOTH stills and season posters from the same cached tmdb_season call (it already
returns the season poster). The page fires it once when either is missing.

Tests updated/added: art backfill targets + season-poster set + endpoint. 104 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 17:42:12 -07:00
parent 2985514627
commit e5cd5cf08c
7 changed files with 78 additions and 45 deletions

View file

@ -108,21 +108,23 @@ 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."""
@bp.route("/wishlist/backfill-art", methods=["POST"])
def video_wishlist_backfill_art():
"""Fill episode stills + season posters for rows that predate art-capture.
One tmdb_season call per (show, season); best-effort. Returns rows 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():
for grp in db.wishlist_art_backfill_targets():
try:
se = eng.tmdb_season(grp["tmdb_id"], grp["season_number"]) or {}
except Exception:
continue
if se.get("poster_url"):
updated += db.set_wishlist_season_poster(grp["tmdb_id"], grp["season_number"], se["poster_url"])
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"],
@ -130,7 +132,7 @@ def register_routes(bp):
updated += 1
return jsonify({"success": True, "updated": updated})
except Exception:
logger.exception("wishlist still backfill failed")
logger.exception("wishlist art backfill failed")
return jsonify({"success": False, "updated": updated})
@bp.route("/wishlist/check", methods=["POST"])

View file

@ -29,7 +29,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 10
SCHEMA_VERSION = 11
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -100,6 +100,7 @@ _COLUMN_MIGRATIONS = [
("shows", "airs_time", "TEXT"), # TVDB show air time, e.g. "21:00" (network local)
("video_watchlist", "state", "TEXT NOT NULL DEFAULT 'follow'"), # follow | mute (tombstone)
("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time)
("video_wishlist", "season_poster_url", "TEXT"), # the episode's season poster
]
@ -1518,17 +1519,19 @@ class VideoDatabase:
conn.execute(
"""INSERT INTO video_wishlist
(kind, tmdb_id, title, poster_url, season_number, episode_number,
episode_title, still_url, air_date, library_id, server_source)
VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
episode_title, still_url, season_poster_url, air_date, library_id, server_source)
VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id, season_number, episode_number) WHERE kind='episode' DO UPDATE SET
title=excluded.title,
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title),
still_url=COALESCE(excluded.still_url, video_wishlist.still_url),
season_poster_url=COALESCE(excluded.season_poster_url, video_wishlist.season_poster_url),
air_date=COALESCE(excluded.air_date, video_wishlist.air_date),
library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""",
(int(show_tmdb_id), show_title, poster_url, int(sn), int(en),
e.get("title"), e.get("still_url"), e.get("air_date"), library_id, server_source))
e.get("title"), e.get("still_url"), e.get("season_poster_url"),
e.get("air_date"), library_id, server_source))
n += 1
conn.commit()
return n
@ -1628,16 +1631,20 @@ class VideoDatabase:
items = []
for sr in show_rows:
eps = conn.execute(
"SELECT season_number, episode_number, episode_title, still_url, air_date, status "
"SELECT season_number, episode_number, episode_title, still_url, "
"season_poster_url, air_date, status "
"FROM video_wishlist WHERE kind='episode' AND tmdb_id=? "
"ORDER BY season_number, episode_number", (sr["tmdb_id"],)).fetchall()
by_season: dict = {}
season_poster: dict = {}
for e in eps:
by_season.setdefault(e["season_number"], []).append({
"episode_number": e["episode_number"], "title": e["episode_title"],
"still_url": e["still_url"], "air_date": e["air_date"], "status": e["status"]})
seasons = [{"season_number": sn, "episodes": by_season[sn]}
for sn in sorted(by_season)]
if e["season_poster_url"] and e["season_number"] not in season_poster:
season_poster[e["season_number"]] = e["season_poster_url"]
seasons = [{"season_number": sn, "poster_url": season_poster.get(sn),
"episodes": by_season[sn]} for sn in sorted(by_season)]
items.append({"kind": "show", "tmdb_id": sr["tmdb_id"], "title": sr["title"],
"poster_url": sr["poster_url"], "library_id": sr["library_id"],
"wanted": sr["wanted"], "done": sr["done"] or 0, "seasons": seasons})
@ -1669,16 +1676,17 @@ 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)."""
def wishlist_art_backfill_targets(self) -> list:
"""Distinct (show_tmdb_id, season) with episode rows missing a still OR a
season poster one tmdb_season fetch per group fills both (cheap backfill
for rows added before art-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()
"WHERE kind='episode' AND tmdb_id IS NOT NULL AND season_number IS NOT NULL "
"AND ((still_url IS NULL OR still_url='') OR "
" (season_poster_url IS NULL OR season_poster_url=''))").fetchall()
return [{"tmdb_id": r["tmdb_id"], "season_number": r["season_number"]} for r in rows]
finally:
conn.close()
@ -1698,6 +1706,21 @@ class VideoDatabase:
finally:
conn.close()
def set_wishlist_season_poster(self, show_tmdb_id, season_number, poster_url) -> int:
"""Fill the season poster on every episode row of a season that lacks one."""
if not poster_url:
return 0
conn = self._get_connection()
try:
cur = conn.execute(
"UPDATE video_wishlist SET season_poster_url=? WHERE kind='episode' AND tmdb_id=? "
"AND season_number=? AND (season_poster_url IS NULL OR season_poster_url='')",
(poster_url, int(show_tmdb_id), int(season_number)))
conn.commit()
return cur.rowcount
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

@ -368,6 +368,7 @@ CREATE TABLE IF NOT EXISTS video_wishlist (
episode_number INTEGER, -- episode rows
episode_title TEXT, -- episode rows
still_url TEXT, -- episode still thumbnail (episode rows)
season_poster_url TEXT, -- the episode's SEASON poster (episode rows)
air_date TEXT, -- episode rows (already aired by the time it's here)
status TEXT NOT NULL DEFAULT 'wanted', -- wanted|searching|downloading|downloaded|failed
library_id INTEGER, -- owned movies.id/shows.id when re-downloading

View file

@ -497,16 +497,16 @@ def test_wishlist_check_by_show(tmp_path):
assert res["by_show"]["1396"] == ["1_1"] and "1399" not in res["by_show"]
def test_wishlist_backfill_stills_endpoint(tmp_path, monkeypatch):
def test_wishlist_backfill_art_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
db.add_episodes_to_wishlist(1396, "BB", [{"season_number": 1, "episode_number": 1}]) # no art
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"}]}
def tmdb_season(self, tv, sn):
return {"poster_url": "/s1.jpg", "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"
assert client.post("/api/video/wishlist/backfill-art").get_json()["success"] is True
season = db.query_wishlist("show")["items"][0]["seasons"][0]
assert season["poster_url"] == "/s1.jpg" and season["episodes"][0]["still_url"] == "/s.jpg"

View file

@ -956,14 +956,15 @@ def test_wishlist_episode_still_roundtrips(db):
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):
def test_wishlist_art_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": 1, "still_url": "/have.jpg", "season_poster_url": "/s1.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
tset = {(t["tmdb_id"], t["season_number"]) for t in db.wishlist_art_backfill_targets()}
assert (1396, 1) in tset and (1396, 2) in tset # S1 missing a still, S2 missing both
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"
assert db.set_wishlist_season_poster(1396, 2, "/s2.jpg") == 1 # fills the season's episodes
by_season = {s["season_number"]: s for s in db.query_wishlist("show")["items"][0]["seasons"]}
assert by_season[1]["poster_url"] == "/s1.jpg" and by_season[2]["poster_url"] == "/s2.jpg"

View file

@ -95,7 +95,8 @@
modalState.sel.forEach(function (key) {
var p = key.split('_'), m = (modalState.epMeta || {})[key] || {};
eps.push({ season_number: parseInt(p[0], 10), episode_number: parseInt(p[1], 10),
title: m.title, air_date: m.air_date, still_url: m.still });
title: m.title, air_date: m.air_date, still_url: m.still,
season_poster_url: (modalState.seasonPoster || {})[p[0]] });
});
if (!eps.length) { if (btn) btn.disabled = false; toast('Select at least one episode', 'info'); return; }
@ -313,12 +314,16 @@
// they load per-season on expand (like the full detail page). Stash the tv
// id so the lazy loader can fetch them.
var tvId = (o && o.source === 'tmdb') ? parseInt(o.id, 10) : null;
modalState = { kind: 'show', sel: new Set(), tvId: tvId, today: today, epMeta: {} };
modalState = { kind: 'show', sel: new Set(), tvId: tvId, today: today, epMeta: {}, seasonPoster: {} };
var totalMissing = 0, anyLazy = false;
var html = '<div class="vgm-eps-head"><span class="vgm-eps-h">Episodes</span>' +
'<label class="vgm-eps-toggle"><input type="checkbox" data-vgm-missing-only checked>' +
'<span>Missing only</span></label></div><div class="vgm-eps-list">';
d.seasons.forEach(function (s) {
// Capture this season's poster (owned → proxy by season id, tmdb → direct).
modalState.seasonPoster[String(s.season_number)] = (o && o.source === 'library')
? (s.has_poster && s.id != null ? '/api/video/poster/season/' + s.id : null)
: (s.poster_url || null);
var eps = s.episodes || [];
// Lazy: a tmdb season with a known count but no episodes shipped yet.
if (!eps.length && (s.episode_total || 0) > 0 && tvId) {

View file

@ -79,8 +79,9 @@
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
var tiles = (sh.seasons || []).map(function (se) {
var n = se.episodes.length;
// #2: stamp the season number over the art so tiles read as distinct seasons
var inner = sh.poster_url ? '<img src="' + esc(sh.poster_url) + '" alt="">' : '<div class="wl-album-tile-fallback">📺</div>';
// real season poster (falls back to the show poster, then a placeholder)
var posterUrl = se.poster_url || sh.poster_url || null;
var inner = posterUrl ? '<img src="' + esc(posterUrl) + '" alt="">' : '<div class="wl-album-tile-fallback">📺</div>';
var art = '<div class="wl-album-tile-art">' + inner + '<span class="vwsh-season-tag">S' + se.season_number + '</span></div>';
var tracks = (se.episodes || []).map(function (e) {
var t = e.title || ('Episode ' + e.episode_number);
@ -204,24 +205,24 @@
render(d.items || []);
updatePagination(p);
updateEmpty(p.total_count);
maybeBackfillStills(d.items || []);
maybeBackfillArt(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;
// Rows added before art-capture have no episode still / season poster — fetch
// them once (cheap: one tmdb_season call per show/season server-side), reload.
var artBackfilled = false;
function maybeBackfillArt(items) {
if (state.tab !== 'show' || artBackfilled) return;
var missing = (items || []).some(function (sh) {
return (sh.seasons || []).some(function (se) {
return (se.episodes || []).some(function (e) { return !e.still_url; });
return !se.poster_url || (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' } })
artBackfilled = true;
fetch('/api/video/wishlist/backfill-art', { 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 */ });