"""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 import draw 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 @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: r = draw.recency(watched_at, now, 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(draw.scaled(genres), draw.scaled(movie_recs), draw.scaled(show_recs))