Files
siftlode/backend/app/plex/affinity.py
T
peter 9310ff85bf 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.
2026-11-07 02:41:30 +01:00

121 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 {}