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
+113 -15
View File
@@ -17,9 +17,25 @@ otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand st
here takes the feed's seeded-md5 idiom instead of `random()`.
"""
from sqlalchemy import and_, func, or_, select
import json
from datetime import datetime, timezone
from sqlalchemy import (
Float,
Select,
and_,
cast,
func,
literal,
literal_column,
or_,
select,
union_all,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session, aliased
from app import draw
from app.discover.budget import band
from app.feedquery import (
feed_columns,
@@ -33,7 +49,7 @@ from app.models import User
from app.plex.models import PlexItem, PlexShow, PlexState
from app.catalog.models import Channel, Video
from app.reader.models import PlaylistItem, VideoState
from app.plex import rollup
from app.plex import affinity, rollup
from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs
from app.userscope import owned_by
@@ -115,14 +131,93 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) ->
params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short")
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys("shuffle", seed, rank_expr)
# Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and
# hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must
# go: the resume tier has already had its chance at them, and it may have passed precisely because
# the REMAINING time did not fit the budget — offering the same item back as a surprise, whose
# runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question.
rows = db.execute(query.order_by(*order_by(keys)).limit(limit * 4)).all()
out = [serialize(r) for r in rows]
return [r for r in out if not r["position_seconds"]][:limit]
take = limit * 4
def unstarted(rows) -> list[dict]:
return [r for r in map(serialize, rows) if not r["position_seconds"]]
channels = _channel_affinity(db, user)
if not channels:
return unstarted(db.execute(query.order_by(*order_by(keys)).limit(take)).all())[:limit]
# Both orders from ONE pass over the scope. The pool is the whole feed scope (~200k videos on a
# prod-sized catalog, ~390 ms a pass), so a second ordered query for the weighted draw doubled
# the cost; a materialized pool of (id, shuffle key, weighted key) is read twice instead.
weight = literal_column("1.0") + CHANNEL_WEIGHT * func.coalesce(
cast(cast(literal(json.dumps(channels)), JSONB).op("->>")(Video.channel_id), Float), 0.0
)
shuffle = func.md5(func.concat(Video.id, str(seed)))
pool = (
query.with_only_columns(
Video.id.label("vid"),
shuffle.label("plain"),
draw.weighted_key(draw.seeded_u(Video.id, seed), weight).label("leaning"),
)
.cte("surprise_pool")
.prefix_with("MATERIALIZED")
)
def top(side: int, *order) -> Select:
part = (
select(pool.c.vid, pool.c.plain, pool.c.leaning, literal(side).label("side"))
.order_by(*order, pool.c.vid)
.limit(take)
.subquery()
)
return select(part)
# One statement, so the pool is built once; each side's order is restored from its keys.
got = db.execute(union_all(top(0, pool.c.plain), top(1, pool.c.leaning, pool.c.plain))).all()
plain_ids = [
r.vid for r in sorted((r for r in got if r.side == 0), key=lambda r: (r.plain, r.vid))
]
leaning_ids = [
r.vid
for r in sorted((r for r in got if r.side == 1), key=lambda r: (r.leaning, r.plain, r.vid))
]
rows = {
r["id"]: r
for r in unstarted(db.execute(query.where(Video.id.in_({*plain_ids, *leaning_ids}))).all())
}
blind = [rows[i] for i in plain_ids if i in rows]
leaning = [rows[i] for i in leaning_ids if i in rows]
return draw.blend(leaning, blind, seed, limit, key=lambda r: r["id"])
# How much a channel the user watches most outweighs one they never watch (1 + this), and how far back
# the watched history counts: the latest WATCHED_RECENT watches, the recent ones weighing more.
CHANNEL_WEIGHT = 3.0
WATCHED_RECENT = 300
WATCHED_HALF_LIFE_DAYS = 90
def _channel_affinity(db: Session, user: User) -> dict[str, float]:
"""Each channel the user watched lately, scored 0..1 against the most watched: the sum of its
watches, each weighing half as much every WATCHED_HALF_LIFE_DAYS. Empty with no history."""
rows = db.execute(
select(Video.channel_id, VideoState.watched_at)
.join(Video, Video.id == VideoState.video_id)
.where(owned_by(VideoState.user_id, user), VideoState.status == "watched")
.order_by(VideoState.watched_at.desc().nullslast())
.limit(WATCHED_RECENT)
).all()
now = datetime.now(timezone.utc)
scores: dict[str, float] = {}
for channel_id, watched_at in rows:
age = (
(now - watched_at).total_seconds() / 86400 if watched_at else 2 * WATCHED_HALF_LIFE_DAYS
)
scores[channel_id] = scores.get(channel_id, 0.0) + 0.5 ** (
max(0.0, age) / WATCHED_HALF_LIFE_DAYS
)
top = max(scores.values(), default=0.0)
return {k: v / top for k, v in scores.items()} if top else {}
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
@@ -289,21 +384,24 @@ def plex_surprise(
if not movie_libs:
return []
lo, hi = band(budget)
q = (
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_libs),
_unseen_movie_conds(uid),
)
.order_by(_seeded(PlexItem.rating_key, seed))
.limit(limit)
q = select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_libs),
_unseen_movie_conds(uid),
)
if lo is not None:
q = q.where(PlexItem.duration_s >= lo)
if hi is not None:
q = q.where(PlexItem.duration_s <= hi)
items = db.execute(q).scalars().all()
shuffled = _seeded(PlexItem.rating_key, seed)
blind = db.execute(q.order_by(shuffled, PlexItem.id).limit(limit)).scalars().all()
leaning = affinity.for_user(db, uid)
if leaning:
key = draw.weighted_key(draw.seeded_u(PlexItem.rating_key, seed), leaning.weight(PlexItem))
weighted = db.execute(q.order_by(key, shuffled, PlexItem.id).limit(limit)).scalars().all()
items = draw.blend(list(weighted), list(blind), seed, limit, key=lambda it: it.id)
else:
items = list(blind)
states = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, states.get(it.id), lang=lang) for it in items]