Discover: redesigned Browse panel + more data per rail

Browse panel (was boring native dropdowns):
- Kind + Sort are now segmented pill controls; Genre + Era are horizontally
  scrollable, edge-faded chip rows with an accent-glow active state; primary
  'Browse all →' CTA. Genre chips rebuild when kind flips. Selection lives in
  state.sel (no <select> reads).

More data per rail (so 'Hide owned' doesn't gut a shelf):
- /discover/list gains a 'pages' param (1–3): fetches that many consecutive
  TMDB pages and concatenates them deduped in one response. Rails request
  pages=2 (~40 items); trending is a fixed list so extra pages are skipped.

Cleanup: removed dead .vdsc-filterbar/.vdsc-select CSS.

Tests: +3 (discover routes registered; multi-page concat+dedup; trending
fetched once despite pages). API suite: 22 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 14:13:27 -07:00
parent 5c9b43a175
commit 8043db0ad4
5 changed files with 183 additions and 72 deletions

View file

@ -64,28 +64,48 @@ def register_routes(bp):
@bp.route("/discover/list", methods=["GET"])
def video_discover_list():
"""One shelf (rail) or one page of a filtered browse — see module docstring."""
"""One shelf (rail) or one page of a filtered browse — see module docstring.
``pages`` (13, default 1) fetches that many consecutive TMDB pages and
concatenates them (deduped) in one response so a rail can show ~40 items
and still look full after 'Hide owned' drops the ones you have."""
from core.video.enrichment.engine import get_video_enrichment_engine
eng = get_video_enrichment_engine()
try:
page = max(1, int(request.args.get("page", 1) or 1))
except (TypeError, ValueError):
page = 1
key = (request.args.get("key") or "").strip()
try:
pages = min(3, max(1, int(request.args.get("pages", 1) or 1)))
except (TypeError, ValueError):
pages = 1
key = (request.args.get("key") or "").strip()
kind = request.args.get("kind", "movie")
genre = request.args.get("genre") or None
year = request.args.get("year") or None
decade = request.args.get("decade") or None
sort = request.args.get("sort") or "popularity.desc"
def fetch(p):
if key == "trending":
items = eng.trending()
elif key:
items = eng.discover_curated(key, page=page)
else:
items = eng.discover_filter(
request.args.get("kind", "movie"),
genre=(request.args.get("genre") or None),
year=(request.args.get("year") or None),
decade=(request.args.get("decade") or None),
sort_by=(request.args.get("sort") or "popularity.desc"),
page=page)
return jsonify({"items": items or [], "page": page})
return eng.trending()
if key:
return eng.discover_curated(key, page=p)
return eng.discover_filter(kind, genre=genre, year=year, decade=decade,
sort_by=sort, page=p)
try:
items, seen = [], set()
for p in range(page, page + pages):
for it in (fetch(p) or []):
dk = (it.get("kind"), it.get("tmdb_id"))
if dk in seen:
continue
seen.add(dk)
items.append(it)
if key == "trending":
break # trending is a fixed list — extra pages just repeat it
return jsonify({"items": items, "page": page})
except Exception:
logger.exception("discover list failed (key=%s)", key)
return jsonify({"items": [], "page": page})

View file

@ -54,6 +54,39 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/episode/<int:tmdb_id>/<int:season>/<int:episode>" in rules
assert any(r.startswith("/api/video/backdrop/") for r in rules)
assert "/api/video/img" in rules
assert "/api/video/discover/hero" in rules
assert "/api/video/discover/genres" in rules
assert "/api/video/discover/taste" in rules
assert "/api/video/discover/list" in rules
def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch):
client, _ = _make_client(tmp_path)
import core.video.enrichment.engine as eng_mod
class FakeEng:
def discover_curated(self, key, page=1):
# page 1 → ids 1,2 ; page 2 → ids 2,3 (overlap on 2)
return ([{"kind": "movie", "tmdb_id": 1}, {"kind": "movie", "tmdb_id": 2}] if page == 1
else [{"kind": "movie", "tmdb_id": 2}, {"kind": "movie", "tmdb_id": 3}])
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
r = client.get("/api/video/discover/list?key=popular_movies&pages=2")
assert [it["tmdb_id"] for it in r.get_json()["items"]] == [1, 2, 3] # concatenated + deduped
def test_discover_list_trending_fetches_once_despite_pages(tmp_path, monkeypatch):
client, _ = _make_client(tmp_path)
import core.video.enrichment.engine as eng_mod
calls = {"n": 0}
class FakeEng:
def trending(self):
calls["n"] += 1
return [{"kind": "movie", "tmdb_id": 9}]
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
r = client.get("/api/video/discover/list?key=trending&pages=3")
assert calls["n"] == 1 # fixed list — not refetched per page
assert [it["tmdb_id"] for it in r.get_json()["items"]] == [9]
def test_img_proxy_rejects_non_tmdb(tmp_path):

