Wishlist TV nebula: cinematic expand + season tags + rich tracks + sort

Next-level pass for the video nebula, ALL scoped under a video-only .vwsh-nebula
class so the music wishlist's global wl-* styling is untouched (verified: no bare
.wl-* rules in the video CSS).

1. Cinematic expand — an open orb bleeds the show's poster as a blurred, hue-
   tinted backdrop behind the season fan + glows the panel in the show's hue.
2. Season tags — each season tile stamps a bold 'S2' over its art so seasons
   read distinctly instead of identical posters.
3. Richer episode tracks — every episode line gets a colored status dot
   (wanted/searching/downloading/done/failed) + its air date.
4. Sort + count — a Recently added / Most wanted / A–Z sort (query_wishlist gains
   a sort param) and a live 'N shows · M episodes' subheader.

Tests: +1 (sort ordering). Backend 101 passed. Movies tab + music side untouched.
video
BoulderBadgeDad 2 weeks ago
parent d90ae493dd
commit 599f36fdf3

@ -39,7 +39,7 @@ def register_routes(bp):
kind = request.args.get("kind")
if kind in _KINDS:
res = db.query_wishlist(
kind, search=request.args.get("search", ""),
kind, search=request.args.get("search", ""), sort=request.args.get("sort", "added"),
page=request.args.get("page", 1), limit=request.args.get("limit", 60))
return jsonify({"success": True, "kind": kind, "counts": counts, **res})
return jsonify({"success": True, "counts": counts})

@ -1578,9 +1578,10 @@ class VideoDatabase:
finally:
conn.close()
def query_wishlist(self, kind: str, *, search=None, page=1, limit=60) -> dict:
def query_wishlist(self, kind: str, *, search=None, sort="added", page=1, limit=60) -> dict:
"""One paged slice of the wishlist. kind='movie' → movie cards; kind='show'
shows grouped showseasonepisode with wanted/done roll-ups. {items,
shows grouped showseasonepisode with wanted/done roll-ups. ``sort``
added | title | wanted (wanted = most episodes, shows only). {items,
pagination} like the other paged queries."""
try:
page = max(1, int(page or 1))
@ -1595,10 +1596,12 @@ class VideoDatabase:
if s:
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
wsql = " WHERE " + " AND ".join(where)
order = {"title": "title COLLATE NOCASE",
"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(
"SELECT tmdb_id, title, poster_url, year, status, library_id, date_added "
"FROM video_wishlist" + wsql + " ORDER BY date_added DESC, id DESC LIMIT ? OFFSET ?",
"FROM video_wishlist" + wsql + " ORDER BY " + order + " LIMIT ? OFFSET ?",
args + [limit, (page - 1) * limit]).fetchall()
items = [{"kind": "movie", "tmdb_id": r["tmdb_id"], "title": r["title"],
"poster_url": r["poster_url"], "year": r["year"], "status": r["status"],
@ -1610,13 +1613,15 @@ class VideoDatabase:
wsql = " WHERE " + " AND ".join(where)
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")
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, "
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
"MAX(date_added) AS last_added "
"FROM video_wishlist" + wsql +
" GROUP BY tmdb_id ORDER BY last_added DESC LIMIT ? OFFSET ?",
" GROUP BY tmdb_id ORDER BY " + order + " LIMIT ? OFFSET ?",
args + [limit, (page - 1) * limit]).fetchall()
items = []
for sr in show_rows:

@ -930,3 +930,18 @@ def test_wishlist_keys_for_shows(db):
keys = db.wishlist_keys_for_shows([1396, 1399, 9999])
assert keys[1396] == {"1_1", "2_3"} and keys[1399] == {"1_1"} and 9999 not in keys
assert db.wishlist_keys_for_shows([]) == {}
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
# most-wanted first
w = [s["title"] for s in db.query_wishlist("show", sort="wanted")["items"]]
assert w[0] == "Zeta"
# AZ
az = [s["title"] for s in db.query_wishlist("show", sort="title")["items"]]
assert az == ["Alpha", "Zeta"]
# movies AZ
db.add_movie_to_wishlist(10, "Banana"); db.add_movie_to_wishlist(11, "Apple")
m = [x["title"] for x in db.query_wishlist("movie", sort="title")["items"]]
assert m == ["Apple", "Banana"]

@ -1054,7 +1054,7 @@
<div class="vwsh-head-row">
<div class="vwsh-head-titles">
<h1 class="vwsh-title">Wishlist</h1>
<p class="vwsh-sub">Movies &amp; episodes you want to grab</p>
<p class="vwsh-sub" data-vwsh-sub>Movies &amp; episodes you want to grab</p>
</div>
<div class="vwsh-tabs" role="tablist">
<button class="vwsh-tab vwsh-tab--on" type="button" role="tab" data-vwsh-tab="movie">
@ -1071,6 +1071,11 @@
<svg class="vwsh-search-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" class="vwsh-search-input" data-vwsh-search placeholder="Search wishlist&hellip;" autocomplete="off" spellcheck="false">
</div>
<select class="vwsh-sort" data-vwsh-sort aria-label="Sort">
<option value="added">Recently added</option>
<option value="wanted">Most wanted</option>
<option value="title">A&ndash;Z</option>
</select>
</div>
<div class="vwsh-body">
<div class="vwsh-loading hidden" data-vwsh-loading>

@ -2485,15 +2485,50 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vwsh-movie-title { display: block; font-size: 13.5px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-movie-meta { display: block; font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 2px; }
/* shows = the "Nebula" (reuses the music wl-* orb/album/track design).
Only the show-remove button is video-specific. */
.wl-nebula-field .wl-orb-remove { position: absolute; top: 0; right: 0; z-index: 6; width: 22px; height: 22px;
/* shows = the "Nebula" (reuses the music wl-* orb/album/track design). EVERY
video enhancement is scoped under .vwsh-nebula (a class only the video field
gets) so the music wishlist's wl-* styling is never affected. */
.vwsh-nebula .wl-orb-remove { position: absolute; top: 0; right: 0; z-index: 6; width: 22px; height: 22px;
border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.15); background: rgba(0, 0, 0, 0.62);
color: rgba(255, 255, 255, 0.7); font-size: 11px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; }
.wl-nebula-field .wl-orb-group:hover .wl-orb-remove,
.wl-nebula-field .wl-orb-group.expanded .wl-orb-remove { opacity: 1; }
.wl-nebula-field .wl-orb-remove:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; }
.vwsh-nebula .wl-orb-group:hover .wl-orb-remove,
.vwsh-nebula .wl-orb-group.expanded .wl-orb-remove { opacity: 1; }
.vwsh-nebula .wl-orb-remove:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; }
/* #1 cinematic expand — blurred poster backdrop + hue glow behind the season fan */
.vwsh-nebula .wl-orb-group.expanded { overflow: hidden;
box-shadow: 0 14px 50px -12px hsla(var(--orb-hue, 230), 70%, 45%, 0.45);
border: 1px solid hsla(var(--orb-hue, 230), 70%, 55%, 0.25); }
.vwsh-nebula .wl-orb-group.expanded::before { content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none;
background-image: var(--vwsh-poster, none); background-size: cover; background-position: center;
filter: blur(28px) saturate(1.25); opacity: 0.4; transform: scale(1.25); }
.vwsh-nebula .wl-orb-group.expanded::after { content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: linear-gradient(180deg, hsla(var(--orb-hue, 230), 55%, 12%, 0.55), rgba(12, 12, 16, 0.92)); }
.vwsh-nebula .wl-orb-group.expanded > * { position: relative; z-index: 1; }
/* #2 season number stamped on the tile art (so seasons read as distinct) */
.vwsh-nebula .wl-album-tile-art { position: relative; }
.vwsh-nebula .vwsh-season-tag { position: absolute; top: 5px; left: 5px; z-index: 2; font-size: 12px; font-weight: 900;
color: #fff; padding: 2px 7px; border-radius: 6px; letter-spacing: 0.02em; backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.58); border: 1px solid rgba(255, 255, 255, 0.18); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); }
/* #3 richer episode tracks — status dot + air date */
.vwsh-nebula .wl-tile-track { gap: 8px; }
.vwsh-nebula .vwsh-ep-dot { flex-shrink: 0; width: 7px; height: 7px; border-radius: 50%;
background: rgb(var(--accent-rgb, 29, 185, 84)); box-shadow: 0 0 6px rgba(var(--accent-rgb, 29, 185, 84), 0.6); }
.vwsh-nebula .vwsh-ep-dot--searching { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.6); }
.vwsh-nebula .vwsh-ep-dot--downloading { background: #8ab0ff; box-shadow: 0 0 6px rgba(100, 149, 237, 0.6); }
.vwsh-nebula .vwsh-ep-dot--downloaded { background: #4ade80; box-shadow: 0 0 6px rgba(34, 197, 94, 0.6); }
.vwsh-nebula .vwsh-ep-dot--failed { background: #f87171; box-shadow: 0 0 6px rgba(248, 113, 113, 0.6); }
.vwsh-nebula .vwsh-ep-date { flex-shrink: 0; font-size: 9.5px; font-weight: 600; color: rgba(255, 255, 255, 0.35); }
/* #5 sort select (toolbar) */
.vwsh-sort { padding: 11px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13.5px; font-weight: 600;
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
.vwsh-sort:focus { border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.5); }
.vwsh-sort option { background: #16161d; color: #fff; }
/* status pills + remove button */
.vwsh-st { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; padding: 3px 8px; border-radius: 6px; flex-shrink: 0; }

@ -12,7 +12,8 @@
var PAGE_ID = 'video-wishlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'movie', search: '', page: 1, counts: { movie: 0, show: 0 } };
var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1,
counts: { movie: 0, show: 0, episode: 0 } };
var searchTimer = null;
function $(s, r) { return (r || document).querySelector(s); }
@ -21,6 +22,12 @@
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtDate(iso) {
var p = String(iso || '').split('-');
if (p.length < 3) return '';
return MO[(+p[1] || 1) - 1] + ' ' + (+p[2] || 1);
}
var STATUS = {
wanted: ['Wanted', 'vwsh-st--wanted'], searching: ['Searching', 'vwsh-st--searching'],
@ -72,13 +79,18 @@
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
var tiles = (sh.seasons || []).map(function (se) {
var n = se.episodes.length;
var art = sh.poster_url
? '<div class="wl-album-tile-art"><img src="' + esc(sh.poster_url) + '" alt=""></div>'
: '<div class="wl-album-tile-art"><div class="wl-album-tile-fallback">S' + se.season_number + '</div></div>';
// #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>';
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);
var st = STATUS[e.status] ? e.status : 'wanted';
var date = fmtDate(e.air_date);
// #3: status dot + air date make the episode line actually informative
return '<div class="wl-tile-track">' +
'<span class="vwsh-ep-dot vwsh-ep-dot--' + st + '" title="' + STATUS[st][0] + '"></span>' +
'<span class="wl-tile-track-name">E' + e.episode_number + ' · ' + esc(t) + '</span>' +
(date ? '<span class="vwsh-ep-date">' + esc(date) + '</span>' : '') +
'<button class="wl-tile-track-remove" type="button" data-vwsh-rm="episode" ' +
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">&#10005;</button>' +
'</div>';
@ -95,10 +107,14 @@
'</div>';
}).join('');
var eps = total + ' episode' + (total === 1 ? '' : 's');
return '<div class="wl-orb-group" data-vwsh-group style="animation-delay:' + Math.min(idx * 45, 700) + 'ms">' +
// --orb-hue on the GROUP so the music orb styles + my cinematic-expand
// backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded.
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
(sh.poster_url ? ";--vwsh-poster:url('" + esc(sh.poster_url) + "')" : '');
return '<div class="wl-orb-group" data-vwsh-group style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '" title="Remove show">&#10005;</button>' +
'<div class="wl-orb-tooltip">' + esc(sh.title) + '<br><span>' + eps + '</span></div>' +
'<div class="wl-orb ' + orbSize(total) + '" style="--orb-hue:' + hue + '" data-vwsh-orb>' +
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb>' +
'<div class="wl-orb-glow"></div>' + img + '<div class="wl-orb-ring"></div>' +
'</div>' +
'<div class="wl-orb-label" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
@ -111,6 +127,7 @@
var grid = $('[data-vwsh-grid]'); if (!grid) return;
var shows = state.tab === 'show';
grid.classList.toggle('wl-nebula-field', shows);
grid.classList.toggle('vwsh-nebula', shows); // video-only scope so music wl-* is untouched
grid.classList.toggle('vwsh-grid--movies', !shows);
grid.innerHTML = shows
? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
@ -119,10 +136,19 @@
// ── counts / badges / pager ───────────────────────────────────────────────
function setCounts(counts) {
state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0 };
state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0,
episode: (counts && counts.episode) || 0 };
var cm = $('[data-vwsh-count-movie]'); if (cm) cm.textContent = state.counts.movie;
var cs = $('[data-vwsh-count-show]'); if (cs) cs.textContent = state.counts.show;
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.show));
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
updateSub();
}
function updateSub() {
var el = $('[data-vwsh-sub]'); if (!el) return;
var c = state.counts;
el.textContent = state.tab === 'show'
? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's')
: c.movie + ' movie' + (c.movie === 1 ? '' : 's');
}
function updateBadges(total) {
var n = total || 0;
@ -160,7 +186,7 @@
function load() {
state.loaded = true;
var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden');
var params = new URLSearchParams({ kind: state.tab, search: state.search, page: state.page, limit: LIMIT });
var params = new URLSearchParams({ kind: state.tab, search: state.search, sort: state.sort, page: state.page, limit: LIMIT });
fetch('/api/video/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
@ -234,6 +260,8 @@
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function () { state.search = search.value.trim(); state.page = 1; load(); }, 250);
});
var sortSel = $('[data-vwsh-sort]');
if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); });
var prev = $('[data-vwsh-prev]');
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
var next = $('[data-vwsh-next]');

Loading…
Cancel
Save