soulsync/core/video/discovery_recs.py
BoulderBadgeDad e7b1a239b4 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.
2026-06-23 00:05:26 -07:00

71 lines
2.6 KiB
Python

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