Wishlist: episode synopsis + clickable cards + FIFO/newest sort

More data in the roomier episode cards (asked for): episodes now carry a synopsis
(new video_wishlist.episode_overview, SCHEMA_VERSION 12 + migration; captured at
add-time, and the art-backfill fills it for old rows from the same tmdb_season
call). The card shows a 2-line synopsis under the meta line and is now clickable
-> opens the show detail (episodes have no page of their own).

Organize the wishlist two ways: added 'Oldest first' (FIFO) alongside 'Recently
added' (newest) — query_wishlist gains the 'oldest' sort for both movies + shows.

Tests: FIFO/newest ordering (with pinned add-times) + overview roundtrip/backfill.
105 passed. Music wishlist untouched.
This commit is contained in:
BoulderBadgeDad 2026-06-16 18:21:26 -07:00
parent 4d62df5c33
commit e8a0e1256b
8 changed files with 80 additions and 27 deletions

View file

@ -126,10 +126,13 @@ def register_routes(bp):
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"],
ep["episode_number"], ep["still_url"]):
updated += 1
en = ep.get("episode_number")
if en is None:
continue
if ep.get("still_url") and db.set_wishlist_still(grp["tmdb_id"], grp["season_number"], en, ep["still_url"]):
updated += 1
if ep.get("overview"):
db.set_wishlist_episode_overview(grp["tmdb_id"], grp["season_number"], en, ep["overview"])
return jsonify({"success": True, "updated": updated})
except Exception:
logger.exception("wishlist art backfill failed")

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 = 11
SCHEMA_VERSION = 12
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -101,6 +101,7 @@ _COLUMN_MIGRATIONS = [
("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
("video_wishlist", "episode_overview", "TEXT"), # episode synopsis
]
@ -1519,18 +1520,20 @@ class VideoDatabase:
conn.execute(
"""INSERT INTO video_wishlist
(kind, tmdb_id, title, poster_url, season_number, episode_number,
episode_title, still_url, season_poster_url, air_date, library_id, server_source)
VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
episode_title, still_url, episode_overview, 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),
episode_overview=COALESCE(excluded.episode_overview, video_wishlist.episode_overview),
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("season_poster_url"),
e.get("title"), e.get("still_url"), e.get("overview"), e.get("season_poster_url"),
e.get("air_date"), library_id, server_source))
n += 1
conn.commit()
@ -1601,7 +1604,7 @@ class VideoDatabase:
if s:
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
wsql = " WHERE " + " AND ".join(where)
order = {"title": "title COLLATE NOCASE",
order = {"title": "title COLLATE NOCASE", "oldest": "date_added ASC, id ASC",
"added": "date_added DESC, id DESC"}.get(sort, "date_added DESC, id DESC")
total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"]
rows = conn.execute(
@ -1619,7 +1622,7 @@ class VideoDatabase:
total = conn.execute(
"SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
order = {"title": "title COLLATE NOCASE", "wanted": "wanted DESC, last_added DESC",
"added": "last_added DESC"}.get(sort, "last_added DESC")
"oldest": "last_added ASC", "added": "last_added DESC"}.get(sort, "last_added DESC")
show_rows = conn.execute(
"SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
"MAX(library_id) AS library_id, COUNT(*) AS wanted, "
@ -1632,7 +1635,7 @@ class VideoDatabase:
for sr in show_rows:
eps = conn.execute(
"SELECT season_number, episode_number, episode_title, still_url, "
"season_poster_url, air_date, status "
"episode_overview, 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 = {}
@ -1640,7 +1643,8 @@ class VideoDatabase:
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"]})
"still_url": e["still_url"], "overview": e["episode_overview"],
"air_date": e["air_date"], "status": e["status"]})
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),
@ -1686,7 +1690,8 @@ class VideoDatabase:
"SELECT DISTINCT tmdb_id, season_number FROM video_wishlist "
"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()
" (season_poster_url IS NULL OR season_poster_url='') OR "
" (episode_overview IS NULL OR episode_overview=''))").fetchall()
return [{"tmdb_id": r["tmdb_id"], "season_number": r["season_number"]} for r in rows]
finally:
conn.close()
@ -1706,6 +1711,21 @@ class VideoDatabase:
finally:
conn.close()
def set_wishlist_episode_overview(self, show_tmdb_id, season_number, episode_number, overview) -> bool:
"""Fill a single episode's synopsis (only if it doesn't already have one)."""
if not overview:
return False
conn = self._get_connection()
try:
cur = conn.execute(
"UPDATE video_wishlist SET episode_overview=? WHERE kind='episode' AND tmdb_id=? "
"AND season_number=? AND episode_number=? AND (episode_overview IS NULL OR episode_overview='')",
(overview, int(show_tmdb_id), int(season_number), int(episode_number)))
conn.commit()
return cur.rowcount > 0
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:

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)
episode_overview TEXT, -- episode synopsis (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

View file

@ -935,9 +935,18 @@ def test_wishlist_keys_for_shows(db):
def test_wishlist_query_sort(db):
db.add_episodes_to_wishlist(1, "Alpha", [{"season_number": 1, "episode_number": 1}]) # 1 ep
db.add_episodes_to_wishlist(2, "Zeta", [{"season_number": 1, "episode_number": i} for i in range(5)]) # 5 eps
# CURRENT_TIMESTAMP collides within a second — pin distinct add-times so the
# FIFO vs newest ordering is deterministic.
with db.connect() as c:
c.execute("UPDATE video_wishlist SET date_added='2024-01-01 00:00:00' WHERE tmdb_id=1")
c.execute("UPDATE video_wishlist SET date_added='2024-01-02 00:00:00' WHERE tmdb_id=2")
c.commit()
# most-wanted first
w = [s["title"] for s in db.query_wishlist("show", sort="wanted")["items"]]
assert w[0] == "Zeta"
# FIFO (oldest first) vs recently-added (newest first)
assert [s["title"] for s in db.query_wishlist("show", sort="oldest")["items"]] == ["Alpha", "Zeta"]
assert [s["title"] for s in db.query_wishlist("show", sort="added")["items"]] == ["Zeta", "Alpha"]
# AZ
az = [s["title"] for s in db.query_wishlist("show", sort="title")["items"]]
assert az == ["Alpha", "Zeta"]
@ -968,3 +977,15 @@ def test_wishlist_art_backfill_targets_and_set(db):
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"
def test_wishlist_episode_overview_roundtrips_and_backfills(db):
db.add_episodes_to_wishlist(1396, "BB", [
{"season_number": 1, "episode_number": 1, "overview": "The one where it begins."},
{"season_number": 1, "episode_number": 2}])
eps = {e["episode_number"]: e for e in db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"]}
assert eps[1]["overview"] == "The one where it begins." and eps[2]["overview"] is None
# episode missing an overview shows up as a backfill target
assert (1396, 1) in {(t["tmdb_id"], t["season_number"]) for t in db.wishlist_art_backfill_targets()}
assert db.set_wishlist_episode_overview(1396, 1, 2, "Filled in.") is True
assert db.set_wishlist_episode_overview(1396, 1, 1, "nope") is False # won't clobber

View file

@ -1073,6 +1073,7 @@
</div>
<select class="vwsh-sort" data-vwsh-sort aria-label="Sort">
<option value="added">Recently added</option>
<option value="oldest">Oldest first</option>
<option value="wanted">Most wanted</option>
<option value="title">A&ndash;Z</option>
</select>

View file

@ -95,7 +95,7 @@
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, overview: m.overview,
season_poster_url: (modalState.seasonPoster || {})[p[0]] });
});
if (!eps.length) { if (btn) btn.disabled = false; toast('Select at least one episode', 'info'); return; }
@ -343,7 +343,7 @@
var missing = 0;
eps.forEach(function (e) {
modalState.epMeta[s.season_number + '_' + e.episode_number] = { title: e.title, air_date: e.air_date,
still: e.has_still ? ('/api/video/poster/episode/' + e.id) : null };
overview: e.overview, still: e.has_still ? ('/api/video/poster/episode/' + e.id) : null };
if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); }
});
totalMissing += missing;
@ -406,7 +406,7 @@
modalState.epMeta = modalState.epMeta || {};
eps.forEach(function (e) {
modalState.epMeta[sn + '_' + e.episode_number] = { title: e.title, air_date: e.air_date,
still: e.still_url || null };
overview: e.overview, still: e.still_url || null };
if (epState(e, today) === 'missing') modalState.sel.add(sn + '_' + e.episode_number);
});
var all = seasonEl.querySelector('[data-vgm-season-all]');

View file

@ -2556,13 +2556,13 @@ body[data-side="video"] #soulsync-toggle { display: none; }
/* #1/#2 — the picked season drops its episodes here, full-width, as 2-line cards */
.vwsh-nebula .vwsh-ep-area:empty { display: none; }
.vwsh-nebula .vwsh-ep-area { margin-top: 14px; }
.vwsh-nebula .vwsh-ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px;
max-height: 360px; overflow-y: auto; padding: 2px; scrollbar-width: thin; }
.vwsh-nebula .vwsh-epc { position: relative; display: flex; align-items: center; gap: 11px; padding: 8px;
.vwsh-nebula .vwsh-ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px;
max-height: 420px; overflow-y: auto; padding: 2px; scrollbar-width: thin; }
.vwsh-nebula .vwsh-epc { position: relative; display: flex; align-items: flex-start; gap: 11px; padding: 9px; cursor: pointer;
border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.15s ease, border-color 0.15s ease; }
.vwsh-nebula .vwsh-epc:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.12); }
.vwsh-nebula .vwsh-epc-thumb { flex-shrink: 0; width: 84px; height: 47px; border-radius: 6px; overflow: hidden;
.vwsh-nebula .vwsh-epc:hover { background: rgba(255, 255, 255, 0.07); border-color: hsla(var(--orb-hue, 230), 60%, 55%, 0.35); }
.vwsh-nebula .vwsh-epc-thumb { flex-shrink: 0; width: 92px; height: 52px; border-radius: 6px; overflow: hidden;
background: #16161d; border: 1px solid rgba(255, 255, 255, 0.08); }
.vwsh-nebula .vwsh-epc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vwsh-nebula .vwsh-epc-thumb--none { background: repeating-linear-gradient(135deg,
@ -2571,7 +2571,10 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vwsh-nebula .vwsh-epc-body { flex: 1; min-width: 0; }
.vwsh-nebula .vwsh-epc-title { font-size: 12.5px; font-weight: 700; color: #fff; line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vwsh-nebula .vwsh-epc:hover .vwsh-epc-title { color: hsl(var(--orb-hue, 230) 80% 80%); }
.vwsh-nebula .vwsh-epc-meta { margin-top: 3px; font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.42); }
.vwsh-nebula .vwsh-epc-ov { margin-top: 5px; font-size: 11px; line-height: 1.45; color: rgba(255, 255, 255, 0.5);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vwsh-nebula .vwsh-epc-rm { flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; border: none;
background: rgba(0, 0, 0, 0.35); color: rgba(255, 255, 255, 0.45); font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; }

View file

@ -114,8 +114,11 @@
'</div>';
}
// A single episode as a 2-line card (still + title that can wrap + meta line).
function epCard(tmdb, se, e) {
// A single episode as a rich card: still + title + meta + synopsis. The card
// links to the show detail (episodes have no page of their own).
function epCard(sh, se, e) {
var src = sh.library_id != null ? 'library' : 'tmdb';
var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id;
var t = e.title || ('Episode ' + e.episode_number);
var st = STATUS[e.status] ? e.status : 'wanted';
var date = fmtDate(e.air_date);
@ -123,14 +126,15 @@
? '<span class="vwsh-epc-thumb"><img src="' + esc(e.still_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vwsh-epc-thumb--none\')"></span>'
: '<span class="vwsh-epc-thumb vwsh-epc-thumb--none"></span>';
return '<div class="vwsh-epc">' + thumb +
return '<div class="vwsh-epc" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '">' + thumb +
'<div class="vwsh-epc-body">' +
'<div class="vwsh-epc-title" title="' + esc(t) + '">' + esc(t) + '</div>' +
'<div class="vwsh-epc-meta"><span class="vwsh-ep-dot vwsh-ep-dot--' + st + '"></span>' +
'S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '') + '</div>' +
(e.overview ? '<div class="vwsh-epc-ov">' + esc(e.overview) + '</div>' : '') +
'</div>' +
'<button class="vwsh-epc-rm" type="button" data-vwsh-rm="episode" ' +
'data-tmdb="' + esc(tmdb) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">&#10005;</button>' +
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">&#10005;</button>' +
'</div>';
}
function renderEpisodeArea(group, tmdb, seasonNum) {
@ -139,7 +143,7 @@
if (sh) (sh.seasons || []).forEach(function (x) { if (x.season_number === seasonNum) se = x; });
if (!se) { area.innerHTML = ''; return; }
area.innerHTML = '<div class="vwsh-ep-grid">' +
(se.episodes || []).map(function (e) { return epCard(tmdb, se, e); }).join('') + '</div>';
(se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join('') + '</div>';
}
function render(items) {
@ -231,7 +235,7 @@
if (state.tab !== 'show' || artBackfilled) return;
var missing = (items || []).some(function (sh) {
return (sh.seasons || []).some(function (se) {
return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url; });
return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url || !e.overview; });
});
});
if (!missing) return;