"""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. That order is the PAGE's answer. "Mást kérek" rotates it (`start`), so each press offers a different KIND of answer rather than the next row of the same list — the user's call, 2026-09-06, after the button was measured cycling nine resume cards and never once reaching the shelf. 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, rails from app.discover.budget import fits from app.models import User HERO_TIERS = ("resume", "comfort", "surprise") # How deep the surprise tier draws before giving up. Deliberately deeper than one, so an excluded # pick moves the tier along instead of emptying it (the shuffle is seeded — the same seed always # hands back the same order, so without the headroom "already shown" would mean "nothing left"). SURPRISE_DRAW = 12 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, start: int = 0, lang: str, ) -> 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. `start` ROTATES which tier is tried first — 0 keeps the priority order (the page's own first answer), 1 leads with comfort, 2 with surprise. Exclusion alone was not enough: the resume rail holds up to 20 rows, so the button walked down it and never reached the shelf or a surprise at all (measured, 2026-09-06: twelve presses returned nine cards, every one of them `resume`). The fallthrough is kept, so a press does not come back empty just because the tier it led with has nothing to offer.""" skip = exclude or set() def _resume() -> dict | None: for card in rails.continue_watching(db, user, lang=lang) if resume is None else resume: playable = rails.playable(db, user.id, card, lang=lang) # 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 playable return None def _comfort() -> dict | None: if shelf is None: own = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang) else: own = shelf for card in pins.playables(own): if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): return card return None def _surprise() -> dict | None: # 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, lang=lang): if cards.key(card) not in skip: return card return None tiers = {"resume": _resume, "comfort": _comfort, "surprise": _surprise} offset = start % len(HERO_TIERS) for tier in HERO_TIERS[offset:] + HERO_TIERS[:offset]: card = tiers[tier]() if card is not None: return {"tier": tier, "card": card} return None