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.
71 lines
2.6 KiB
Python
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"]
|