video discover phase 2: blended 'Recommended for you' wall

A single personalized wall aggregating TMDB recommendations across many of your owned titles
(random_owned_titles seeds), ranked by consensus — a title recommended by more of your library
ranks higher (ties by rating then popularity), owned + seed titles excluded.
- core/video/discovery_recs.py: pure blend_recommendations (dedup/consensus/exclude), 7 tests.
- /api/video/discover/foryou aggregates ~12 seeds' recommendations.
- loadForYou() prepends the 'Recommended for you' rail on top of the stack; re-runs on the
  hide-owned toggle.
This commit is contained in:
BoulderBadgeDad 2026-06-23 00:05:26 -07:00
parent d984895cba
commit e7b1a239b4
4 changed files with 173 additions and 0 deletions

View file

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

View file

@ -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"]

View file

@ -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]) == []

View file

@ -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 = '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--foryou" data-vdsc-loaded="1">' +
'<div class="vdsc-shelf-head">' +
'<h3 class="vdsc-shelf-title">Recommended for you</h3>' +
'<div class="vdsc-shelf-nav">' +
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left"></button>' +
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right"></button>' +
'</div>' +
'</div>' +
'<div class="vdsc-rail" data-vdsc-rail>' + items.map(card).join('') + '</div>' +
'</section>';
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() {