diff --git a/api/video/discover.py b/api/video/discover.py index 7ba6c450..396a282a 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -186,6 +186,8 @@ def register_routes(bp): decade = request.args.get("decade") or None providers = request.args.get("providers") or None sort = request.args.get("sort") or "popularity.desc" + lang = (request.args.get("lang") or "").strip() or None # original-language filter + hide_owned = (request.args.get("hide_owned") or "") in ("1", "true", "yes") def fetch(p): if key == "trending": @@ -193,19 +195,31 @@ def register_routes(bp): if key: return eng.discover_curated(key, page=p) return eng.discover_filter(kind, genre=genre, year=year, decade=decade, - providers=providers, sort_by=sort, page=p) + providers=providers, sort_by=sort, page=p, language=lang) try: items, seen = [], set() - for p in range(page, page + pages): - for it in (fetch(p) or []): + # Hiding owned + a huge library means most popular titles are already owned, so + # page DEEPER and drop owned server-side until the rail has enough un-owned to + # look full (instead of the client CSS-hiding most of a 2-page batch to nothing). + target = 24 if hide_owned else 0 + max_pages = 8 if hide_owned else pages + for offset in range(max_pages): + batch = fetch(page + offset) or [] + for it in batch: dk = (it.get("kind"), it.get("tmdb_id")) if dk in seen: continue seen.add(dk) + if hide_owned and it.get("library_id") is not None: + continue items.append(it) - if key == "trending": - break # trending is a fixed list — extra pages just repeat it + if key == "trending" or not batch: + break # trending is a fixed list; empty batch = TMDB ran out + if target and len(items) >= target: + break # enough un-owned collected + if not hide_owned and offset + 1 >= pages: + break # normal mode: respect the requested page count return jsonify({"items": items, "page": page}) except Exception: logger.exception("discover list failed (key=%s)", key) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 31e94bdd..3b93adf9 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -512,9 +512,11 @@ class TMDBClient: return self._disc_map((r.json() or {}).get("results"), kind) def discover(self, kind, *, genre=None, year=None, decade=None, providers=None, - sort_by="popularity.desc", page=1, region="US"): + sort_by="popularity.desc", page=1, region="US", language=None): """Browse /discover/{movie,tv} filtered by genre / year / decade / streaming - provider (``providers`` is a TMDB provider id; needs ``region``).""" + provider (``providers`` is a TMDB provider id; needs ``region``). ``language`` + restricts to an original-language (ISO-639-1, e.g. 'en') so general rails aren't + flooded with foreign-language titles; pass a non-English code for a foreign rail.""" if not self.api_key: return [] import requests @@ -522,6 +524,8 @@ class TMDBClient: path = "/discover/movie" if is_movie else "/discover/tv" params = {"api_key": self.api_key, "sort_by": sort_by, "page": page, "include_adult": "false", "vote_count.gte": 40} + if language: + params["with_original_language"] = language if genre: params["with_genres"] = genre if providers: diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index fb29ed02..da69ecb9 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -363,21 +363,21 @@ class VideoEnrichmentEngine: return self._stamp_owned(items) 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 / streaming provider, - cached + owned-annotated.""" + sort_by="popularity.desc", page=1, language=None) -> list: + """Browse /discover filtered by genre / year / decade / streaming provider / + original language, 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, providers, sort_by, page) + ck = ("disc-flt", kind, genre, year, decade, providers, sort_by, page, language) items = self._cache_get(ck) if items is None: try: items = w.client.discover(kind, genre=genre, year=year, decade=decade, providers=providers, sort_by=sort_by, page=page, - region=self._region()) or [] + region=self._region(), language=language) or [] self._cache_put(ck, items, ttl=3600) except Exception: logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)", diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index dd058cb3..503f79cc 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -72,10 +72,19 @@ 'talk': '148, 163, 184', 'news': '148, 163, 184', }; var DECADE_RAILS = [ - { title: 'Best of the 2010s', q: 'kind=movie&decade=2010&sort=vote_average.desc' }, - { title: '2000s Favorites', q: 'kind=movie&decade=2000&sort=vote_average.desc' }, - { title: '’90s Classics', q: 'kind=movie&decade=1990&sort=vote_average.desc' }, - { title: 'Retro ’80s', q: 'kind=movie&decade=1980&sort=vote_average.desc' }, + { title: 'Best of the 2010s', q: 'kind=movie&decade=2010&sort=vote_average.desc&lang=en' }, + { title: '2000s Favorites', q: 'kind=movie&decade=2000&sort=vote_average.desc&lang=en' }, + { title: '’90s Classics', q: 'kind=movie&decade=1990&sort=vote_average.desc&lang=en' }, + { title: 'Retro ’80s', q: 'kind=movie&decade=1980&sort=vote_average.desc&lang=en' }, + ]; + // Dedicated foreign-language rails so non-English titles live HERE rather than + // leaking into the general genre/decade rails (which are pinned to lang=en). + var FOREIGN_RAILS = [ + { title: 'Korean Cinema', q: 'kind=movie&sort=popularity.desc&lang=ko' }, + { title: 'Japanese Films', q: 'kind=movie&sort=popularity.desc&lang=ja' }, + { title: 'Spanish-Language', q: 'kind=movie&sort=popularity.desc&lang=es' }, + { title: 'French Cinema', q: 'kind=movie&sort=popularity.desc&lang=fr' }, + { title: 'Hindi Cinema', q: 'kind=movie&sort=popularity.desc&lang=hi' }, ]; function buildShelfList() { @@ -84,19 +93,19 @@ // personalized first — seeded from what you actually own (state.taste.movie || []).slice(0, 3).forEach(function (name) { var id = gm[name.toLowerCase()]; - if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); used['m:' + name.toLowerCase()] = 1; } + if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc&lang=en' }); used['m:' + name.toLowerCase()] = 1; } }); (state.taste.show || []).slice(0, 2).forEach(function (name) { var id = gs[name.toLowerCase()]; - if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); } + if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc&lang=en' }); } }); out = out.concat(CURATED); GENRE_RAILS.forEach(function (name) { var id = gm[name.toLowerCase()]; if (id != null && !used['m:' + name.toLowerCase()]) - out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); + out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc&lang=en' }); }); - return out.concat(DECADE_RAILS); + return out.concat(DECADE_RAILS).concat(FOREIGN_RAILS); } // ── card (mirrors the search title card: owned ribbon + get button) ─────── @@ -334,12 +343,18 @@ }, { rootMargin: '400px 0px' }); for (var j = 0; j < shelves.length; j++) state.io.observe(shelves[j]); } + function isHideOwned() { + var h = $('[data-vdsc-hideowned]'); + return !!(h && h.checked); + } function fillShelf(shelf) { if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return; shelf.setAttribute('data-vdsc-loaded', '1'); var rail = $('[data-vdsc-rail]', shelf); - // 2 pages (~40 items) so a rail still looks full after 'Hide owned'. - cachedFetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q') + '&pages=2') + // Normal: 2 pages (~40 items). Hiding owned: let the backend page DEEPER and drop + // owned server-side, so a huge library's rail still fills instead of CSS-hiding to ~nothing. + var q = shelf.getAttribute('data-vdsc-q') + (isHideOwned() ? '&hide_owned=1' : '&pages=2'); + cachedFetch(LIST_URL + '?' + q) .then(function (d) { var items = (d && d.items) || []; if (!items.length) { shelf.remove(); return; } // drop empty shelves @@ -507,6 +522,11 @@ hide.addEventListener('change', function () { page.classList.toggle('vdsc-hide-owned', hide.checked); try { localStorage.setItem('vdsc_hideowned', hide.checked ? '1' : '0'); } catch (e) { /* ignore */ } + // The rails are now server-filtered for owned (and page deeper), so rebuild + // them with the new state instead of just CSS-hiding cards in place. + renderShelves(); + loadMoreLike(); + loadGaps(); }); } // Infinite scroll: a sentinel near the grid bottom pulls the next page.