`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It now seeds a video, a movie and an episode, and asserts all three kinds are present before the loop, so it cannot go vacuous again. The Plex half of the surprise rail had no test for either of its promises: every test that reached `plex_surprise` passed budget="any" against a library with no state rows, so the duration bounds and the already-started exclusion could both be deleted with the suite green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half of one principle. `plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at all, leaving the rating_key → internal-id translation and both shelf arms uncovered, including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick` was exercised only through its domain function, so the route's own query-string translation could regress silently; the new test excludes all three candidates, which is what proves the whole list parsed rather than one unsplit string. `PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route re-typed the kind list. Both now drive off the constants. The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced, `(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via `==`, so two null keys compare equal and `None < None` is never reached; three independent runs confirmed it over every permutation. The rationale now says what the sentinel actually buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling has been swept to match. Its test is renamed and re-scoped to the mutation it does catch. Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player typechecked and shipped green. All eight fixes mutation-checked; every mutant died on its intended test.
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""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):
|
|
# From `pins.PIN_KINDS`, not a second hand-written list: the vocabulary, the schema columns and
|
|
# this request contract have to move together, and re-typing them here is how they would not.
|
|
kind: Literal[*pins.PIN_KINDS]
|
|
# 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,
|
|
}
|
|
|
|
|
|
# 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 = "",
|
|
start: 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.
|
|
|
|
`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.
|
|
|
|
`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]), start=start
|
|
),
|
|
}
|
|
|
|
|
|
@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}
|