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:
parent
d984895cba
commit
e7b1a239b4
4 changed files with 173 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
71
core/video/discovery_recs.py
Normal file
71
core/video/discovery_recs.py
Normal 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"]
|
||||
51
tests/video/test_discovery_recs.py
Normal file
51
tests/video/test_discovery_recs.py
Normal 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]) == []
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue