E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module.
91 lines
4.2 KiB
Python
91 lines
4.2 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.
|
|
|
|
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
|