diff --git a/api/video/discover.py b/api/video/discover.py index 396a282a..0a859e9e 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -85,6 +85,31 @@ def register_routes(bp): logger.exception("discover morelike failed") return jsonify({"rails": []}) + @bp.route("/discover/foryou", methods=["GET"]) + def video_discover_foryou(): + """A single 'Recommended for you' wall blended from many owned titles — a title + recommended by more of your library ranks higher (consensus).""" + from . import get_video_db + from core.video.enrichment.engine import get_video_enrichment_engine + from core.video.discovery_recs import blend_recommendations + try: + from core.video.sources import resolve_video_server + srv = resolve_video_server() + except Exception: + srv = None + db = get_video_db() + eng = get_video_enrichment_engine() + try: + seeds = db.random_owned_titles(6, srv) # up to 6 movies + 6 shows + seed_ids = [s["tmdb_id"] for s in seeds if s.get("tmdb_id")] + rec_lists = [eng.recommendations(s["kind"], s["tmdb_id"]) + for s in seeds if s.get("tmdb_id")] + items = blend_recommendations(rec_lists, exclude_ids=seed_ids, limit=40) + return jsonify({"items": items}) + except Exception: + logger.exception("discover foryou failed") + return jsonify({"items": []}) + @bp.route("/discover/gaps", methods=["GET"]) def video_discover_gaps(): """'What am I missing?' rails — franchises you've started but not finished, and diff --git a/core/video/discovery_recs.py b/core/video/discovery_recs.py new file mode 100644 index 00000000..657998e5 --- /dev/null +++ b/core/video/discovery_recs.py @@ -0,0 +1,71 @@ +"""Blend per-title TMDB recommendations into one ranked "Recommended for you" wall. + +The discover page already does per-title rails ("More like Dune"). This aggregates the +recommendations of MANY owned titles into a single personalized wall: a candidate +recommended by *more* of your titles ranks higher (consensus is a stronger signal than +any one seed), ties broken by rating then popularity. Owned titles (the engine annotates +each recommendation with ``library_id`` when owned) and the seed titles themselves are +excluded, so the wall is all stuff you don't have. + +Pure + I/O-free: the discover API fetches each seed's recommendations (cached) and passes +the lists here, so the dedup/consensus ranking is unit-testable without TMDB. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, List + + +def blend_recommendations( + rec_lists: List[List[Dict[str, Any]]], + *, + exclude_ids: Iterable = (), + limit: int = 40, +) -> List[Dict[str, Any]]: + """Aggregate ``rec_lists`` (one recommendation list per seed title) into a single + ranked, deduped list of un-owned titles. + + Each item is a dict with ``tmdb_id`` / ``kind`` (and optionally ``library_id`` set by + the engine when owned, plus ``rating`` / ``popularity``). A title appearing across more + seed lists scores higher; ties fall back to rating then popularity. Owned items + (``library_id`` not None) and ``exclude_ids`` (the seeds) are dropped. ``limit`` caps + the result (0 = all). + """ + exclude = set() + for x in exclude_ids or []: + try: + exclude.add(int(x)) + except (TypeError, ValueError): + continue + + agg: Dict[tuple, Dict[str, Any]] = {} + for lst in rec_lists or []: + for it in lst or []: + if not isinstance(it, dict): + continue + if it.get("library_id") is not None: # owned (engine-annotated) — skip + continue + tid = it.get("tmdb_id") + try: + tid = int(tid) + except (TypeError, ValueError): + continue + if tid in exclude: + continue + key = (it.get("kind"), tid) + entry = agg.get(key) + if entry is None: + agg[key] = {"item": it, "count": 1} + else: + entry["count"] += 1 + + ranked = sorted( + agg.values(), + key=lambda e: (e["count"], e["item"].get("rating") or 0, e["item"].get("popularity") or 0), + reverse=True, + ) + items = [e["item"] for e in ranked] + return items[:limit] if limit and limit > 0 else items + + +__all__ = ["blend_recommendations"] diff --git a/tests/video/test_discovery_recs.py b/tests/video/test_discovery_recs.py new file mode 100644 index 00000000..b5e57092 --- /dev/null +++ b/tests/video/test_discovery_recs.py @@ -0,0 +1,51 @@ +"""Blended 'Recommended for you' aggregation (#discover phase 2).""" +from __future__ import annotations + +from core.video.discovery_recs import blend_recommendations + + +def _it(tid, kind="movie", rating=0, pop=0, owned=False): + d = {"tmdb_id": tid, "kind": kind, "rating": rating, "popularity": pop} + if owned: + d["library_id"] = 99 + return d + + +def test_consensus_ranks_higher(): + # title 2 recommended by 3 seeds, title 1 by 1 -> title 2 first + lists = [[_it(1), _it(2)], [_it(2)], [_it(2), _it(3)]] + out = blend_recommendations(lists) + assert [i["tmdb_id"] for i in out][0] == 2 + + +def test_excludes_owned_and_seeds(): + lists = [[_it(1), _it(2, owned=True), _it(3)]] + out = blend_recommendations(lists, exclude_ids=[1]) + assert [i["tmdb_id"] for i in out] == [3] # 1 = seed, 2 = owned + + +def test_ties_break_by_rating_then_popularity(): + lists = [[_it(1, rating=7, pop=10), _it(2, rating=9, pop=5), _it(3, rating=9, pop=50)]] + # all count=1 -> rating desc (3,2 tie at 9 -> pop desc: 3 then 2), then 1 + assert [i["tmdb_id"] for i in blend_recommendations(lists)] == [3, 2, 1] + + +def test_dedup_same_title_across_lists_counts_once_per_list(): + lists = [[_it(5)], [_it(5)], [_it(5)]] + out = blend_recommendations(lists) + assert len(out) == 1 and out[0]["tmdb_id"] == 5 + + +def test_kind_distinguishes_same_tmdb_id(): + lists = [[_it(7, kind="movie"), _it(7, kind="show")]] + assert len(blend_recommendations(lists)) == 2 + + +def test_limit(): + lists = [[_it(i, pop=i) for i in range(1, 11)]] + assert len(blend_recommendations(lists, limit=3)) == 3 + + +def test_empty(): + assert blend_recommendations([]) == [] + assert blend_recommendations([[], None]) == [] diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index 503f79cc..f60d39dc 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -309,6 +309,30 @@ .catch(function () { /* gaps are best-effort */ }); } + // ── "Recommended for you" — one wall blended from across your library, prepended on top ─ + function loadForYou() { + fetch('/api/video/discover/foryou', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + var items = (d && d.items) || []; + var host = $('[data-vdsc-shelves]'); + if (items.length < 6 || !host) return; + var html = '
' + + '
' + + '

Recommended for you

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + items.map(card).join('') + '
' + + '
'; + host.insertAdjacentHTML('afterbegin', html); + hydrateGet(host); + }) + .catch(function () { /* best-effort */ }); + } + // ── shelves (lazy rails) ────────────────────────────────────────────────── function renderShelves() { var host = $('[data-vdsc-shelves]'); if (!host) return; @@ -527,6 +551,7 @@ renderShelves(); loadMoreLike(); loadGaps(); + loadForYou(); }); } // Infinite scroll: a sentinel near the grid bottom pulls the next page. @@ -565,6 +590,7 @@ renderShelves(); loadMoreLike(); // prepend personalized 'More like…' rails when ready loadGaps(); // prepend 'what am I missing' (franchise + person) gap rails + loadForYou(); // prepend the blended 'Recommended for you' wall (sits on top) }); } function showEmpty() {