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:
@@ -77,7 +77,8 @@ _FEED_BASE: dict = {
|
|||||||
|
|
||||||
|
|
||||||
def _seeded(col, seed: int):
|
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)))
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
scores: dict[str, float] = {}
|
scores: dict[str, float] = {}
|
||||||
for channel_id, watched_at in rows:
|
for channel_id, watched_at in rows:
|
||||||
age = (
|
r = draw.recency(watched_at, now, WATCHED_HALF_LIFE_DAYS)
|
||||||
(now - watched_at).total_seconds() / 86400 if watched_at else 2 * WATCHED_HALF_LIFE_DAYS
|
scores[channel_id] = scores.get(channel_id, 0.0) + r
|
||||||
)
|
return draw.scaled(scores)
|
||||||
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:
|
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||||
|
|||||||
+24
-4
@@ -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
|
(`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:
|
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
|
- **u is the seed's md5 of the row's key**, the Home surprise's seeded order. With every weight 1
|
||||||
is exactly the md5 order the draw had before it was weighted, so a user with no history sees the
|
the order is exactly that md5 order, so a user with no history sees the unweighted draw, and one
|
||||||
old draw, and one seed still moves both sources of the Home hero together. A draw that is not
|
seed still moves both sources of the Home hero together. A draw that is not seeded (the Plex
|
||||||
seeded (the Plex Discover rail) takes u from random().
|
Discover rail) takes u from random().
|
||||||
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
|
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
|
||||||
favourites cannot fill the rail.
|
favourites cannot fill the rail.
|
||||||
|
|
||||||
The YouTube surprise draws over a window of the feed scope rather than all of it (`yt_surprise`):
|
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.
|
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 hashlib
|
||||||
import math
|
import math
|
||||||
from collections.abc import Callable, Hashable
|
from collections.abc import Callable, Hashable
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Float, cast, func, literal
|
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.
|
# 2**32: the first 8 hex digits of an md5 as a fraction of one.
|
||||||
_SPAN = 4294967296.0
|
_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(
|
def seeded_u(
|
||||||
key: ColumnElement[Any] | InstrumentedAttribute[Any], seed: int
|
key: ColumnElement[Any] | InstrumentedAttribute[Any], seed: int
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
|
from app import draw
|
||||||
from app.plex.models import PlexItem, PlexShow, PlexState
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
from app.userscope import owned_by
|
from app.userscope import owned_by
|
||||||
|
|
||||||
@@ -28,8 +29,6 @@ HALF_LIFE_DAYS = 180
|
|||||||
RECENT_TITLES = 200
|
RECENT_TITLES = 200
|
||||||
GENRE_WEIGHT = 2.0
|
GENRE_WEIGHT = 2.0
|
||||||
REC_WEIGHT = 3.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)
|
@dataclass(frozen=True)
|
||||||
@@ -99,10 +98,7 @@ def for_user(db: Session, uid: int) -> Affinity:
|
|||||||
movie_recs: dict[str, float] = {}
|
movie_recs: dict[str, float] = {}
|
||||||
show_recs: dict[str, float] = {}
|
show_recs: dict[str, float] = {}
|
||||||
for watched_at, item_genres, item_recs, show_genres, show_recs_json in rows:
|
for watched_at, item_genres, item_recs, show_genres, show_recs_json in rows:
|
||||||
if watched_at is None:
|
r = draw.recency(watched_at, now, HALF_LIFE_DAYS)
|
||||||
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 []:
|
for g in (show_genres if show_genres is not None else item_genres) or []:
|
||||||
genres[g] = genres.get(g, 0.0) + r
|
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 []:
|
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
|
bucket = show_recs if rec.get("media_type") == "tv" else movie_recs
|
||||||
guid = f"tmdb:{rec['tmdb_id']}"
|
guid = f"tmdb:{rec['tmdb_id']}"
|
||||||
bucket[guid] = max(bucket.get(guid, 0.0), r)
|
bucket[guid] = max(bucket.get(guid, 0.0), r)
|
||||||
return Affinity(_scaled(genres), _scaled(movie_recs), _scaled(show_recs))
|
return Affinity(draw.scaled(genres), draw.scaled(movie_recs), draw.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 {}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ history the order is exactly the old one. DB lane.
|
|||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from app import draw
|
from app import draw
|
||||||
from app.catalog.models import Channel, Subscription, Video
|
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("comedy_new") > 0
|
||||||
assert leads.count("horror_new") + leads.count("tipped_new") > 3 * leads.count("comedy_new")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user