feat(plex): unify movies + shows into one cross-library browser

Collapse the separate Movie/Show library sections into ONE unified library with a
shared search + shared filter sidebar and a Movies/Shows/Both scope selector.

Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and
shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with
the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user
watch-state (a show's state = the aggregate of its episodes). On a search that also
matches episodes, matching episodes come back in a separate 'episodes' list (the grouped
'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's
libraries). /item and /show now return their library section key (for the admin
collection editor, since there's no single library prop in the unified view).

Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets +
browse follow the scope (App's plexLib repurposed to a validated scope, default both).
PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section
on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a
clickable title (not just the poster), and a movie/show type tag. The quick watched
toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could
mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/
content-rating) are clickable filters; clicking a person widens the scope to Both so the
result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de).

Still pending from the polish list (next pass): season-card quick toggles (watched +
add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast
customize menu on the series pages.
This commit is contained in:
npeter83 2026-07-11 00:15:49 +02:00
parent 11b7558c6c
commit 736db017e4
9 changed files with 579 additions and 269 deletions

View file

@ -18,7 +18,7 @@ from urllib.parse import quote
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse
from sqlalchemy import and_, case, func, or_, text
from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_, text
from sqlalchemy.orm import Session, aliased
from app import sysconfig
@ -301,6 +301,15 @@ def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int,
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
def _lib_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
return {
"id": e.rating_key,
@ -342,6 +351,38 @@ def _added_cutoff(within: str | None) -> datetime | None:
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
def _apply_meta_filters(q, model, p: dict):
"""Apply the shared metadata filters (identical column names on plex_items + plex_shows) to a
query over `model`. `p` is the request's filter params dict. Duration is movie-only, applied by
the caller. Kept DRY so movies, shows, and the unified cross-library browse filter identically."""
gsel = _csv(p.get("genres"))
if gsel:
conds = [model.genres.contains([g]) for g in gsel]
q = q.filter(and_(*conds) if p.get("genre_mode") == "all" else or_(*conds))
crs = _csv(p.get("content_ratings"))
if crs:
q = q.filter(model.content_rating.in_(crs))
if p.get("year_min") is not None:
q = q.filter(model.year >= p["year_min"])
if p.get("year_max") is not None:
q = q.filter(model.year <= p["year_max"])
if p.get("rating_min") is not None:
q = q.filter(model.rating >= p["rating_min"])
cutoff = _added_cutoff(p.get("added_within"))
if cutoff is not None:
q = q.filter(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND
q = q.filter(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND
q = q.filter(model.cast_names.contains([a]))
stds = _csv(p.get("studios"))
if stds: # OR
q = q.filter(model.studio.in_(stds))
if p.get("collection"):
q = q.filter(model.collection_keys.contains([p["collection"]]))
return q
@router.get("/browse")
def browse(
library: str,
@ -507,15 +548,201 @@ def browse(
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
@router.get("/facets")
def facets(
library: str,
@router.get("/library")
def unified_library(
scope: str = "both",
q: str | None = None,
sort: str = "added",
sort_dir: str = "desc",
show: str = "all",
offset: int = 0,
limit: int = Query(default=40, ge=1, le=100),
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Available filter values for a movie OR show library — genres + content ratings (with counts)
and the year/rating[/duration] bounds so the sidebar only offers what the library contains.
Shows carry the same filterable metadata as movies now (0052), minus duration."""
"""Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to
movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of
its episodes). On a search that also matches episodes (and shows are in scope), the matching
episodes come back in a separate `episodes` list (grouped result the 'Episodes' section)."""
offset = max(0, offset)
p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"added_within": added_within, "directors": directors, "actors": actors,
"studios": studios, "collection": collection,
}
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
ts = _to_tsquery_str(q) if q else None
tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
selects = []
if want_movies:
st = aliased(PlexState)
m_status = case(
(st.status == "watched", "watched"),
(and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress"),
else_="new",
)
mq = (
db.query(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
PlexItem.title.label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
(func.ts_rank(PlexItem.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
)
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
.filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
if show == "watched":
mq = mq.filter(st.status == "watched")
elif show == "in_progress":
mq = mq.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
elif show == "unwatched":
mq = mq.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else:
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
mq = _apply_meta_filters(mq, PlexItem, p)
if duration_min is not None:
mq = mq.filter(PlexItem.duration_s >= duration_min)
if duration_max is not None:
mq = mq.filter(PlexItem.duration_s <= duration_max)
if tsq is not None:
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
selects.append(mq)
if want_shows:
agg = _show_agg_subq(db, user.id).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = case(
(and_(tot > 0, wat >= tot), "watched"),
(or_(wat > 0, inp > 0), "in_progress"),
else_="new",
)
sq = (
db.query(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
PlexShow.title.label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
(func.ts_rank(PlexShow.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.filter(PlexShow.library_id.in_(show_lib_ids))
)
if show == "watched":
sq = sq.filter(tot > 0, wat >= tot)
elif show == "in_progress":
sq = sq.filter(or_(wat > 0, inp > 0), wat < tot)
elif show == "unwatched":
sq = sq.filter(wat == 0, inp == 0)
sq = _apply_meta_filters(sq, PlexShow, p)
if tsq is not None:
sq = sq.filter(PlexShow.search_vector.op("@@")(tsq))
selects.append(sq)
if not selects:
return {"scope": scope, "total": 0, "offset": offset, "limit": limit, "items": [], "episodes": []}
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery()
total = db.query(func.count()).select_from(u).scalar() or 0
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = {
"title": func.lower(u.c.title),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
}.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = db.query(u).order_by(*order).offset(offset).limit(limit).all()
items = []
for r in rows:
if r.kind == "movie":
items.append({
"id": r.rk, "type": "movie", "title": r.title, "year": r.year,
"duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable, "status": r.status, "position_seconds": r.position_seconds,
})
else:
items.append({
"id": r.rk, "type": "show", "title": r.title, "year": r.year,
"thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status,
})
episodes = []
if tsq is not None and want_shows:
ep_st = aliased(PlexState)
eps = (
db.query(PlexItem, ep_st, PlexShow.title.label("show_title"))
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.filter(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
PlexItem.search_vector.op("@@")(tsq),
)
.order_by(func.ts_rank(PlexItem.search_vector, tsq).desc())
.limit(24)
.all()
)
for e, s, show_title in eps:
card = _episode_card(e, s)
card["show_title"] = show_title
episodes.append(card)
return {"scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes}
@router.get("/facets")
def facets(
scope: str = "both",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Available filter values for the unified library, scoped to movie|show|both: genres + content
ratings (with counts merged across the scope's libraries) and the year/rating/duration bounds, so
the sidebar only offers what the scope actually contains. Duration bounds are movie-only."""
empty = {
"genres": [],
"content_ratings": [],
@ -525,38 +752,56 @@ def facets(
"duration_min": None,
"duration_max": None,
}
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None:
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_ids = [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") else []
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
if lib.kind == "movie":
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
dur_sel = "min(duration_s), max(duration_s)"
else: # show library — same columns on plex_shows, no per-row duration
base = "FROM plex_shows WHERE library_id = :lib"
dur_sel = "NULL, NULL"
genres = db.execute(
text(
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
"GROUP BY g ORDER BY c DESC, g"
),
{"lib": lib.id},
).all()
crs = db.execute(
text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"),
{"lib": lib.id},
).all()
b = db.execute(
text(f"SELECT min(year), max(year), max(rating), {dur_sel} {base}"),
{"lib": lib.id},
).first()
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def collect(table: str, ids: list[int], with_dur: bool) -> None:
if not ids:
return
base = f"FROM {table} WHERE library_id = ANY(:ids)"
for g, c in db.execute(
text(f"SELECT g, count(*) c FROM (SELECT jsonb_array_elements_text(genres) g {base}) x GROUP BY g"),
{"ids": ids},
).all():
genre_counts[g] = genre_counts.get(g, 0) + c
for cr, c in db.execute(
text(f"SELECT content_rating, count(*) c {base} AND content_rating IS NOT NULL GROUP BY content_rating"),
{"ids": ids},
).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
dur_sel = ", min(duration_s), max(duration_s)" if with_dur else ""
b = db.execute(text(f"SELECT min(year), max(year), max(rating){dur_sel} {base}"), {"ids": ids}).first()
if b[0] is not None:
years.append(b[0])
if b[1] is not None:
years.append(b[1])
if b[2] is not None:
ratings.append(float(b[2]))
if with_dur:
if b[3] is not None:
durs.append(b[3])
if b[4] is not None:
durs.append(b[4])
collect("plex_items", movie_ids, with_dur=True)
collect("plex_shows", show_ids, with_dur=False)
return {
"genres": [{"value": g, "count": c} for g, c in genres],
"content_ratings": [{"value": cr, "count": c} for cr, c in crs],
"year_min": b[0],
"year_max": b[1],
"rating_max": float(b[2]) if b[2] is not None else None,
"duration_min": b[3],
"duration_max": b[4],
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: (-kv[1], kv[0]))],
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
"duration_min": min(durs) if durs else None,
"duration_max": max(durs) if durs else None,
}
@ -1079,6 +1324,7 @@ def show_detail(
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": sh.genres or [],
@ -1636,6 +1882,7 @@ def item_detail(
"playable": it.playable,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
"cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),