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
+120
View File
@@ -0,0 +1,120 @@
"""What a user's Plex history leans towards, as a weight for a surprise draw (`app.draw`).
Two signals, both already in the database (no TMDB call):
- **genres** of the titles the user watched, the recent ones weighing more (a half-life of
HALF_LIFE_DAYS), each scored 0..1 against the strongest;
- the **TMDB recommendations** stored on those titles (`recommendations`), each scored by the most
recent title that recommends it.
A title counts once however many of its episodes were watched — a binge of one show would otherwise
outvote every film — and only the RECENT_TITLES latest titles count. A candidate's weight is
1 + GENRE_WEIGHT × (the mean score of its genres) + REC_WEIGHT × (its recommendation score), so a
title with no affinity keeps weight 1 and still comes up.
"""
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import Float, cast, func, literal, literal_column, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from app.plex.models import PlexItem, PlexShow, PlexState
from app.userscope import owned_by
HALF_LIFE_DAYS = 180
RECENT_TITLES = 200
GENRE_WEIGHT = 2.0
REC_WEIGHT = 3.0
# A watch with no date (an old import) counts as one this many half-lives ago.
_UNDATED_HALF_LIVES = 2
@dataclass(frozen=True)
class Affinity:
genres: dict[str, float] = field(default_factory=dict)
movie_recs: dict[str, float] = field(default_factory=dict)
show_recs: dict[str, float] = field(default_factory=dict)
def __bool__(self) -> bool:
return bool(self.genres or self.movie_recs or self.show_recs)
def weight(self, model: type[PlexItem] | type[PlexShow]) -> ColumnElement[float]:
"""The draw weight of a `model` row (a movie or a show)."""
scores = cast(literal(json.dumps(self.genres)), JSONB)
recs = self.show_recs if model is PlexShow else self.movie_recs
genre = func.jsonb_array_elements_text(model.genres).table_valued("value").alias("g")
genre_aff = (
select(
func.coalesce(
func.avg(func.coalesce(cast(scores.op("->>")(genre.c.value), Float), 0.0)),
0.0,
)
)
.select_from(genre)
.scalar_subquery()
)
rec_aff = func.coalesce(
cast(cast(literal(json.dumps(recs)), JSONB).op("->>")(model.guid), Float), 0.0
)
return literal_column("1.0") + GENRE_WEIGHT * genre_aff + REC_WEIGHT * rec_aff
def for_user(db: Session, uid: int) -> Affinity:
"""The user's affinity from their latest watched titles; empty (`not aff`) with no history."""
# One key per title: a show for an episode, the item for a film. concat() of a NULL show_id is
# just "s", which nullif turns back into NULL so the film's key takes over.
title = func.coalesce(
func.nullif(func.concat("s", PlexItem.show_id), "s"), func.concat("m", PlexItem.id)
).label("title")
latest = (
select(title, PlexState.watched_at, PlexItem.id.label("item_id"), PlexItem.show_id)
.join(PlexItem, PlexItem.id == PlexState.item_id)
.where(owned_by(PlexState.user_id, uid), PlexState.status == "watched")
.distinct(title)
.order_by(title, PlexState.watched_at.desc().nullslast())
.subquery()
)
rows = db.execute(
select(
latest.c.watched_at,
PlexItem.genres,
PlexItem.recommendations,
PlexShow.genres,
PlexShow.recommendations,
)
.select_from(latest)
.join(PlexItem, PlexItem.id == latest.c.item_id)
.outerjoin(PlexShow, PlexShow.id == latest.c.show_id)
.order_by(latest.c.watched_at.desc().nullslast())
.limit(RECENT_TITLES)
).all()
if not rows:
return Affinity()
now = datetime.now(timezone.utc)
genres: dict[str, float] = {}
movie_recs: dict[str, float] = {}
show_recs: dict[str, float] = {}
for watched_at, item_genres, item_recs, show_genres, show_recs_json in rows:
if watched_at is None:
r = 0.5**_UNDATED_HALF_LIVES
else:
r = 0.5 ** (max(0.0, (now - watched_at).total_seconds() / 86400) / HALF_LIFE_DAYS)
for g in (show_genres if show_genres is not None else item_genres) or []:
genres[g] = genres.get(g, 0.0) + r
for rec in (show_recs_json if show_recs_json is not None else item_recs) or []:
if not rec.get("tmdb_id"):
continue
bucket = show_recs if rec.get("media_type") == "tv" else movie_recs
guid = f"tmdb:{rec['tmdb_id']}"
bucket[guid] = max(bucket.get(guid, 0.0), r)
return Affinity(_scaled(genres), _scaled(movie_recs), _scaled(show_recs))
def _scaled(scores: dict[str, float]) -> dict[str, float]:
"""Each score over the strongest, so the strongest is 1."""
top = max(scores.values(), default=0.0)
return {k: v / top for k, v in scores.items()} if top else {}
+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)