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; }