VideoState, Playlist, PlaylistItem, SavedView, ComfortPin, Tag and ChannelTag leave app/models.py for app/reader/models.py; app.tables registers them. No schema change.
204 lines
7.8 KiB
Python
204 lines
7.8 KiB
Python
"""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: 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
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import current_user
|
|
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 User
|
|
from app.reader.models import ComfortPin
|
|
from app.plex.cards import norm_lang
|
|
from app.routes._common import owned_or_404, not_found
|
|
|
|
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,
|
|
lang: str = "en",
|
|
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."""
|
|
lang = norm_lang(lang)
|
|
resume = rails.continue_watching(db, user, lang=lang)
|
|
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
|
|
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
|
|
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang)
|
|
return {
|
|
"budget": budget,
|
|
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf, lang=lang),
|
|
"continue_watching": resume,
|
|
"comfort": shelf,
|
|
}
|
|
|
|
|
|
# How many already-shown cards a reroll may carry, matched to the client's own rolling window
|
|
# (`SEEN_WINDOW` in `Home.tsx`). A cheap sanity clamp, and deliberately no more than that: `exclude`
|
|
# never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate
|
|
# pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is
|
|
# no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The
|
|
# comment that claimed otherwise was later corrected; the request line's own length is what
|
|
# actually bounds a hostile `exclude`.
|
|
MAX_EXCLUDE = 32
|
|
|
|
|
|
@router.get("/pick")
|
|
def get_pick(
|
|
budget: Budget = DEFAULT_BUDGET,
|
|
seed: int = 0,
|
|
exclude: str = "",
|
|
start: int = 0,
|
|
lang: str = "en",
|
|
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,
|
|
lang=norm_lang(lang),
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/pins")
|
|
def list_pins(
|
|
lang: str = "en",
|
|
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, lang=norm_lang(lang))}
|
|
|
|
|
|
@router.post("/pins")
|
|
def add_pin(
|
|
body: PinIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Pin something to the comfort 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 not_found("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:
|
|
# Not the unique constraint, then: the only other way in is the target row being pruned
|
|
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
|
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
|
# returns above — re-raising handed the user a 500 they could do nothing with.
|
|
raise not_found("item") from None
|
|
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(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin")
|
|
db.delete(pin)
|
|
db.commit()
|
|
return {"deleted": pin_id}
|
|
|
|
|
|
@router.post("/pins/reorder")
|
|
def reorder_pins(
|
|
body: ReorderIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Rewrite the shelf order from the list of pin ids the client shows.
|
|
|
|
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. The list may be
|
|
PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
|
|
pins.reorder(db, user, body.ids)
|
|
db.commit()
|
|
return {"ok": True}
|