"""A weighted draw that stays a surprise: what "Surprise me" deals from. A title's chance to come first is its weight over the pool's (Efraimidis–Spirakis: order by -ln(1-u)/w, u uniform per row). The weight is 1 + the title's affinity to what the user watches (`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 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 from sqlalchemy.dialects.postgresql import BIT from sqlalchemy.orm import InstrumentedAttribute 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 ) -> ColumnElement[float]: """A uniform u in [0, 1) per row, from md5(key || seed) — the order `md5(...)` ascending gives.""" digest = func.md5(func.concat(key, str(seed))) head = cast(cast(literal("x") + func.substr(digest, 1, 8), BIT(32)), BigInteger) return cast(head, Float) / _SPAN def weighted_key(u: ColumnElement[float], weight: ColumnElement[float]) -> ColumnElement[float]: """The row's place in a weighted draw, ascending: -ln(1-u)/w. Monotonic in u when every w is 1, so the unweighted order is unchanged.""" return -func.ln(1.0 - u) / weight def random_u() -> ColumnElement[float]: return func.random() def u_of(key: str, seed: int) -> float: """`seeded_u` in Python: the same u for the same key and seed.""" return int(hashlib.md5(f"{key}{seed}".encode()).hexdigest()[:8], 16) / _SPAN def shuffled(items: list, seed: int, key: Callable[[Any], str]) -> list: """`items` in the seed's md5 order — the order `ORDER BY md5(key || seed)` gives.""" return sorted(items, key=lambda it: hashlib.md5(f"{key(it)}{seed}".encode()).hexdigest()) def leaning( items: list, seed: int, key: Callable[[Any], str], weight: Callable[[Any], float] ) -> list: """`items` in the weighted draw's order (`weighted_key` in Python), ties in the md5 order.""" order = {key(it): i for i, it in enumerate(shuffled(items, seed, key))} return sorted( items, key=lambda it: (-math.log(1.0 - u_of(key(it), seed)) / weight(it), order[key(it)]), ) def blend( weighted: list, blind: list, offset: int, n: int, key: Callable[[Any], Hashable] = lambda x: x ) -> list: """`n` picks: slot i comes from `blind` when (i + offset) % 3 == 0, else from `weighted`, each skipping what an earlier slot took (`key` tells two picks apart). A side that runs out leaves the rest to the other.""" out: list = [] taken: set = set() sources = (iter(weighted), iter(blind)) def nxt(source): for item in source: if key(item) not in taken: return item return None for i in range(n): first, second = (sources[1], sources[0]) if (i + offset) % 3 == 0 else sources item = nxt(first) if item is None: item = nxt(second) if item is None: break taken.add(key(item)) out.append(item) return out