video discover: keep foreign titles out of general rails + fix hide-owned sparse rows

Two Discover UX issues:
- Foreign-language titles leaked into the general genre/decade rails. Added an
  original-language filter (with_original_language) through client.discover -> discover_filter
  -> /discover/list (?lang=); the genre/decade/'because you like' rails now pin lang=en, and a
  handful of dedicated foreign rails (Korean/Japanese/Spanish/French/Hindi) house non-English.
- 'Hide owned' + a huge library = nearly-empty rails (a 2-page batch was mostly owned, then
  CSS-hidden to almost nothing). /discover/list now takes hide_owned=1: it drops owned
  server-side and pages DEEPER (up to 8) until a rail has ~24 un-owned. fillShelf passes
  hide_owned when the toggle's on; toggling re-renders the rails (+ personalized rows) instead
  of just CSS-hiding cards.
This commit is contained in:
BoulderBadgeDad 2026-06-22 23:59:21 -07:00
parent a68d9755a9
commit d984895cba
4 changed files with 60 additions and 22 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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)",

View file

@ -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.