feat(e24): the Home surface — one question, one answer, both sources

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.
This commit is contained in:
2026-09-06 04:14:14 +02:00
parent 53b50231ff
commit f3c0b7be23
21 changed files with 964 additions and 14 deletions
+7
View File
@@ -59,6 +59,13 @@ def _base(
}
def key(card: dict) -> str:
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
say "not that one" about either."""
return f"{card['source']}:{card['id']}"
def from_video(row: dict) -> dict:
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
card = _base(
+23 -4
View File
@@ -29,6 +29,10 @@ 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.
@@ -50,22 +54,37 @@ def pick(
*,
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."""
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 fits(card["remaining_seconds"], budget):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return {"tier": "comfort", "card": card}
for card in rails.surprise(db, user, budget, seed, limit=1):
return {"tier": "surprise", "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
+16 -2
View File
@@ -60,18 +60,32 @@ def get_discover(
}
# How many already-shown cards a reroll may carry. The client keeps a rolling window (see
# `Home.tsx`); the cap is here so a crafted URL cannot turn the exclusion list into an unbounded scan.
MAX_EXCLUDE = 32
@router.get("/pick")
def get_pick(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
exclude: str = "",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
Plex half while the YouTube half stood still, and the button would look broken half the time."""
return {"budget": budget, "hero": hero_mod.pick(db, user, budget, seed)}
Plex half while the YouTube half stood still, and the button would look broken half the time.
`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."""
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])),
}
@router.get("/pins")
+56
View File
@@ -356,6 +356,62 @@ def test_the_seed_decides_which_source_leads(db):
assert leaders == {"youtube", "plex"}
def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
"""A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns
the most recently progressed thing that fits, so without an exclusion the reroll handed the same
card straight back and the button looked dead (found in the browser, 2026-09-06)."""
user, _ = _seed_yt(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
VideoState(
user_id=user.id,
video_id="vshort",
status="new",
position_seconds=60,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
first = hero.pick(db, user, "any", seed=1)
assert first["card"]["id"] == "vmed"
# Same seed, same everything — only the exclusion differs.
second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"})
assert second["card"]["id"] == "vshort"
def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db):
"""The user rerolled past the SHOW they were shown; handing back its next episode under a
different title would be the same suggestion wearing a hat."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=120,
progress_updated_at=NOW,
)
)
db.commit()
shown = hero.pick(db, user, "any", seed=1)
assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode
# Excluding what the USER saw — the show — must move the tier on, not return the episode again.
after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"})
assert after is None or after["card"]["id"] != "ps1e1"
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────