View file

@ -871,34 +871,37 @@
<div class="vdsc-page" data-vdsc-page>
<div class="vdsc-amb" data-vdsc-amb aria-hidden="true"></div>
<div class="vdsc-hero hidden" data-vdsc-hero></div>
<div class="vdsc-filterbar">
<span class="vdsc-filter-label">Browse</span>
<select class="vdsc-select" data-vdsc-f-kind aria-label="Type">
<option value="movie">Movies</option>
<option value="show">TV Shows</option>
</select>
<select class="vdsc-select" data-vdsc-f-genre aria-label="Genre">
<option value="">All genres</option>
</select>
<select class="vdsc-select" data-vdsc-f-decade aria-label="Decade">
<option value="">Any decade</option>
<option value="2020">2020s</option>
<option value="2010">2010s</option>
<option value="2000">2000s</option>
<option value="1990">1990s</option>
<option value="1980">1980s</option>
<option value="1970">1970s</option>
</select>
<select class="vdsc-select" data-vdsc-f-sort aria-label="Sort">
<option value="popularity.desc">Most popular</option>
<option value="vote_average.desc">Top rated</option>
<option value="vote_count.desc">Most voted</option>
</select>
<button class="vdsc-btn" type="button" data-vdsc-apply>Browse</button>
<label class="vdsc-toggle">
<input type="checkbox" data-vdsc-hideowned>
<span>Hide owned</span>
</label>
<div class="vdsc-browse">
<div class="vdsc-browse-top">
<div class="vdsc-seg" data-vdsc-seg="kind" role="group" aria-label="Type">
<button class="vdsc-seg-btn vdsc-seg-btn--on" type="button" data-val="movie">Movies</button>
<button class="vdsc-seg-btn" type="button" data-val="show">TV Shows</button>
</div>
<div class="vdsc-seg" data-vdsc-seg="sort" role="group" aria-label="Sort">
<button class="vdsc-seg-btn vdsc-seg-btn--on" type="button" data-val="popularity.desc">Popular</button>
<button class="vdsc-seg-btn" type="button" data-val="vote_average.desc">Top Rated</button>
<button class="vdsc-seg-btn" type="button" data-val="vote_count.desc">Most Voted</button>
</div>
<label class="vdsc-toggle">
<input type="checkbox" data-vdsc-hideowned>
<span>Hide owned</span>
</label>
</div>
<div class="vdsc-chips" data-vdsc-chipset="genre" aria-label="Genre">
<button class="vdsc-chip vdsc-chip--on" type="button" data-val="">All genres</button>
</div>
<div class="vdsc-browse-bottom">
<div class="vdsc-chips vdsc-chips--era" data-vdsc-chipset="decade" aria-label="Decade">
<button class="vdsc-chip vdsc-chip--on" type="button" data-val="">Any era</button>
<button class="vdsc-chip" type="button" data-val="2020">2020s</button>
<button class="vdsc-chip" type="button" data-val="2010">2010s</button>
<button class="vdsc-chip" type="button" data-val="2000">2000s</button>
<button class="vdsc-chip" type="button" data-val="1990">1990s</button>
<button class="vdsc-chip" type="button" data-val="1980">1980s</button>
<button class="vdsc-chip" type="button" data-val="1970">1970s</button>
</div>
<button class="vdsc-btn vdsc-browse-go" type="button" data-vdsc-apply>Browse all &rarr;</button>
</div>
</div>
<div class="vdsc-shelves" data-vdsc-shelves></div>
<div class="vdsc-grid-wrap hidden" data-vdsc-grid-wrap>

View file

@ -22,6 +22,7 @@
io: null,
hero: { items: [], idx: 0, timer: null },
cat: { title: '', q: '', page: 1, paginates: true, busy: false },
sel: { kind: 'movie', genre: '', decade: '', sort: 'popularity.desc' }, // Browse panel
};
function $(s, r) { return (r || document).querySelector(s); }
@ -206,7 +207,8 @@
if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return;
shelf.setAttribute('data-vdsc-loaded', '1');
var rail = $('[data-vdsc-rail]', shelf);
fetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q'), { headers: { Accept: 'application/json' } })
// 2 pages (~40 items) so a rail still looks full after 'Hide owned'.
fetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q') + '&pages=2', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var items = (d && d.items) || [];
@ -264,14 +266,11 @@
});
}
function applyFilter() {
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
var genre = ($('[data-vdsc-f-genre]') || {}).value || '';
var decade = ($('[data-vdsc-f-decade]') || {}).value || '';
var sort = ($('[data-vdsc-f-sort]') || {}).value || 'popularity.desc';
var q = ['kind=' + kind, 'sort=' + encodeURIComponent(sort)];
var bits = [kind === 'show' ? 'Shows' : 'Movies'];
if (genre) { q.push('genre=' + genre); var gn = genreName(kind, genre); if (gn) bits.push(gn); }
if (decade) { q.push('decade=' + decade); bits.push(decade + 's'); }
var s = state.sel;
var q = ['kind=' + s.kind, 'sort=' + encodeURIComponent(s.sort)];
var bits = [s.kind === 'show' ? 'Shows' : 'Movies'];
if (s.genre) { q.push('genre=' + s.genre); var gn = genreName(s.kind, s.genre); if (gn) bits.push(gn); }
if (s.decade) { q.push('decade=' + s.decade); bits.push(s.decade + 's'); }
openCategory(bits.join(' · '), q.join('&'));
}
function genreName(kind, id) {
@ -279,12 +278,17 @@
for (var i = 0; i < list.length; i++) if (String(list[i].id) === String(id)) return list[i].name;
return '';
}
function rebuildGenreOptions() {
var sel = $('[data-vdsc-f-genre]'); if (!sel) return;
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
sel.innerHTML = '<option value="">All genres</option>' + (state.genres[kind] || []).map(function (g) {
return '<option value="' + g.id + '">' + esc(g.name) + '</option>';
}).join('');
function renderGenreChips() {
var box = $('[data-vdsc-chipset="genre"]'); if (!box) return;
box.innerHTML = '<button class="vdsc-chip vdsc-chip--on" type="button" data-val="">All genres</button>' +
(state.genres[state.sel.kind] || []).map(function (g) {
return '<button class="vdsc-chip" type="button" data-val="' + g.id + '">' + esc(g.name) + '</button>';
}).join('');
state.sel.genre = '';
}
function setActive(box, el, selector, onClass) {
var all = box.querySelectorAll(selector);
for (var i = 0; i < all.length; i++) all[i].classList.toggle(onClass, all[i] === el);
}
// ── wiring ────────────────────────────────────────────────────────────────
@ -312,6 +316,22 @@
if (shelf) openCategory(shelf.getAttribute('data-vdsc-title'), shelf.getAttribute('data-vdsc-q'));
return;
}
var seg = e.target.closest('.vdsc-seg-btn');
if (seg) {
var sbox = seg.closest('[data-vdsc-seg]');
var which = sbox.getAttribute('data-vdsc-seg');
setActive(sbox, seg, '.vdsc-seg-btn', 'vdsc-seg-btn--on');
state.sel[which] = seg.getAttribute('data-val');
if (which === 'kind') renderGenreChips(); // genres differ by kind
return;
}
var chip = e.target.closest('.vdsc-chip');
if (chip) {
var cbox = chip.closest('[data-vdsc-chipset]');
setActive(cbox, chip, '.vdsc-chip', 'vdsc-chip--on');
state.sel[cbox.getAttribute('data-vdsc-chipset')] = chip.getAttribute('data-val');
return;
}
var arrow = e.target.closest('[data-vdsc-scroll]');
if (arrow) {
var rail = $('[data-vdsc-rail]', arrow.closest('.vdsc-shelf'));
@ -325,8 +345,6 @@
var hero = $('[data-vdsc-hero]');
if (hero) { hero.addEventListener('mouseenter', stopHeroTimer); hero.addEventListener('mouseleave', startHeroTimer); }
var kindSel = $('[data-vdsc-f-kind]');
if (kindSel) kindSel.addEventListener('change', rebuildGenreOptions);
var apply = $('[data-vdsc-apply]'); if (apply) apply.addEventListener('click', applyFilter);
var clear = $('[data-vdsc-clear]'); if (clear) clear.addEventListener('click', closeCategory);
var more = $('[data-vdsc-more]'); if (more) more.addEventListener('click', function () { state.cat.page++; loadGrid(false); });
@ -343,7 +361,7 @@
var g = res[0] || {}, t = res[1] || {};
state.genres = { movie: g.movie || [], show: g.show || [] };
state.taste = { movie: t.movie || [], show: t.show || [] };
rebuildGenreOptions();
renderGenreChips();
renderShelves();
});
}

View file

