diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index f83e6498..d0469a8a 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -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: diff --git a/backend/app/draw.py b/backend/app/draw.py index 7281501d..5f4d850c 100644 --- a/backend/app/draw.py +++ b/backend/app/draw.py @@ -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 diff --git a/backend/app/plex/affinity.py b/backend/app/plex/affinity.py index 4d25fbd3..656990d8 100644 --- a/backend/app/plex/affinity.py +++ b/backend/app/plex/affinity.py @@ -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)) diff --git a/backend/tests/test_surprise_affinity.py b/backend/tests/test_surprise_affinity.py index 0921c02c..3276c200 100644 --- a/backend/tests/test_surprise_affinity.py +++ b/backend/tests/test_surprise_affinity.py @@ -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