"""Surprise me leans on what the user watches, 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`): a title's weight is 1 + its affinity — for Plex the genres of the films and shows the user watched (recent ones weigh more) and the TMDB recommendations stored on them; for YouTube the channels the user watches. One slot of three stays a blind draw, and with no watch history the order is exactly the old one. DB lane. """ import hashlib from datetime import datetime, timedelta, timezone from sqlalchemy import select, text from app import draw from app.catalog.models import Channel, Subscription, Video from app.discover import queries from app.plex import affinity, discover as plex_discover from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState from app.plex.query import LibraryFilters from app.reader.models import VideoState NOW = datetime.now(timezone.utc) SEEDS = range(90) # ── the draw itself ────────────────────────────────────────────────────────────────────────────── def test_every_third_slot_is_blind_and_nothing_repeats(): weighted = ["a", "b", "c", "d", "e", "f"] blind = ["f", "e", "d", "c", "b", "a"] # offset 0: slots 0 and 3 are blind assert draw.blend(weighted, blind, 0, 6) == ["f", "a", "b", "e", "c", "d"] # offset 1: slot 2 and 5 assert draw.blend(weighted, blind, 1, 6) == ["a", "b", "f", "c", "d", "e"] assert draw.blend(weighted, blind, 2, 3) == ["a", "f", "b"] def test_a_short_side_is_topped_up_from_the_other(): assert draw.blend(["a"], ["a", "b", "c"], 0, 3) == ["a", "b", "c"] assert draw.blend(["a", "b", "c"], [], 0, 3) == ["a", "b", "c"] def test_a_watch_weighs_by_its_age_an_undated_one_two_half_lives_and_a_future_one_now(): # Both affinities weigh every watch through this one rule; an old import has no date, and a # clock ahead of the server's must not weigh more than a watch made now. assert draw.recency(NOW, NOW, 30) == 1.0 assert draw.recency(NOW - timedelta(days=30), NOW, 30) == 0.5 assert draw.recency(None, NOW, 30) == 0.25 assert draw.recency(NOW + timedelta(days=5), NOW, 30) == 1.0 def test_scores_are_scaled_to_the_strongest_and_nothing_scored_is_empty(): assert draw.scaled({"a": 4.0, "b": 1.0}) == {"a": 1.0, "b": 0.25} assert draw.scaled({}) == {} # ── YouTube: the channels the user watches ─────────────────────────────────────────────────────── def _md5_order(ids, seed): return sorted(ids, key=lambda i: hashlib.md5(f"{i}{seed}".encode()).hexdigest()) def _seed_yt(db, make_user, *, watched_in_fav: int): user = make_user() fav, other = Channel(id="UCfav", title="Fav"), Channel(id="UCother", title="Other") db.add_all([fav, other]) db.flush() db.add_all( [ Subscription(user_id=user.id, channel_id="UCfav"), Subscription(user_id=user.id, channel_id="UCother"), ] ) def video(vid, ch, days): return Video( id=vid, channel_id=ch, title=vid, duration_seconds=20 * 60, published_at=NOW - timedelta(days=days), ) # One unseen video per channel, plus the user's watched history on the favourite. db.add_all([video("fav_new", "UCfav", 1), video("other_new", "UCother", 1)]) for i in range(watched_in_fav): db.add(video(f"fav_seen{i}", "UCfav", 10 + i)) db.flush() for i in range(watched_in_fav): db.add( VideoState( user_id=user.id, video_id=f"fav_seen{i}", status="watched", watched_at=NOW - timedelta(days=i), ) ) db.commit() return user def _first(rows): return rows[0]["id"] if rows else None def test_a_watched_channel_wins_most_draws_but_not_all(db, make_user): user = _seed_yt(db, make_user, watched_in_fav=12) leads = [_first(queries.yt_surprise(db, user, "any", s, 2)) for s in SEEDS] fav = leads.count("fav_new") / len(leads) # weight 1 + 3 against 1: 0.8 in a weighted slot, 0.5 in the blind one (a third of the seeds) assert 0.6 < fav < 0.9, fav def test_the_blind_slot_deals_the_plain_shuffle_however_strong_the_lean(db, make_user): user = _seed_yt(db, make_user, watched_in_fav=12) for s in range(0, 30, 3): # (0 + seed) % 3 == 0: the first slot is the blind one got = _first(queries.yt_surprise(db, user, "any", s, 2)) assert got == _md5_order(["fav_new", "other_new"], s)[0] def test_with_no_history_the_youtube_order_is_the_old_shuffle(db, make_user): user = _seed_yt(db, make_user, watched_in_fav=0) for s in range(12): got = [r["id"] for r in queries.yt_surprise(db, user, "any", s, 2)] assert got == _md5_order(["fav_new", "other_new"], s) def test_a_watched_video_is_never_drawn(db, make_user): user = _seed_yt(db, make_user, watched_in_fav=12) drawn = {r["id"] for s in SEEDS for r in queries.yt_surprise(db, user, "any", s, 5)} assert drawn == {"fav_new", "other_new"} # ── Plex: genres and recommendations of what the user watched ──────────────────────────────────── def _seed_plex(db, make_user, *, history: bool): user = make_user() lib = PlexLibrary(plex_key="77", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() tmdb_ids = iter(range(5000, 6000)) def movie(key, genres, recs=None): m = PlexItem( rating_key=key, library_id=lib.id, kind="movie", title=key, duration_s=100 * 60, genres=genres, guid=f"tmdb:{next(tmdb_ids)}", recommendations=recs, ) db.add(m) return m horror = movie("horror_new", ["Horror"]) comedy = movie("comedy_new", ["Comedy"]) tipped = movie("tipped_new", ["Western"]) db.flush() tip = [{"tmdb_id": int(tipped.guid.split(":")[1]), "media_type": "movie", "title": "t"}] seen = [movie(f"horror_seen{i}", ["Horror"], tip if i == 0 else None) for i in range(3)] db.flush() if history: for i, m in enumerate(seen): db.add( PlexState( user_id=user.id, item_id=m.id, status="watched", watched_at=NOW - timedelta(days=i), ) ) db.commit() from app import sysconfig sysconfig.set_value(db, "plex_enabled", True) db.commit() return user, horror, comedy, tipped def test_the_genre_and_the_recommendation_of_what_was_watched_win_most_draws(db, make_user): user, *_ = _seed_plex(db, make_user, history=True) leads = [_first(queries.plex_surprise(db, user.id, "any", s, 3, lang="en")) for s in SEEDS] # Weights: Horror 1 + 2 (its genre), the recommended Western 1 + 3, the Comedy nobody chose 1. In # a weighted slot the Comedy leads 1 time in 8, in the blind third 1 in 3: about 0.19 against # 0.81 for the other two together (a uniform draw: 1 in 3 each). It still leads now and then — # a surprise, not a ranking. comedy = leads.count("comedy_new") assert comedy > 0 assert leads.count("horror_new") > 1.5 * comedy and leads.count("tipped_new") > 1.5 * comedy assert leads.count("horror_new") + leads.count("tipped_new") > 3 * comedy def test_with_no_history_the_plex_order_is_the_old_shuffle(db, make_user): user, *_ = _seed_plex(db, make_user, history=False) 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, user.id, "any", s, 6, lang="en")] assert got == _md5_order(keys, s) def test_a_watched_film_is_never_drawn(db, make_user): user, *_ = _seed_plex(db, make_user, history=True) drawn = { c["id"] for s in SEEDS for c in queries.plex_surprise(db, user.id, "any", s, 6, lang="en") } assert drawn == {"horror_new", "comedy_new", "tipped_new"} def test_the_plex_blind_slot_deals_the_plain_shuffle(db, make_user): user, *_ = _seed_plex(db, make_user, history=True) unseen = ["horror_new", "comedy_new", "tipped_new"] for s in range(0, 30, 3): got = _first(queries.plex_surprise(db, user.id, "any", s, 3, lang="en")) assert got == _md5_order(unseen, s)[0] def test_a_binged_show_counts_once_and_recent_watches_weigh_more(db, make_user): user = make_user() lib = PlexLibrary(plex_key="78", title="Shows", kind="show", enabled=True) mlib = PlexLibrary(plex_key="79", title="Movies", kind="movie", enabled=True) db.add_all([lib, mlib]) db.flush() show = PlexShow(rating_key="sitcom", library_id=lib.id, title="Sitcom", genres=["Comedy"]) db.add(show) db.flush() eps = [ PlexItem( rating_key=f"ep{i}", library_id=lib.id, kind="episode", title=f"e{i}", show_id=show.id, season_number=1, episode_number=i, ) for i in range(10) ] films = [ PlexItem( rating_key=f"h{i}", library_id=mlib.id, kind="movie", title=f"h{i}", genres=["Horror"] ) for i in range(2) ] old = PlexItem( rating_key="w0", library_id=mlib.id, kind="movie", title="w0", genres=["Western"] ) db.add_all([*eps, *films, old]) db.flush() for it in [*eps, *films]: db.add(PlexState(user_id=user.id, item_id=it.id, status="watched", watched_at=NOW)) db.add( PlexState( user_id=user.id, item_id=old.id, status="watched", watched_at=NOW - timedelta(days=720) ) ) db.commit() genres = affinity.for_user(db, user.id).genres # Ten episodes of one show are one title: two horror films outweigh it. assert genres["Horror"] == 1.0 assert genres["Comedy"] == 0.5 # Four half-lives ago weighs a sixteenth of today. assert abs(genres["Western"] - 0.5 / 16) < 0.01 def test_the_discover_rail_leans_the_same_way(db, make_user): # The rail draws with random(), not a seed; setseed makes this session's draws repeatable. user, *_ = _seed_plex(db, make_user, history=True) db.execute(text("SELECT setseed(0.42)")) leads = [ plex_discover.surprise(db, user, "movie", "en", 3, [], [], LibraryFilters())["items"][0][ "id" ] for _ in SEEDS ] 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