The grid served Hungarian titles, but every other card surface (Discover rails, the show page's episodes, search, collections, the person page, playlists, Home's resume rail, hero and comfort shelf) served English. The card serializers now take a required keyword-only lang (a forgotten call site fails instead of going English), and each of those reads takes ?lang=hu, with the language in its query key. A Hungarian search finds an episode by its Hungarian title too. An episode on a poster rail showed its 16:9 still cropped into a poster, titled only with the episode name; it now shows the show's poster and name, with S·E · title under it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the number in the reader's language; a season with a name of its own keeps it. Home's comfort-shelf move wrote its optimistic order to the key without the language, which nothing read any more; it writes the query's own key.
110 lines
5.1 KiB
Python
110 lines
5.1 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,
|
|
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
|