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:
parent
a9ec025706
commit
2b3ca12310
7 changed files with 93 additions and 24 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue