feat(e24): the Home surface read-model — hero, resume rail, comfort shelf

E24 S1. The anti-paralysis answer layer, served from our own Postgres with no
upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's
own comfort shelf.

Survey first, because the roadmap line was wrong about the starting point: Plex
already ships the whole trio (E5's Discover rails, including a reshuffling
Surprise me), the YouTube side ships none of it, and nothing anywhere answers
the question across both. The data already existed on both sides — VideoState
and PlexState are structurally identical, the feed has an `in_progress` filter
and a seeded shuffle, and the YT player already resumes position.

- app/discover/budget.py — the time bands. "How much time do you have"
  (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire:
  deterministic, answerable from a column we have, and it picks the source for
  free. Matched against REMAINING time, so a film with 8 minutes left is a
  ten-minute answer. An unknown runtime fits only the unbounded band.
- app/discover/cards.py — a thin unified card that carries the NATIVE feed /
  Plex payload, so the page renders with the existing card components instead of
  a third one. `aspect` is the one new field: a rail needs to know poster from
  16:9, and that is a property of the artwork, not of the module.
- app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather
  than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a
  surface that has a sidebar. One seed drives both halves so a reroll moves
  them together.
- app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in
  the payload so the client labels it honestly instead of guessing.
- comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1
  rather than a flag or a polymorphic string ref. A flag cannot order a shelf or
  hold a channel; a string ref would let a deleted library item leave a dead tile
  on the one surface whose whole job is to be reliable.

Two honest-state rules the tests pin: the surprise draw excludes anything
already started (the resume tier may have skipped it for not fitting, and
offering it back as "Meglepetés" would contradict the question), and which
source leads the draw follows the seed, so the Plex library can actually answer
"egy este".

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770,
pytest 987.
This commit is contained in:
2026-09-06 03:31:26 +02:00
parent 5703c4d713
commit 53b50231ff
12 changed files with 1635 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
"""E24 · the Home surface's HTTP shell.
Thin by construction: every decision — what counts as in progress, what fits a time budget, which
tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask
the same questions without a request. This file validates, gates and serializes.
The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a
landing page that fires four requests to answer one question would spend its whole budget on the
thing it exists to remove.
"""
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import current_user, require_human
from app.db import get_db
from app.discover import hero as hero_mod
from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User
from app.routes._common import owned_or_404
router = APIRouter(prefix="/api/discover", tags=["discover"])
class PinIn(BaseModel):
kind: Literal["video", "channel", "plex_item", "plex_show", "playlist"]
# The id the rest of the API speaks for that kind: a video id, a channel id, a Plex rating_key,
# or a playlist id. `pins.resolve_ref` does the translation to the row's internal key.
ref: str
class ReorderIn(BaseModel):
ids: list[int]
@router.get("")
def get_discover(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the Home page renders, in one pass.
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load."""
resume = rails.continue_watching(db, user)
shelf = pins.shelf(db, user)
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
"continue_watching": resume,
"comfort": shelf,
}
@router.get("/pick")
def get_pick(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
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)}
@router.get("/pins")
def list_pins(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page."""
return {"items": pins.shelf(db, user)}
@router.post("/pins")
def add_pin(
body: PinIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Pin something to the comfort shelf.
`require_human`, like every other personal-state write: the shared demo account must not be able
to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the
existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique
constraint is the mechanism, not the message."""
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
if resolved is None:
raise HTTPException(status_code=404, detail="Unknown item")
column, value = resolved
existing = db.execute(
select(ComfortPin).where(
ComfortPin.user_id == user.id,
getattr(ComfortPin, column) == value,
)
).scalar_one_or_none()
if existing is not None:
return {"ok": True, "id": existing.id, "created": False}
pin = ComfortPin(user_id=user.id, position=pins.next_position(db, user), **{column: value})
db.add(pin)
try:
db.commit()
except IntegrityError:
# A concurrent pin of the same thing from a second tab. The unique constraint did its job;
# the user's intent ("be on my shelf") is satisfied either way, so report the winner rather
# than a 500 the user can do nothing with.
db.rollback()
winner = db.execute(
select(ComfortPin).where(
ComfortPin.user_id == user.id,
getattr(ComfortPin, column) == value,
)
).scalar_one_or_none()
if winner is None:
raise
return {"ok": True, "id": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True}
@router.delete("/pins/{pin_id}")
def remove_pin(
pin_id: int,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
pin = owned_or_404(db, ComfortPin, pin_id, user)
db.delete(pin)
db.commit()
return {"deleted": pin_id}
@router.post("/pins/reorder")
def reorder_pins(
body: ReorderIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Rewrite the shelf order from a full list of pin ids.
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
deleted meanwhile should reorder what still exists, not fail wholesale."""
owned = {p.id for p in pins.list_pins(db, user)}
for position, pin_id in enumerate(i for i in body.ids if i in owned):
db.execute(
update(ComfortPin)
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
.values(position=position)
)
db.commit()
return {"ok": True}