`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.
174 lines
7.3 KiB
Python
174 lines
7.3 KiB
Python
"""The comfort shelf: pin a thing you can always fall back to, and have it be one click from playing.
|
|
|
|
Five kinds are pinnable — a video, a YouTube channel, a Plex show, a Plex movie, a playlist — because
|
|
comfort media is almost always "put on X", a series or a channel, and only sometimes one particular
|
|
video. `models.ComfortPin` carries that union as five nullable FKs under a `num_nonnulls(...) = 1`
|
|
check; this module is where the union is spoken about in one vocabulary, so no call site invents a
|
|
second one.
|
|
|
|
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
|
|
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
|
|
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is
|
|
short and hand-curated, and it buys the property the whole surface exists for: one click, no
|
|
intermediate page, no second decision.
|
|
|
|
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
|
|
yours" and the route turns that into its 404 — core never imports routes.
|
|
"""
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.discover import cards, queries
|
|
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
|
from app.plex import rollup
|
|
from app.plex.cards import leaf_card, show_card
|
|
from app.plex.lookups import by_rating_key
|
|
|
|
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
|
PIN_COLUMNS: dict[str, str] = {
|
|
"video": "video_id",
|
|
"channel": "channel_id",
|
|
"plex_item": "plex_item_id",
|
|
"plex_show": "plex_show_id",
|
|
"playlist": "playlist_id",
|
|
}
|
|
PIN_KINDS = tuple(PIN_COLUMNS)
|
|
|
|
|
|
def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, object] | None:
|
|
"""Translate the client's (kind, ref) into the (column, value) the row stores, or None.
|
|
|
|
The client speaks the ids the rest of the API speaks: a video id, a channel id, a Plex
|
|
`rating_key`, a playlist id. Plex's internal integer keys never leave the backend, so the
|
|
translation belongs here rather than in five call sites.
|
|
|
|
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
|
|
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the
|
|
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
|
|
all — a constant that called itself the one vocabulary while nothing spoke it."""
|
|
col = PIN_COLUMNS.get(kind)
|
|
if col is None:
|
|
return None
|
|
if kind == "video":
|
|
return (col, ref) if db.get(Video, ref) is not None else None
|
|
if kind == "channel":
|
|
return (col, ref) if db.get(Channel, ref) is not None else None
|
|
if kind == "plex_item":
|
|
it = by_rating_key(db, PlexItem, ref)
|
|
return (col, it.id) if it is not None else None
|
|
if kind == "plex_show":
|
|
sh = by_rating_key(db, PlexShow, ref)
|
|
return (col, sh.id) if sh is not None else None
|
|
try:
|
|
pid = int(ref)
|
|
except ValueError:
|
|
return None
|
|
pl = db.get(Playlist, pid)
|
|
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
|
|
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
|
|
# the same reason `owned_or_404` 404s rather than 403s.
|
|
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
|
|
|
|
|
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
|
return list(
|
|
db.execute(
|
|
select(ComfortPin)
|
|
.where(ComfortPin.user_id == user.id)
|
|
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
def next_position(db: Session, user: User) -> int:
|
|
"""New pins land at the end of the shelf.
|
|
|
|
`max(position) + 1`, not the row count. Positions are never renumbered on removal — `remove_pin`
|
|
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
|
|
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
|
|
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
|
|
Same idiom as `routes/playlists._next_playlist_position`."""
|
|
return (
|
|
db.scalar(
|
|
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
|
|
ComfortPin.user_id == user.id
|
|
)
|
|
)
|
|
+ 1
|
|
)
|
|
|
|
|
|
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
|
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
|
|
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
|
card: dict | None = None
|
|
nxt: dict | None = None
|
|
|
|
if pin.video_id is not None:
|
|
row = queries.yt_video_row(db, user, pin.video_id)
|
|
if row is None:
|
|
return None
|
|
card = cards.from_video(row)
|
|
nxt = dict(card) # a video plays itself
|
|
|
|
elif pin.channel_id is not None:
|
|
ch = db.get(Channel, pin.channel_id)
|
|
if ch is None:
|
|
return None
|
|
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
|
|
row = queries.channel_next_video(db, user, ch.id)
|
|
nxt = cards.from_video(row) if row else None
|
|
|
|
elif pin.plex_item_id is not None:
|
|
it = db.get(PlexItem, pin.plex_item_id)
|
|
if it is None:
|
|
return None
|
|
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id)
|
|
card = cards.from_plex(leaf_card(it, st))
|
|
nxt = dict(card) # a movie or a single episode plays itself
|
|
|
|
elif pin.plex_show_id is not None:
|
|
sh = db.get(PlexShow, pin.plex_show_id)
|
|
if sh is None:
|
|
return None
|
|
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
|
card = cards.from_plex(show_card(sh, status))
|
|
ep = queries.next_episode(db, user.id, sh.id)
|
|
nxt = cards.from_plex(ep) if ep else None
|
|
|
|
elif pin.playlist_id is not None:
|
|
pl = db.get(Playlist, pin.playlist_id)
|
|
if pl is None:
|
|
return None
|
|
count, row = queries.playlist_summary(db, user, pl.id)
|
|
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
|
nxt = cards.from_video(row) if row else None
|
|
|
|
if card is None:
|
|
return None
|
|
# A COPY, never the card itself: a self-playing pin (a video, a movie) would otherwise make
|
|
# `card["next"] is card`, which serializes as a circular reference and 500s the whole shelf. The
|
|
# copy is taken before `pin_id`/`next` are attached, so `next` stays a plain playable card.
|
|
card["pin_id"] = pin.id
|
|
card["next"] = nxt
|
|
return card
|
|
|
|
|
|
def shelf(db: Session, user: User) -> list[dict]:
|
|
"""The whole shelf, in the user's own order."""
|
|
out = []
|
|
for pin in list_pins(db, user):
|
|
card = _shelf_card(db, user, pin)
|
|
if card is not None:
|
|
out.append(card)
|
|
return out
|
|
|
|
|
|
def playables(shelf_cards: list[dict]) -> list[dict]:
|
|
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
|
|
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
|
|
return [c["next"] for c in shelf_cards if c.get("next")]
|