`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.
469 lines
18 KiB
Python
469 lines
18 KiB
Python
"""Plex Discover: the rail-surface read-model (E5 S4).
|
||
|
||
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
|
||
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
|
||
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
|
||
recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
|
||
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||
card paths); the HU overlay coalesces only in the main grid.
|
||
|
||
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||
the client hides). Genre-language matches the grid; duration is movie-only.
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.auth import current_user
|
||
from app.db import get_db
|
||
from app.models import PlexItem, PlexShow, PlexState, User
|
||
from app.plex import rollup
|
||
from app.plex.cards import leaf_card, movie_card, show_card
|
||
from app.plex.query import (
|
||
LibraryFilters,
|
||
apply_meta_filters,
|
||
apply_show_state_filter,
|
||
content_libs,
|
||
genres_col,
|
||
meta_filter_conds,
|
||
top_art_keys,
|
||
)
|
||
from app.plex.tmdb import resolve_recs
|
||
from app.routes.plex._shared import _norm_lang
|
||
|
||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||
|
||
RAIL_LIMIT = 20
|
||
GENRE_TILES = 12
|
||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||
|
||
|
||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||
movie_ids, show_ids = content_libs(db)
|
||
return (
|
||
movie_ids if scope in ("movie", "both") else [],
|
||
show_ids if scope in ("show", "both") else [],
|
||
)
|
||
|
||
|
||
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||
q = apply_meta_filters(q, PlexItem, p, lang)
|
||
if f.duration_min is not None:
|
||
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||
if f.duration_max is not None:
|
||
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||
return q
|
||
|
||
|
||
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
|
||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||
return [movie_card(it, sts.get(it.id)) for it in items]
|
||
|
||
|
||
def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
|
||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||
return [show_card(s, statuses.get(s.id, "new")) for s in shows]
|
||
|
||
|
||
def _recently_added(
|
||
db: Session,
|
||
uid: int,
|
||
movie_ids: list[int],
|
||
show_ids: list[int],
|
||
p: dict,
|
||
lang: str,
|
||
f: LibraryFilters,
|
||
) -> list[dict]:
|
||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||
movies = (
|
||
db.execute(
|
||
_movie_meta(
|
||
select(PlexItem).where(
|
||
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||
),
|
||
p,
|
||
lang,
|
||
f,
|
||
)
|
||
.order_by(PlexItem.added_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
if movie_ids
|
||
else []
|
||
)
|
||
shows = (
|
||
db.execute(
|
||
apply_meta_filters(
|
||
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||
)
|
||
.order_by(PlexShow.added_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
if show_ids
|
||
else []
|
||
)
|
||
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — the
|
||
# E24 review found this one still on the older `(is not None, value)` form, i.e. one rule in two
|
||
# spellings, with the two comments explaining it differently.
|
||
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
|
||
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
|
||
top = tagged[:RAIL_LIMIT]
|
||
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||
# share one), so the merge doesn't depend on object identity staying alive.
|
||
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||
top_shows = [o for _, kind, o in top if kind == "show"]
|
||
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))}
|
||
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))}
|
||
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
||
|
||
|
||
def _recently_watched(
|
||
db: Session,
|
||
uid: int,
|
||
movie_ids: list[int],
|
||
show_ids: list[int],
|
||
p: dict,
|
||
lang: str,
|
||
f: LibraryFilters,
|
||
) -> list[dict]:
|
||
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||
rows: list[tuple[object, dict]] = [] # (watched_at, card)
|
||
if movie_ids:
|
||
mq = (
|
||
select(PlexItem, PlexState)
|
||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||
.where(
|
||
PlexItem.kind == "movie",
|
||
PlexItem.library_id.in_(movie_ids),
|
||
PlexState.status == "watched",
|
||
)
|
||
)
|
||
for it, st in db.execute(
|
||
_movie_meta(mq, p, lang, f)
|
||
.order_by(PlexState.watched_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
).all():
|
||
rows.append((st.watched_at, leaf_card(it, st)))
|
||
if show_ids:
|
||
eq = (
|
||
select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
|
||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||
.where(
|
||
PlexItem.kind == "episode",
|
||
PlexItem.library_id.in_(show_ids),
|
||
PlexState.status == "watched",
|
||
)
|
||
.order_by(PlexState.watched_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
)
|
||
for ep, st, sh_title, sh_rk in db.execute(eq).all():
|
||
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
|
||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||
|
||
|
||
def _continue_watching(
|
||
db: Session,
|
||
uid: int,
|
||
movie_ids: list[int],
|
||
show_ids: list[int],
|
||
p: dict,
|
||
lang: str,
|
||
f: LibraryFilters,
|
||
) -> list[dict]:
|
||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
|
||
|
||
if movie_ids:
|
||
mq = (
|
||
select(PlexItem, PlexState)
|
||
.join(
|
||
PlexState,
|
||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||
)
|
||
.where(
|
||
PlexItem.kind == "movie",
|
||
PlexItem.library_id.in_(movie_ids),
|
||
PlexState.position_seconds > 0,
|
||
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
|
||
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
|
||
or_(
|
||
PlexState.status.is_(None),
|
||
PlexState.status.notin_(["watched", "hidden"]),
|
||
),
|
||
)
|
||
)
|
||
mrows = db.execute(
|
||
_movie_meta(mq, p, lang, f)
|
||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
).all()
|
||
for it, st in mrows:
|
||
rows.append((st.progress_updated_at, movie_card(it, st)))
|
||
|
||
if show_ids:
|
||
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
|
||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||
sq = apply_show_state_filter(
|
||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||
prog.c.total,
|
||
prog.c.watched,
|
||
prog.c.inprog,
|
||
"in_progress",
|
||
).where(PlexShow.library_id.in_(show_ids))
|
||
srows = db.execute(
|
||
apply_meta_filters(sq, PlexShow, p, lang)
|
||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||
.limit(RAIL_LIMIT)
|
||
).all()
|
||
for sh, resumed_at in srows:
|
||
rows.append((resumed_at, show_card(sh, "in_progress")))
|
||
|
||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||
|
||
|
||
def _genre_tiles(
|
||
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||
) -> list[dict]:
|
||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||
counts: dict[str, int] = {}
|
||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||
if not ids:
|
||
continue
|
||
gcol = genres_col(model, lang)
|
||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||
model.library_id.in_(ids),
|
||
func.jsonb_typeof(gcol) == "array",
|
||
*meta_filter_conds(model, p, lang),
|
||
)
|
||
if model is PlexItem:
|
||
base = base.where(model.kind == "movie")
|
||
expanded = base.subquery()
|
||
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||
counts[g] = counts.get(g, 0) + c
|
||
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
|
||
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||
art = top_art_keys(
|
||
db,
|
||
PlexItem,
|
||
movie_ids,
|
||
genres_col(PlexItem, lang),
|
||
[g for g, _ in top],
|
||
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||
)
|
||
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||
|
||
|
||
def _because_you_watched(
|
||
db: Session,
|
||
uid: int,
|
||
movie_ids: list[int],
|
||
show_ids: list[int],
|
||
p: dict,
|
||
lang: str,
|
||
f: LibraryFilters,
|
||
) -> dict | None:
|
||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||
if not movie_ids:
|
||
return None
|
||
anchor = (
|
||
db.execute(
|
||
select(PlexItem)
|
||
.join(
|
||
PlexState,
|
||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||
)
|
||
.where(
|
||
PlexItem.kind == "movie",
|
||
PlexItem.library_id.in_(movie_ids),
|
||
PlexState.status == "watched",
|
||
PlexItem.recommendations.isnot(None),
|
||
)
|
||
.order_by(PlexState.watched_at.desc().nullslast())
|
||
.limit(1)
|
||
)
|
||
.scalars()
|
||
.first()
|
||
)
|
||
if anchor is None:
|
||
return None
|
||
resolved = resolve_recs(db, anchor.recommendations)
|
||
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
|
||
rks = [
|
||
r["rating_key"]
|
||
for r in resolved
|
||
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
|
||
]
|
||
if not rks:
|
||
return None
|
||
movies = {
|
||
m.rating_key: m
|
||
for m in db.execute(
|
||
_movie_meta(
|
||
select(PlexItem).where(PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)),
|
||
p,
|
||
lang,
|
||
f,
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
}
|
||
shows = {
|
||
s.rating_key: s
|
||
for s in db.execute(
|
||
apply_meta_filters(
|
||
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
}
|
||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||
mcards = {
|
||
m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))
|
||
}
|
||
scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))}
|
||
items: list[dict] = []
|
||
for rk in rks:
|
||
if rk in mcards:
|
||
items.append(mcards[rk])
|
||
elif rk in scards:
|
||
items.append(scards[rk])
|
||
if not items:
|
||
return None
|
||
return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items}
|
||
|
||
|
||
@router.get("/discover")
|
||
def discover(
|
||
scope: str = "both",
|
||
lang: str = "en",
|
||
f: LibraryFilters = Depends(),
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||
is null when there's no anchor."""
|
||
lang = _norm_lang(lang)
|
||
p = f.as_dict()
|
||
movie_ids, show_ids = _scope_libs(db, scope)
|
||
return {
|
||
"scope": scope,
|
||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||
}
|
||
|
||
|
||
@router.get("/surprise")
|
||
def surprise(
|
||
scope: str = "both",
|
||
lang: str = "en",
|
||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||
f: LibraryFilters = Depends(),
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||
the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
|
||
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||
one type."""
|
||
lang = _norm_lang(lang)
|
||
p = f.as_dict()
|
||
movie_ids, show_ids = _scope_libs(db, scope)
|
||
per = min(limit, SURPRISE_PER_KIND)
|
||
movies = []
|
||
if movie_ids:
|
||
# Exclude movies this user has watched.
|
||
watched_movie = (
|
||
select(PlexState.id)
|
||
.where(
|
||
PlexState.item_id == PlexItem.id,
|
||
PlexState.user_id == user.id,
|
||
PlexState.status == "watched",
|
||
)
|
||
.exists()
|
||
)
|
||
movies = (
|
||
db.execute(
|
||
_movie_meta(
|
||
select(PlexItem).where(
|
||
PlexItem.kind == "movie",
|
||
PlexItem.library_id.in_(movie_ids),
|
||
~watched_movie,
|
||
),
|
||
p,
|
||
lang,
|
||
f,
|
||
)
|
||
.order_by(func.random())
|
||
.limit(per)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
shows = []
|
||
if show_ids:
|
||
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||
prog = rollup.agg_subquery(db, user.id).subquery()
|
||
not_fully_watched = or_(
|
||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||
)
|
||
shows = (
|
||
db.execute(
|
||
apply_meta_filters(
|
||
select(PlexShow)
|
||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||
PlexShow,
|
||
p,
|
||
lang,
|
||
)
|
||
.order_by(func.random())
|
||
.limit(per)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
mcards = _movie_cards(db, user.id, movies)
|
||
scards = _show_cards(db, user.id, shows)
|
||
# Interleave movie/show cards, then trim to `limit`.
|
||
items: list[dict] = []
|
||
for i in range(max(len(mcards), len(scards))):
|
||
if i < len(mcards):
|
||
items.append(mcards[i])
|
||
if i < len(scards):
|
||
items.append(scards[i])
|
||
return {"scope": scope, "items": items[:limit]}
|