The rail draws three unseen titles per kind under the sidebar filters; on a small filtered pool every reshuffle dealt the same few again. The redraw now sends the draw on screen (exclude=), the server deals the rest of the pool first and tops up from the excluded titles only when the pool is short, so a small pool still fills the rail. (The two discover.py baseline entries went with the rewritten draw.)
503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""Plex Discover: the rail-surface read-model.
|
||
|
||
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 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
|
||
their `art` + 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.collation import sorted_words
|
||
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 display_title, 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.discover.queries import RAIL_LIMIT
|
||
from app.plex.tmdb import resolve_recs
|
||
from app.routes.plex._shared import _norm_lang
|
||
|
||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||
|
||
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], lang: str) -> list[dict]:
|
||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||
return [movie_card(it, sts.get(it.id), lang=lang) for it in items]
|
||
|
||
|
||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||
return [show_card(s, statuses.get(s.id, "new"), lang=lang) 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 — a
|
||
# 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, lang), strict=True)
|
||
}
|
||
scards = {
|
||
s.rating_key: c
|
||
for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
|
||
}
|
||
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, lang=lang)))
|
||
if show_ids:
|
||
eq = (
|
||
select(PlexItem, PlexState, PlexShow)
|
||
.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 in db.execute(eq).all():
|
||
card = leaf_card(
|
||
ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang
|
||
)
|
||
rows.append((st.watched_at, card))
|
||
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, lang=lang)))
|
||
|
||
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", lang=lang)))
|
||
|
||
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
|
||
alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))}
|
||
top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[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, lang=lang)
|
||
# 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, lang), strict=True)
|
||
}
|
||
scards = {
|
||
s.rating_key: c
|
||
for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
|
||
}
|
||
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": display_title(anchor, lang)},
|
||
"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),
|
||
}
|
||
|
||
|
||
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||
fresh = (
|
||
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||
.scalars()
|
||
.all()
|
||
if exclude
|
||
else []
|
||
)
|
||
if len(fresh) == per:
|
||
return list(fresh)
|
||
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||
return [*fresh, *rest.all()]
|
||
|
||
|
||
@router.get("/surprise")
|
||
def surprise(
|
||
scope: str = "both",
|
||
lang: str = "en",
|
||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||
# The previous draw's rating keys: a reshuffle deals other titles first.
|
||
exclude: list[str] = Query(default=[], max_length=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 = _draw(
|
||
db,
|
||
_movie_meta(
|
||
select(PlexItem).where(
|
||
PlexItem.kind == "movie",
|
||
PlexItem.library_id.in_(movie_ids),
|
||
~watched_movie,
|
||
),
|
||
p,
|
||
lang,
|
||
f,
|
||
),
|
||
PlexItem.rating_key,
|
||
per,
|
||
exclude,
|
||
)
|
||
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 = _draw(
|
||
db,
|
||
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,
|
||
),
|
||
PlexShow.rating_key,
|
||
per,
|
||
exclude,
|
||
)
|
||
mcards = _movie_cards(db, user.id, movies, lang)
|
||
scards = _show_cards(db, user.id, shows, lang)
|
||
# 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]}
|