Files
siftlode/backend/app/routes/plex/discover.py
T
peter 6df257d7df fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn
Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
2026-10-09 22:01:28 +02:00

555 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (
SHOW_NEW,
ShowState,
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, SHOW_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, prog.c.inprog).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, inprog in srows:
state = ShowState("in_progress", (inprog or 0) > 0)
rows.append((resumed_at, show_card(sh, state, 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()]
def _kept(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
) -> list[dict]:
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
stays in it, marked watched, and one gone from the scope's libraries drops out."""
movies = (
db.execute(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
PlexItem.rating_key.in_(keep),
)
)
.scalars()
.all()
if movie_ids
else []
)
shows = (
db.execute(
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
)
.scalars()
.all()
if show_ids
else []
)
cards = {
c["id"]: c
for c in [
*_movie_cards(db, uid, list(movies), lang),
*_show_cards(db, uid, list(shows), lang),
]
}
return [cards[k] for k in keep if k in cards]
@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),
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
# toggle) while the draw itself stands until the ↻.
keep: 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)
if keep:
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
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]}