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:
parent
11b7558c6c
commit
736db017e4
9 changed files with 579 additions and 269 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue