Discover: streaming-provider filter + infinite scroll + a11y polish

Provider filter (#4):
- client.discover() + engine.discover_filter() take a TMDB provider id and pass
  with_watch_providers + watch_region (engine._region) + flatrate. Browse gets a
  streaming-service chip row (Netflix/Prime/Disney+/Max/Apple TV+/Hulu/Paramount+/
  Peacock); the grid title reflects 'on <service>'.

Infinite scroll (#6):
- Grid paginates via an IntersectionObserver sentinel (600px lookahead) with a
  bottom spinner; the Load more button stays only as a no-IO fallback.

Polish (#7):
- Hero keyboard nav (←/→ when Discover is the visible view, ignoring inputs and
  while the trailer is open); focus-visible rings on chips/segments/cards/arrows.

Note: 'complete-the-franchise' rail (#5) needs a collection_id per movie, which
the schema doesn't store yet — deferred (would need an enrichment pass).

Tests: +1 (provider watch_region params); updated the discover_filter fake for
the new kwargs. Enrichment + API suites: 115 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 14:55:13 -07:00
parent a9ec025706
commit 2b3ca12310
7 changed files with 93 additions and 24 deletions

View file

@ -134,6 +134,7 @@ def register_routes(bp):
genre = request.args.get("genre") or None
year = request.args.get("year") or None
decade = request.args.get("decade") or None
providers = request.args.get("providers") or None
sort = request.args.get("sort") or "popularity.desc"
def fetch(p):
@ -142,7 +143,7 @@ def register_routes(bp):
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)
providers=providers, sort_by=sort, page=p)
try:
items, seen = [], set()

View file

@ -505,9 +505,10 @@ class TMDBClient:
r.raise_for_status()
return self._disc_map((r.json() or {}).get("results"), kind)
def discover(self, kind, *, genre=None, year=None, decade=None,
sort_by="popularity.desc", page=1):
"""Browse /discover/{movie,tv} filtered by genre / year / decade."""
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1, region="US"):
"""Browse /discover/{movie,tv} filtered by genre / year / decade / streaming
provider (``providers`` is a TMDB provider id; needs ``region``)."""
if not self.api_key:
return []
import requests
@ -517,6 +518,10 @@ class TMDBClient:
"include_adult": "false", "vote_count.gte": 40}
if genre:
params["with_genres"] = genre
if providers:
params["with_watch_providers"] = providers
params["watch_region"] = region or "US"
params["with_watch_monetization_types"] = "flatrate" # streaming, not rent/buy
if year:
params["primary_release_year" if is_movie else "first_air_date_year"] = year
if decade:

View file

