"""The hero: the single suggestion the Home surface leads with. One rule, in one function, because the page's entire promise is that it answers rather than offers. Three tiers, tried in order: 1. **resume** — something already started whose REMAINING time fits the budget. Almost always the right answer: it is the thing the user themselves chose most recently, and finishing it costs no decision at all. 2. **comfort** — a pinned shelf entry that fits. The user's own declared fallback, which beats anything we could infer. 3. **surprise** — a seeded draw from what is unseen and fits. The tier travels with the card so the client can label it honestly ("Ott hagytad abba" vs "A polcodról" vs "Meglepetés") instead of guessing from the shape of the payload — a kicker that says "where you left off" over a film the user has never opened is exactly the kind of quiet lie the house rules call a dishonest state. Budgets are matched against REMAINING time throughout (`budget.remaining_seconds`, which equals the runtime for anything not yet started): with eight minutes left, a 90-minute film is a ten-minute answer. """ from sqlalchemy.orm import Session from app.discover import cards, pins, queries, rails from app.discover.budget import fits from app.models import PlexShow, User from app.plex.lookups import by_rating_key HERO_TIERS = ("resume", "comfort", "surprise") # How deep the surprise tier draws before giving up. The client's exclusion window is 8 (see # `Home.tsx`), so a draw of 12 can always step past a full window of already-shown picks. SURPRISE_DRAW = 12 def _playable(db: Session, uid: int, card: dict) -> dict | None: """A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start. Everything else already plays itself. None when the show has no episodes mirrored yet.""" if not (card.get("source") == "plex" and card.get("kind") == "show"): return card sh = by_rating_key(db, PlexShow, card["id"]) if sh is None: return None ep = queries.next_episode(db, uid, sh.id) return cards.from_plex(ep) if ep else None def pick( db: Session, user: User, budget: str, seed: int, *, resume: list[dict] | None = None, shelf: list[dict] | None = None, exclude: set[str] | None = None, ) -> dict | None: """`{"tier": ..., "card": ...}`, or None when nothing at all fits. `resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does not build the same two rails twice; the reroll endpoint passes neither and pays only for what it reaches. `exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE tier: the resume tier deterministically returns the most recently progressed thing that fits, so pressing reroll on a user who has something half-finished handed back the identical card and the button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes every press produce a different answer, at every tier.""" skip = exclude or set() for card in rails.continue_watching(db, user) if resume is None else resume: playable = _playable(db, user.id, card) # Excluded on the card the USER saw (the show), not only on what it resolves to, so a # rerolled-past show does not come straight back as its own next episode. if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip): continue if playable is not None and fits(playable["remaining_seconds"], budget): return {"tier": "resume", "card": playable} for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf): if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): return {"tier": "comfort", "card": card} # A wider draw than the one card we need: the shuffle is seeded, so without the headroom an # excluded pick would leave the tier empty rather than move past it. for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW): if cards.key(card) not in skip: return {"tier": "surprise", "card": card} return None