From e5cd5cf08c9a833ed008708c85b1546fb34a17c2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 17:42:12 -0700 Subject: [PATCH] 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. --- api/video/wishlist.py | 14 ++++---- database/video_database.py | 49 ++++++++++++++++++++------- database/video_schema.sql | 1 + tests/test_video_api.py | 14 ++++---- tests/test_video_database.py | 13 +++---- webui/static/video/video-get-modal.js | 9 +++-- webui/static/video/video-wishlist.js | 23 +++++++------ 7 files changed, 78 insertions(+), 45 deletions(-) diff --git a/api/video/wishlist.py b/api/video/wishlist.py index dcf56486..5ea2bde8 100644 --- a/api/video/wishlist.py +++ b/api/video/wishlist.py @@ -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"]) diff --git a/database/video_database.py b/database/video_database.py index 00ed38ba..c2e4402f 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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}.""" diff --git a/database/video_schema.sql b/database/video_schema.sql index 2ca66a25..4deaeb6b 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -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 diff --git a/tests/test_video_api.py b/tests/test_video_api.py index b23d0355..8bd2e288 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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" diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 586b5a9f..8c25b0f3 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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" diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 24dc2a28..a1b4c92c 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -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 = '
Episodes' + '
'; 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) { diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index ce091dae..f2aaedb1 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -79,8 +79,9 @@ : '
' + esc(initials(sh.title)) + '
'; 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 ? '' : '
📺
'; + // real season poster (falls back to the show poster, then a placeholder) + var posterUrl = se.poster_url || sh.poster_url || null; + var inner = posterUrl ? '' : '
📺
'; var art = '
' + inner + 'S' + se.season_number + '
'; 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 */ });