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:
parent
2985514627
commit
e5cd5cf08c
7 changed files with 78 additions and 45 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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}."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 */ });
|
||||
|
|
|
|||
Loading…
Reference in a new issue