Full-epic /deep-review (11 finders + 12 adversarial verifiers) surfaced no HIGH; every mechanism finding calibrated to LOW on verification. Fix all 13: Correctness / reliability: - Combobox: Enter now commits the VISUALLY highlighted row (highlightIndex ?? activeIndex), not the bare keyboard activeIndex — so arrow-then-hover-then-Enter no longer toggles a different option than the one painted, and hover-with-no-arrow is no longer a silent no-op. - parse_person: dedup the known_for pool by (id, media_type), not the bare id — TMDB movie and tv id spaces are independent, so a same-id movie+show pair is two distinct credits (matches the collision-safe key the rest of the module already uses). - person-enrich: a per-person in-flight + 60s cooldown guard (people._should_schedule_enrich) dedups concurrent opens and rate-limits re-triggering while TMDB is degraded (a transient TmdbError leaves the watermark NULL), so bursts can't pile 8s tasks onto the shared threadpool. - PlexInfo compact()/money(): format with i18n.language (keeping currency USD) instead of a hardcoded en/en-US, matching the app's Intl convention. - delete the dead `plex.filter.add` i18n key (both locales) — the +Add button that used it is gone. Reuse: - _continue_watching reuses rollup.agg_subquery(include_resumed=True) + query.apply_show_state_filter instead of a third hand-inlined copy of the per-show rollup + in_progress predicate, so the Discover rail can't drift from the grid's definition. Test coverage (the gaps a regression would ship through): - resolve_library_guids scope-exclusion (a credit in a non-scoped/disabled library is dropped) + the []-vs-None distinction; parse_person cross-media-id retention; enrich_person transient (NULL watermark) and 404 (stamp) branches; _enrich_person_bg body (fresh session close/rollback, None-guard) + the cooldown dedup; continue_watching fully-watched-show exclusion; Combobox Enter-commits-highlighted + async/controlled-query mode; PopoverItem aria-checked contract; RatingsRow missing-IMDb + imdb==audience branches. Backend lane 413→420, FE vitest 351→357; all gates green. No migration.
297 lines
12 KiB
Python
297 lines
12 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.
|
|
"""
|
|
|
|
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 movie_card, show_card
|
|
from app.plex.query import apply_show_state_filter, genres_col
|
|
from app.plex.tmdb import resolve_recs
|
|
from app.routes.plex._shared import _content_libs, _norm_lang
|
|
|
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|
|
|
RAIL_LIMIT = 20
|
|
GENRE_TILES = 12
|
|
|
|
|
|
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_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]
|
|
) -> list[dict]:
|
|
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
|
movies = (
|
|
db.execute(
|
|
select(PlexItem)
|
|
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
|
.order_by(PlexItem.added_at.desc().nullslast())
|
|
.limit(RAIL_LIMIT)
|
|
).scalars().all()
|
|
if movie_ids
|
|
else []
|
|
)
|
|
shows = (
|
|
db.execute(
|
|
select(PlexShow)
|
|
.where(PlexShow.library_id.in_(show_ids))
|
|
.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). The `(is not None, value)` key sorts a missing
|
|
# timestamp last under reverse.
|
|
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] is not None, t[0]), 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 _continue_watching(
|
|
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
|
|
) -> 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:
|
|
mrows = db.execute(
|
|
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"]),
|
|
),
|
|
)
|
|
.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()
|
|
srows = db.execute(
|
|
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))
|
|
.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] is not None, r[0]), reverse=True)
|
|
return [card for _, card in rows[:RAIL_LIMIT]]
|
|
|
|
|
|
def _genre_tiles(
|
|
db: Session, movie_ids: list[int], show_ids: list[int], 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."""
|
|
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"
|
|
)
|
|
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]
|
|
return [{"value": g, "count": c} for g, c in top]
|
|
|
|
|
|
def _because_you_watched(
|
|
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
|
|
) -> 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."""
|
|
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(
|
|
select(PlexItem).where(
|
|
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
|
)
|
|
).scalars().all()
|
|
}
|
|
shows = {
|
|
s.rating_key: s
|
|
for s in db.execute(
|
|
select(PlexShow).where(PlexShow.rating_key.in_(rks))
|
|
).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",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""The Discover landing payload — every rail in one pass (serve-from-DB). 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)
|
|
movie_ids, show_ids = _scope_libs(db, scope)
|
|
return {
|
|
"scope": scope,
|
|
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids),
|
|
"recently_added": _recently_added(db, user.id, movie_ids, show_ids),
|
|
"genres": _genre_tiles(db, movie_ids, show_ids, lang),
|
|
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids),
|
|
}
|
|
|
|
|
|
@router.get("/surprise")
|
|
def surprise(
|
|
scope: str = "both",
|
|
limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
|
|
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). Movies and
|
|
shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type."""
|
|
movie_ids, show_ids = _scope_libs(db, scope)
|
|
movies = (
|
|
db.execute(
|
|
select(PlexItem)
|
|
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
|
.order_by(func.random())
|
|
.limit(limit)
|
|
).scalars().all()
|
|
if movie_ids
|
|
else []
|
|
)
|
|
shows = (
|
|
db.execute(
|
|
select(PlexShow)
|
|
.where(PlexShow.library_id.in_(show_ids))
|
|
.order_by(func.random())
|
|
.limit(limit)
|
|
).scalars().all()
|
|
if show_ids
|
|
else []
|
|
)
|
|
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]}
|