@ -333,23 +333,26 @@ class VideoEnrichmentEngine:
return []
return self._stamp_owned(items)
def discover_filter(self, kind, *, genre=None, year=None, decade=None,
def discover_filter(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1) -> list:
"""Browse /discover filtered by genre / year / decade, cached + annotated."""
"""Browse /discover filtered by genre / year / decade / streaming provider,
cached + owned-annotated."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
if kind not in ("movie", "show"):
kind = "movie"
ck = ("disc-flt", kind, genre, year, decade, sort_by, page)
ck = ("disc-flt", kind, genre, year, decade, providers, sort_by, page)
items = self._cache_get(ck)
if items is None:
try:
items = w.client.discover(kind, genre=genre, year=year, decade=decade,
sort_by=sort_by, page=page) or []
providers=providers, sort_by=sort_by, page=page,
region=self._region()) or []
self._cache_put(ck, items, ttl=3600)
except Exception:
logger.exception("discover filter failed (%s g=%s y=%s d=%s)", kind, genre, year, decade)
logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)",
kind, genre, year, decade, providers)
return []
return self._stamp_owned(items)

View file

@ -1070,18 +1070,28 @@ def test_engine_discover_filter_normalizes_kind_and_passes_filters(db):
class Tmdb:
enabled = True
def discover(self, kind, *, genre=None, year=None, decade=None,
sort_by="popularity.desc", page=1):
captured.update(kind=kind, genre=genre, decade=decade, sort_by=sort_by)
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1, region="US"):
captured.update(kind=kind, genre=genre, decade=decade, providers=providers, sort_by=sort_by)
return [{"kind": "movie", "tmdb_id": 9, "title": "X"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.discover_filter("bogus", genre=28, decade=2010, sort_by="vote_average.desc")
res = eng.discover_filter("bogus", genre=28, decade=2010, providers=8, sort_by="vote_average.desc")
assert captured["kind"] == "movie" # unknown kind normalised
assert captured["genre"] == 28 and captured["decade"] == 2010
assert captured["genre"] == 28 and captured["decade"] == 2010 and captured["providers"] == 8
assert captured["sort_by"] == "vote_average.desc"
assert res[0]["library_id"] is None # not owned → None
def test_tmdb_discover_provider_sets_watch_region(monkeypatch):
captured = {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(
get=lambda u, **k: (captured.update(params=k.get("params")), _Resp({"results": []}))[1]))
TMDBClient("KEY").discover("movie", providers=8, region="GB")
p = captured["params"]
assert p["with_watch_providers"] == 8 and p["watch_region"] == "GB"
assert p["with_watch_monetization_types"] == "flatrate"
def test_engine_genre_list_caches(db):
calls = []

View file

@ -890,6 +890,17 @@
<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-chips" data-vdsc-chipset="providers" aria-label="Streaming service">
<button class="vdsc-chip vdsc-chip--on" type="button" data-val="">Any service</button>
<button class="vdsc-chip" type="button" data-val="8">Netflix</button>
<button class="vdsc-chip" type="button" data-val="9">Prime Video</button>
<button class="vdsc-chip" type="button" data-val="337">Disney+</button>
<button class="vdsc-chip" type="button" data-val="1899">Max</button>
<button class="vdsc-chip" type="button" data-val="350">Apple TV+</button>
<button class="vdsc-chip" type="button" data-val="15">Hulu</button>
<button class="vdsc-chip" type="button" data-val="531">Paramount+</button>
<button class="vdsc-chip" type="button" data-val="386">Peacock</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>
@ -924,6 +935,8 @@
<div class="vsr-state-title">Nothing here</div>
<div class="vsr-state-sub">Try a different genre, decade or sort.</div>
</div>
<div class="vdsc-sentinel" data-vdsc-sentinel aria-hidden="true"></div>
<div class="vsr-loading hidden" data-vdsc-more-loading><div class="loading-spinner"></div></div>
<div class="vdsc-more-wrap">
<button class="vdsc-btn vdsc-more hidden" type="button" data-vdsc-more>Load more</button>
</div>

View file

@ -21,9 +21,10 @@
genres: { movie: [], show: [] }, taste: { movie: [], show: [] },
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
cat: { title: '', q: '', page: 1, paginates: true, busy: false, hasMore: false },
sel: { kind: 'movie', genre: '', decade: '', providers: '', sort: 'popularity.desc' }, // Browse panel
};
var AUTO = (typeof IntersectionObserver !== 'undefined'); // infinite-scroll capable
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) {
@ -302,7 +303,7 @@
// ── category / filter grid (paged) ────────────────────────────────────────
function openCategory(title, q) {
state.mode = 'grid';
state.cat = { title: title, q: q, page: 1, paginates: !/key=trending/.test(q), busy: false };
state.cat = { title: title, q: q, page: 1, paginates: !/key=trending/.test(q), busy: false, hasMore: false };
$('[data-vdsc-shelves]').classList.add('hidden');
var hero = $('[data-vdsc-hero]'); if (hero) hero.classList.add('hidden');
var wrap = $('[data-vdsc-grid-wrap]'); wrap.classList.remove('hidden');
@ -321,8 +322,9 @@
var c = state.cat;
if (c.busy) return;
c.busy = true;
var more = $('[data-vdsc-more]'); if (more) { more.disabled = true; more.textContent = 'Loading…'; }
var ld = $('[data-vdsc-grid-loading]'); if (ld && reset) ld.classList.remove('hidden');
var more = $('[data-vdsc-more]'); if (more && !reset) { more.disabled = true; more.textContent = 'Loading…'; }
var ld = reset ? $('[data-vdsc-grid-loading]') : $('[data-vdsc-more-loading]');
if (ld) ld.classList.remove('hidden');
fetch(LIST_URL + '?' + c.q + '&page=' + c.page, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
@ -333,11 +335,10 @@
if (grid) { grid.insertAdjacentHTML('beforeend', items.map(card).join('')); hydrateGet(grid); }
var empty = $('[data-vdsc-grid-empty]');
if (empty) empty.classList.toggle('hidden', !(reset && !items.length));
if (more) {
more.textContent = 'Load more';
more.disabled = false;
more.classList.toggle('hidden', !c.paginates || items.length < 18);
}
c.hasMore = c.paginates && items.length >= 18;
// With infinite scroll the sentinel pulls more — keep the button only
// as a no-IntersectionObserver fallback.
if (more) { more.textContent = 'Load more'; more.disabled = false; more.classList.toggle('hidden', AUTO || !c.hasMore); }
})
.catch(function () {
c.busy = false;
@ -345,11 +346,16 @@
if (more) { more.textContent = 'Load more'; more.disabled = false; }
});
}
function activeChipText(chipset) {
var c = $('[data-vdsc-chipset="' + chipset + '"] .vdsc-chip--on');
return (c && c.getAttribute('data-val')) ? c.textContent : '';
}
function applyFilter() {
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.providers) { q.push('providers=' + s.providers); var pn = activeChipText('providers'); if (pn) bits.push('on ' + pn); }
if (s.decade) { q.push('decade=' + s.decade); bits.push(s.decade + 's'); }
openCategory(bits.join(' · '), q.join('&'));
}
@ -457,6 +463,28 @@
});
}
window.addEventListener('resize', positionSegs);
// Infinite scroll: a sentinel near the grid bottom pulls the next page.
var sentinel = $('[data-vdsc-sentinel]');
if (sentinel && AUTO) {
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting && state.mode === 'grid' && state.cat.hasMore && !state.cat.busy) {
state.cat.page++; loadGrid(false);
}
}, { rootMargin: '600px 0px' }).observe(sentinel);
}
// Hero keyboard nav (←/→) when Discover is the visible view.
document.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (trailerEl || state.mode !== 'shelves' || !state.hero.items.length) return;
if (!page || page.offsetParent === null) return; // Discover not visible
var tag = e.target && e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
goHero(state.hero.idx + (e.key === 'ArrowRight' ? 1 : -1));
startHeroTimer();
});
}
function loadMeta() {

View file

@ -2385,3 +2385,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdsc-tr-none { color: rgba(255, 255, 255, 0.6); font-size: 15px; font-weight: 600; }
.vdsc-tr-title { margin-top: 14px; text-align: center; font-size: 15px; font-weight: 700; color: rgba(255, 255, 255, 0.85); }
@media (prefers-reduced-motion: reduce) { .vdsc-tr-frame { transition: none; } }
/* infinite-scroll sentinel (invisible trigger near the grid bottom) */
.vdsc-sentinel { height: 1px; width: 100%; }
/* keyboard focus rings (a11y) */
.vdsc-chip:focus-visible, .vdsc-seg-btn:focus-visible, .vdsc-arrow:focus-visible, .vdsc-seeall:focus-visible,
.vdsc-btn:focus-visible, .vdsc-hero-trailer:focus-visible, .vdsc-dot:focus-visible, .vdsc-toggle:focus-within,
.vdsc-page .vsr-card:focus-visible {
outline: 2px solid rgba(var(--accent-rgb, 88 101 242), 0.9); outline-offset: 2px; }