feat(discover): Surprise me leans on what you watch, and stays a surprise

The Home hero's surprise tier and the Plex Discover rail were uniform draws over the unseen. They
are now weighted draws (`app.draw`, Efraimidis-Spirakis over the same md5 shuffle): a title's
weight is 1 + its affinity. Plex: the genres of the latest watched titles (half-life 180 days, a
show counts once) and the TMDB recommendations stored on them (`plex.affinity`). YouTube: the
channels the user watches (half-life 90 days). One slot of three stays a blind draw, and with no
history the order is exactly the old one. No visible change.

The YouTube draw builds its pool once (a materialized CTE read twice): the scope is ~200k videos on
a prod-sized catalog, and a second ordered pass doubled the cost. Measured on localdev: a reroll
210 -> 292 ms.
This commit is contained in:
2026-11-07 02:41:30 +01:00
parent 5b846136ad
commit 9310ff85bf
7 changed files with 598 additions and 31 deletions
+21 -13
View File
@@ -22,7 +22,8 @@ from sqlalchemy.orm import Session
from app.collation import sorted_words
from app.discover.queries import RAIL_LIMIT
from app.models import User
from app.plex import rollup
from app import draw
from app.plex import affinity, rollup
from app.plex.cards import SHOW_NEW, display_title, leaf_card, movie_card, show_card
from app.plex.models import PlexItem, PlexShow, PlexState
from app.plex.query import (
@@ -399,22 +400,25 @@ def discover_page(db: Session, user: User, scope: str, lang: str, f: LibraryFilt
}
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str], weight=None) -> list:
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
fresh = (
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
.scalars()
.all()
if exclude
else []
)
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail.
With a `weight` (`plex.affinity`) the draw leans on it, every third slot left blind (`app.draw`)."""
def pick(q, n: int) -> list:
blind = db.execute(q.order_by(func.random()).limit(n)).scalars().all()
if weight is None:
return list(blind)
key = draw.weighted_key(draw.random_u(), weight)
leaning = db.execute(q.order_by(key).limit(n)).scalars().all()
return draw.blend(list(leaning), list(blind), 1, n, key=lambda r: r.rating_key)
fresh = pick(stmt.where(key_col.not_in(exclude)), per) if exclude else []
if len(fresh) == per:
return list(fresh)
return fresh
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
return [*fresh, *rest.all()]
return [*fresh, *pick(stmt.where(*taken), per - len(fresh))]
def _kept(
@@ -474,6 +478,8 @@ def surprise(
if keep:
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
per = min(limit, SURPRISE_PER_KIND)
# What the user's history leans towards; with none, every title weighs the same (a plain draw).
leaning = affinity.for_user(db, user.id)
movies = []
if movie_ids:
# Exclude movies this user has watched.
@@ -501,6 +507,7 @@ def surprise(
PlexItem.rating_key,
per,
exclude,
leaning.weight(PlexItem) if leaning else None,
)
shows = []
if show_ids:
@@ -523,6 +530,7 @@ def surprise(
PlexShow.rating_key,
per,
exclude,
leaning.weight(PlexShow) if leaning else None,
)
mcards = _movie_cards(db, user.id, movies, lang)
scards = _show_cards(db, user.id, shows, lang)