refactor(discover): both affinities weigh a watch by one rule

The YouTube channel affinity and the Plex one each had the recency decay, the undated-watch rule and
the scale to the strongest; app.draw now holds them. Tests hold that another user's history leans
neither draw, the rail's blind slot, and that a show leans on show recommendations.
This commit is contained in:
2026-11-07 21:48:38 +01:00
parent e7e8d8236e
commit bb91d0a22d
4 changed files with 99 additions and 27 deletions
+5 -9
View File
@@ -77,7 +77,8 @@ _FEED_BASE: dict = {
def _seeded(col, seed: int):
"""The feed's deterministic shuffle expression, reused for Plex so one seed moves both halves."""
"""The Home surprise's seeded order (md5 of the key and the seed), the same for Plex so one
seed moves both halves."""
return func.md5(func.concat(col, str(seed)))
@@ -188,14 +189,9 @@ def _channel_affinity(db: Session, user: User) -> dict[str, float]:
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 {}
r = draw.recency(watched_at, now, WATCHED_HALF_LIFE_DAYS)
scores[channel_id] = scores.get(channel_id, 0.0) + r
return draw.scaled(scores)
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
+24 -4
View File
@@ -5,20 +5,24 @@ A title's chance to come first is its weight over the pool's (Efraimidis–Spira
(`plex.affinity`, `discover.queries`), so a title nobody leans towards still comes up — only less
often. Two things keep it a surprise rather than a ranking:
- **u is the seed's md5 of the row's key**, the feed's shuffle idiom. With every weight 1 the order
is exactly the md5 order the draw had before it was weighted, so a user with no history sees the
old draw, and one seed still moves both sources of the Home hero together. A draw that is not
seeded (the Plex Discover rail) takes u from random().
- **u is the seed's md5 of the row's key**, the Home surprise's seeded order. With every weight 1
the order is exactly that md5 order, so a user with no history sees the unweighted draw, and one
seed still moves both sources of the Home hero together. A draw that is not seeded (the Plex
Discover rail) takes u from random().
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
favourites cannot fill the rail.
The YouTube surprise draws over a window of the feed scope rather than all of it (`yt_surprise`):
the window is read in SQL, and `shuffled` / `leaning` order it here with the same u.
Both affinities weigh a watch by `recency` and score against the strongest with `scaled`, so the two
sources lean by one rule.
"""
import hashlib
import math
from collections.abc import Callable, Hashable
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, Float, cast, func, literal
@@ -29,6 +33,22 @@ from sqlalchemy.sql.elements import ColumnElement
# 2**32: the first 8 hex digits of an md5 as a fraction of one.
_SPAN = 4294967296.0
# A watch with no date (an old import) counts as one this many half-lives ago.
UNDATED_HALF_LIVES = 2
def recency(watched_at: datetime | None, now: datetime, half_life_days: float) -> float:
"""A watch's weight in an affinity: 1 now, half as much every `half_life_days`."""
if watched_at is None:
return 0.5**UNDATED_HALF_LIVES
return 0.5 ** (max(0.0, (now - watched_at).total_seconds() / 86400) / half_life_days)
def scaled(scores: dict[str, float]) -> dict[str, float]:
"""Each score over the strongest, so the strongest is 1; empty when nothing scored."""
top = max(scores.values(), default=0.0)
return {k: v / top for k, v in scores.items()} if top else {}
def seeded_u(
key: ColumnElement[Any] | InstrumentedAttribute[Any], seed: int
+3 -13
View File
@@ -21,6 +21,7 @@ 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
@@ -28,8 +29,6 @@ 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)
@@ -99,10 +98,7 @@ def for_user(db: Session, uid: int) -> Affinity:
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)
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 []:
@@ -111,10 +107,4 @@ def for_user(db: Session, uid: int) -> Affinity:
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 {}
return Affinity(draw.scaled(genres), draw.scaled(movie_recs), draw.scaled(show_recs))
+67 -1
View File
@@ -10,7 +10,7 @@ history the order is exactly the old one. DB lane.
import hashlib
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from sqlalchemy import select, text
from app import draw
from app.catalog.models import Channel, Subscription, Video
@@ -267,3 +267,69 @@ def test_the_discover_rail_leans_the_same_way(db, make_user):
]
assert leads.count("comedy_new") > 0
assert leads.count("horror_new") + leads.count("tipped_new") > 3 * leads.count("comedy_new")
# ── whose history: only the user's own ───────────────────────────────────────────────────────────
def test_another_users_youtube_history_does_not_lean_my_draw(db, make_user):
watcher = _seed_yt(db, make_user, watched_in_fav=12)
me = make_user()
db.add_all(
[
Subscription(user_id=me.id, channel_id="UCfav"),
Subscription(user_id=me.id, channel_id="UCother"),
]
)
db.commit()
assert queries._channel_affinity(db, watcher)
assert queries._channel_affinity(db, me) == {}
def test_another_users_plex_history_does_not_lean_my_draw(db, make_user):
_seed_plex(db, make_user, history=True)
me = make_user()
assert not affinity.for_user(db, me.id)
keys = ["horror_new", "comedy_new", "tipped_new"] + [f"horror_seen{i}" for i in range(3)]
for s in range(12):
got = [c["id"] for c in queries.plex_surprise(db, me.id, "any", s, 6, lang="en")]
assert got == _md5_order(keys, s)
# ── the rail: its blind slot, and a show's own recommendations ───────────────────────────────────
def test_the_rails_third_slot_is_the_blind_draw(db, make_user, monkeypatch):
# The rail draws with random(), so the blend is watched rather than its odds: every leaning draw
# goes through it, with the blind order beside the weighted one and the third slot blind.
user, *_ = _seed_plex(db, make_user, history=True)
calls = []
real = draw.blend
def spy(weighted, blind, offset, n, key):
calls.append((offset, n, sorted(map(key, weighted)), sorted(map(key, blind))))
return real(weighted, blind, offset, n, key=key)
monkeypatch.setattr(draw, "blend", spy)
got = plex_discover.surprise(db, user, "movie", "en", 3, [], [], LibraryFilters())["items"]
assert calls and all(offset == 1 and n == 3 for offset, n, *_ in calls)
pool = sorted(["horror_new", "comedy_new", "tipped_new"])
assert calls[0][2] == pool and calls[0][3] == pool
assert len(got) == 3
def test_a_show_leans_on_the_shows_recommended_not_the_films(db, make_user):
lib = PlexLibrary(plex_key="80", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
tipped = PlexShow(rating_key="tv1", library_id=lib.id, title="t", guid="tmdb:1", genres=[])
db.add(tipped)
db.commit()
shows = affinity.Affinity(show_recs={"tmdb:1": 1.0})
films = affinity.Affinity(movie_recs={"tmdb:1": 1.0})
def weight(aff):
return db.execute(select(aff.weight(PlexShow)).where(PlexShow.id == tipped.id)).scalar_one()
assert weight(shows) == 1 + affinity.REC_WEIGHT
assert weight(films) == 1