diff --git a/api/video/discover.py b/api/video/discover.py index 0a859e9e..8fb5ef45 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -176,6 +176,29 @@ def register_routes(bp): tr = None return jsonify({"trailer": tr or None}) + @bp.route("/discover/languages", methods=["GET", "POST"]) + def video_discover_languages(): + """Get/set the preferred original-languages for general rails (ISO-639-1 codes). + POST {languages: ['en','ko']} (or 'en,ko'); GET returns the current list.""" + from . import get_video_db + db = get_video_db() + try: + if request.method == "POST": + body = request.get_json(silent=True) or {} + langs = body.get("languages") + if isinstance(langs, list): + val = ",".join(str(c).strip().lower() for c in langs if str(c).strip()) + else: + val = ",".join(c.strip().lower() for c in str(langs or "").split(",") if c.strip()) + db.set_setting("discover_languages", val or "en") + return jsonify({"success": True, + "languages": [c for c in (val or "en").split(",") if c]}) + raw = db.get_setting("discover_languages", "en") or "en" + return jsonify({"languages": [c.strip() for c in raw.split(",") if c.strip()]}) + except Exception: + logger.exception("discover languages get/set failed") + return jsonify({"languages": ["en"]}) + @bp.route("/discover/genres", methods=["GET"]) def video_discover_genres(): """Genre id→name maps for both kinds (powers the genre rails + filter).""" @@ -211,8 +234,19 @@ 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 + lang = (request.args.get("lang") or "").strip() or None # explicit (foreign rail) hide_owned = (request.args.get("hide_owned") or "") in ("1", "true", "yes") + # Preferred original-languages (multi) for GENERAL/curated rails — so the feeds + # aren't flooded with foreign titles (e.g. Bollywood in Popular/Trending). A rail + # with an explicit `lang` (a dedicated foreign rail) bypasses this. Default 'en'. + prefer_langs = None + if not lang: + try: + from . import get_video_db + raw = get_video_db().get_setting("discover_languages", "en") or "en" + prefer_langs = {c.strip().lower() for c in raw.split(",") if c.strip()} or None + except Exception: + prefer_langs = {"en"} def fetch(p): if key == "trending": @@ -224,11 +258,11 @@ def register_routes(bp): try: items, seen = [], set() - # 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 + # When filtering (hide-owned or language), page DEEPER and drop items server-side + # until the rail has ~enough to look full — instead of returning a half-empty rail. + need_fill = hide_owned or bool(prefer_langs) + target = 24 if need_fill else 0 + max_pages = 8 if need_fill else pages for offset in range(max_pages): batch = fetch(page + offset) or [] for it in batch: @@ -238,13 +272,17 @@ def register_routes(bp): seen.add(dk) if hide_owned and it.get("library_id") is not None: continue + if prefer_langs: + ol = (it.get("original_language") or "").lower() + if ol and ol not in prefer_langs: + continue # known foreign language not in your preference items.append(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 + break # enough collected + if not target and offset + 1 >= pages: + break # no filtering: 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 3b93adf9..e076136c 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -482,6 +482,8 @@ class TMDBClient: "year": ((it.get("release_date") if is_movie else it.get("first_air_date")) or "")[:4] or None, "rating": it.get("vote_average") or None, "overview": it.get("overview") or None, + "original_language": it.get("original_language") or None, # for the language filter + "popularity": it.get("popularity") or None, # for blended ranking "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None, "backdrop": (self.BACKDROP_W + it["backdrop_path"]) if it.get("backdrop_path") else None, }) diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index f60d39dc..e7239b86 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -72,10 +72,10 @@ '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&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' }, + { 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' }, ]; // 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). @@ -93,17 +93,17 @@ // 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&lang=en' }); used['m:' + name.toLowerCase()] = 1; } + if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); 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&lang=en' }); } + if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); } }); 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&lang=en' }); + out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); }); return out.concat(DECADE_RAILS).concat(FOREIGN_RAILS); }