diff --git a/api/video/discover.py b/api/video/discover.py
index 48425e88..6b413d7b 100644
--- a/api/video/discover.py
+++ b/api/video/discover.py
@@ -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()
diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py
index 4afdf505..2500d7f0 100644
--- a/core/video/enrichment/clients.py
+++ b/core/video/enrichment/clients.py
@@ -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:
diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py
index 2385469b..c315b76e 100644
--- a/core/video/enrichment/engine.py
+++ b/core/video/enrichment/engine.py
@@ -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)
diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py
index bc558d42..199e5dcc 100644
--- a/tests/test_video_enrichment.py
+++ b/tests/test_video_enrichment.py
@@ -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 = []
diff --git a/webui/index.html b/webui/index.html
index 82c1c328..42cc5cfe 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -890,6 +890,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -924,6 +935,8 @@
Nothing here
Try a different genre, decade or sort.
+
+
diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js
index 5b26804b..6060d3d8 100644
--- a/webui/static/video/video-discover.js
+++ b/webui/static/video/video-discover.js
@@ -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() {
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index 318ed406..07ebaf11 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -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; }