"""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") 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, ) -> 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.""" for card in rails.continue_watching(db, user) if resume is None else resume: playable = _playable(db, user.id, card) 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 fits(card["remaining_seconds"], budget): return {"tier": "comfort", "card": card} for card in rails.surprise(db, user, budget, seed, limit=1): return {"tier": "surprise", "card": card} return None