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.
121 lines
5.0 KiB
Python
121 lines
5.0 KiB
Python
"""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 {}
|