@ -2198,17 +2198,7 @@ body[data-side="video"] #soulsync-toggle { display: none; }
background: rgba(255, 255, 255, 0.32); transition: all 0.2s ease; }
.vdsc-dot--on { background: hsl(var(--vgm-h, 230), 85%, 65%); width: 26px; border-radius: 5px; }
/* ── filter bar ───────────────────────────────────────────────────────────── */
.vdsc-filterbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 24px;
padding: 14px 16px; border-radius: 14px; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07); }
.vdsc-filter-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.42); margin-right: 2px; }
.vdsc-select { padding: 10px 13px; border-radius: 10px; background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
.vdsc-select:focus { border-color: rgba(var(--accent-rgb, 88 101 242), 0.6); }
.vdsc-select option { background: #16161d; color: #fff; }
/* ── Browse button (shared by the Browse panel + grid header) ─────────────── */
.vdsc-btn { padding: 10px 18px; border-radius: 10px; font-size: 13px; font-weight: 800; cursor: pointer;
border: 1px solid transparent; background: rgb(var(--accent-rgb, 88 101 242)); color: #fff;
box-shadow: 0 8px 22px rgba(var(--accent-rgb, 88 101 242), 0.34); transition: all 0.15s ease; }
@ -2261,7 +2251,7 @@ body[data-side="video"] #soulsync-toggle { display: none; }
/* ── Discover v2: ambient glow + see-all + grid head + hide-owned + polish ─── */
.vdsc-page { position: relative; }
.vdsc-hero, .vdsc-filterbar, .vdsc-shelves, .vdsc-grid-wrap { position: relative; z-index: 1; }
.vdsc-hero, .vdsc-browse, .vdsc-shelves, .vdsc-grid-wrap { position: relative; z-index: 1; }
/* a faint page-top color bleed that follows the current hero slide's hue */
.vdsc-amb { position: absolute; top: -40px; left: 50%; transform: translateX(-50%); z-index: 0;
width: 130%; height: 580px; pointer-events: none; opacity: 0.5; transition: opacity 0.7s ease;
@ -2296,3 +2286,50 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdsc-shelf--in .vdsc-rail { animation: none; }
.vdsc-amb { transition: none; }
}
/* ── Discover Browse panel — segmented controls + genre/era chips ──────────── */
.vdsc-browse { position: relative; z-index: 1; margin: 6px 0 28px; padding: 16px 18px 18px;
border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 18px 50px -30px rgba(var(--accent-rgb, 88 101 242), 0.5); }
.vdsc-browse-top { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
/* segmented control (pill of buttons with one active) */
.vdsc-seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: 11px;
background: rgba(0, 0, 0, 0.28); border: 1px solid rgba(255, 255, 255, 0.07); }
.vdsc-seg-btn { padding: 8px 16px; border: none; background: none; cursor: pointer; border-radius: 9px;
font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.58); white-space: nowrap;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.2s ease; }
.vdsc-seg-btn:hover { color: rgba(255, 255, 255, 0.92); }
.vdsc-seg-btn--on { color: #fff;
background: linear-gradient(135deg, rgba(var(--accent-rgb, 88 101 242), 0.95), rgba(var(--accent-rgb, 88 101 242), 0.72));
box-shadow: 0 6px 18px -6px rgba(var(--accent-rgb, 88 101 242), 0.7); }
/* chip rows (genres, eras) — horizontally scrollable, edge-faded */
.vdsc-chips { display: flex; gap: 8px; overflow-x: auto; padding: 4px 0 6px; margin-top: 14px;
scrollbar-width: thin;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 14px, #000 calc(100% - 14px), transparent);
mask-image: linear-gradient(90deg, transparent, #000 14px, #000 calc(100% - 14px), transparent); }
.vdsc-chips::-webkit-scrollbar { height: 6px; }
.vdsc-chips::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.13); border-radius: 3px; }
.vdsc-chips--era { margin-top: 0; }
.vdsc-chip { flex: 0 0 auto; padding: 7px 14px; border-radius: 999px; cursor: pointer; white-space: nowrap;
font-size: 12.5px; font-weight: 700; color: rgba(255, 255, 255, 0.66);
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.2s ease; }
.vdsc-chip:hover { color: #fff; background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2); }
.vdsc-chip--on { color: #fff; border-color: transparent;
background: linear-gradient(135deg, rgba(var(--accent-rgb, 88 101 242), 0.95), rgba(var(--accent-rgb, 88 101 242), 0.7));
box-shadow: 0 8px 22px -8px rgba(var(--accent-rgb, 88 101 242), 0.8); }
.vdsc-browse-bottom { display: flex; align-items: center; gap: 16px; margin-top: 12px; }
.vdsc-browse-bottom .vdsc-chips { flex: 1; min-width: 0; margin-top: 0; }
.vdsc-browse-go { flex: 0 0 auto; padding: 11px 22px;
box-shadow: 0 10px 26px -8px rgba(var(--accent-rgb, 88 101 242), 0.7); }
.vdsc-browse-go:hover { filter: brightness(1.08); transform: translateY(-1px); }
.vdsc-browse .vdsc-toggle { margin-left: auto; }
@media (max-width: 720px) {
.vdsc-browse-bottom { flex-direction: column; align-items: stretch; }
.vdsc-browse-go { width: 100%; }
}