- A13: with Plex off, the Home shelf and the hero's comfort tier go YouTube-only like the rails beside them (owner's call: hide the tiles). The pins are kept; GET /pins still lists them. - Reorder deals the listed pins into the slots they already hold, so a partial list (hidden Plex pins, a stale tab) no longer puts two pins on one position; repeats are ignored. - A21: a comfort hero carries `pinned_from`, the shelf entry it was resolved from; its menu acts on that pin, so a pinned show's next episode offers "Unpin" instead of creating a second pin. - B12: the palette's "Meglepetés" asks for the surprise tier (the next press leading with it); the rotation carries on from there. - B13: the surprise token names the history entry it was requested for (minted by setPage's new `then`, after the push) and is dropped on any other entry, so a navigation that never mounted Home cannot replay it later.
109 lines
5.0 KiB
Python
109 lines
5.0 KiB
Python
"""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,
|
|
) -> 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) if resume is None else resume:
|
|
playable = rails.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 playable
|
|
return None
|
|
|
|
def _comfort() -> dict | None:
|
|
if shelf is None:
|
|
own = pins.shelf(db, user, include_plex=rails.plex_available(db))
|
|
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):
|
|
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
|