fix(e24): the Home surface's three UAT findings — Back, the reroll, and a show card
All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
This commit is contained in:
@@ -10,6 +10,10 @@ Three tiers, tried in order:
|
||||
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
|
||||
@@ -22,30 +26,18 @@ answer.
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.discover import cards, pins, queries, rails
|
||||
from app.discover import cards, pins, rails
|
||||
from app.discover.budget import fits
|
||||
from app.models import PlexShow, User
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.models import User
|
||||
|
||||
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.
|
||||
# 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 _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,
|
||||
@@ -55,6 +47,7 @@ def pick(
|
||||
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.
|
||||
|
||||
@@ -66,25 +59,46 @@ def pick(
|
||||
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."""
|
||||
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()
|
||||
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}
|
||||
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
|
||||
|
||||
# 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}
|
||||
def _comfort() -> dict | None:
|
||||
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 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
|
||||
|
||||
@@ -275,12 +275,21 @@ def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) ->
|
||||
return [movie_card(it, states.get(it.id)) for it in items]
|
||||
|
||||
|
||||
# Watch states that take an episode out of the running for "what would play next".
|
||||
_DONE = ("watched", "hidden")
|
||||
|
||||
|
||||
def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
|
||||
"""What "put that show on" means: the episode in progress, else the first unwatched one, else the
|
||||
first — in broadcast order, the same `(season, episode)` ordering `detail.py::_episode_nav` uses.
|
||||
|
||||
Three ordered `LIMIT 1` probes rather than one scan picked apart in Python. The Python version
|
||||
loaded EVERY episode of the show to choose one of them — 236 rows for Friends, 227 for JAG — which
|
||||
the comfort shelf could just about afford (a handful of hand-picked pins) and the resume rail
|
||||
could not, since it resolves a show per rail row on the landing page.
|
||||
|
||||
Returned as a `leaf_card` so it carries the show title the episode row itself does not."""
|
||||
rows = db.execute(
|
||||
base = (
|
||||
select(PlexItem, PlexState)
|
||||
.outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||
@@ -288,20 +297,26 @@ def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).all()
|
||||
if not rows:
|
||||
.limit(1)
|
||||
)
|
||||
# In progress. `position_seconds > 0` already requires the state row to exist, so no null guard.
|
||||
pick = db.execute(
|
||||
base.where(PlexState.position_seconds > 0, PlexState.status.notin_(_DONE))
|
||||
).first()
|
||||
# Unwatched — INCLUDING an episode with no state row at all, which is the common case and the one
|
||||
# a bare `status NOT IN (...)` silently drops (NULL is not false, it is unknown, so the row fails
|
||||
# the filter).
|
||||
pick = (
|
||||
pick
|
||||
or db.execute(
|
||||
base.where(or_(PlexState.id.is_(None), PlexState.status.notin_(_DONE)))
|
||||
).first()
|
||||
)
|
||||
# Fully watched show: start it again from the top.
|
||||
pick = pick or db.execute(base).first()
|
||||
if pick is None:
|
||||
return None
|
||||
|
||||
def _started(st: PlexState | None) -> bool:
|
||||
return bool(st and st.position_seconds > 0 and st.status not in ("watched", "hidden"))
|
||||
|
||||
def _unseen(st: PlexState | None) -> bool:
|
||||
return st is None or st.status not in ("watched", "hidden")
|
||||
|
||||
pick = next((r for r in rows if _started(r[1])), None)
|
||||
pick = pick or next((r for r in rows if _unseen(r[1])), None)
|
||||
pick = pick or rows[0]
|
||||
|
||||
ep, st = pick
|
||||
show = db.get(PlexShow, show_id)
|
||||
return leaf_card(
|
||||
|
||||
@@ -13,7 +13,8 @@ from sqlalchemy.orm import Session
|
||||
from app import sysconfig
|
||||
from app.discover import cards, queries
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.models import User
|
||||
from app.models import PlexShow, User
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
# Sort sentinel for a row whose timestamp is missing. A plain `(ts is not None, ts)` key looks like it
|
||||
# handles NULLs and does not: two NULL rows compare equal on the flag and then compare `None < None`,
|
||||
@@ -27,18 +28,49 @@ def plex_available(db: Session) -> bool:
|
||||
return sysconfig.get_bool(db, "plex_enabled")
|
||||
|
||||
|
||||
def playable(db: Session, uid: int, card: dict) -> dict | None:
|
||||
"""The thing that would actually START from this card — itself for anything with a runtime, and
|
||||
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
|
||||
|
||||
ONE function, because three surfaces need the same answer and a second implementation is how the
|
||||
shelf and the rail came to disagree in the first place: the hero calls it to decide what it is
|
||||
offering, `continue_watching` calls it to fill each show tile's `next`, and the comfort shelf
|
||||
resolves the same way in `pins`. None when the show has no episodes mirrored yet.
|
||||
|
||||
A card that already carries a resolved `next` (a rail row) is taken at its word rather than
|
||||
re-queried — the hero reads the rail it was handed."""
|
||||
if not (card.get("source") == "plex" and card.get("kind") == "show"):
|
||||
return card
|
||||
if "next" in card:
|
||||
return card["next"]
|
||||
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 continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]:
|
||||
"""One resume rail across both sources, most recently progressed first.
|
||||
|
||||
A Plex SHOW enters as a show card (that is what the Plex rail shows too, and it is what the user
|
||||
recognises); resolving it to an episode is the hero's job, where something actually has to play."""
|
||||
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
|
||||
user recognises — but it carries `next`: the episode that would actually start. Without it a
|
||||
click on a half-watched series landed on the show page with six seasons to choose between, which
|
||||
is the decision this whole surface exists to remove (the comfort shelf and the hero already
|
||||
resolved; only this rail did not). The client plays `next ?? card`, so the tile stays a poster and
|
||||
the click stays one click."""
|
||||
rows: list[tuple[object, dict]] = [
|
||||
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
|
||||
]
|
||||
if plex_available(db):
|
||||
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
|
||||
rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:limit]]
|
||||
out = [card for _, card in rows[:limit]]
|
||||
# Resolve AFTER the slice: only the rows that survive it are worth a query.
|
||||
for card in out:
|
||||
if card["kind"] == "show":
|
||||
card["next"] = playable(db, user.id, card)
|
||||
return out
|
||||
|
||||
|
||||
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]:
|
||||
|
||||
@@ -70,6 +70,7 @@ def get_pick(
|
||||
budget: Budget = DEFAULT_BUDGET,
|
||||
seed: int = 0,
|
||||
exclude: str = "",
|
||||
start: int = 0,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -80,11 +81,17 @@ def get_pick(
|
||||
|
||||
`exclude` is a comma-separated list of `<source>:<id>` keys the user has already been shown. It
|
||||
is what makes the button honest: a seed alone only moves the surprise tier, so a user with
|
||||
something half-finished got the same card back every press."""
|
||||
something half-finished got the same card back every press.
|
||||
|
||||
`start` is the press count: it rotates which tier answers first, so consecutive presses offer a
|
||||
different KIND of answer instead of walking down the resume rail. Taken modulo the tier count in
|
||||
`hero.pick`, so any integer is valid and no value can 422 a button."""
|
||||
skip = {p for p in exclude.split(",") if p}
|
||||
return {
|
||||
"budget": budget,
|
||||
"hero": hero_mod.pick(db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE])),
|
||||
"hero": hero_mod.pick(
|
||||
db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user