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
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse
|
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 sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import sysconfig
|
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}
|
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:
|
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": e.rating_key,
|
"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
|
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")
|
@router.get("/browse")
|
||||||
def browse(
|
def browse(
|
||||||
library: str,
|
library: str,
|
||||||
|
|
@ -507,15 +548,201 @@ def browse(
|
||||||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/facets")
|
@router.get("/library")
|
||||||
def facets(
|
def unified_library(
|
||||||
library: str,
|
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),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Available filter values for a movie OR show library — genres + content ratings (with counts)
|
"""Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to
|
||||||
and the year/rating[/duration] bounds — so the sidebar only offers what the library contains.
|
movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of
|
||||||
Shows carry the same filterable metadata as movies now (0052), minus duration."""
|
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 = {
|
empty = {
|
||||||
"genres": [],
|
"genres": [],
|
||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
|
|
@ -525,38 +752,56 @@ def facets(
|
||||||
"duration_min": None,
|
"duration_min": None,
|
||||||
"duration_max": None,
|
"duration_max": None,
|
||||||
}
|
}
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
|
||||||
if lib is None:
|
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
|
return empty
|
||||||
if lib.kind == "movie":
|
|
||||||
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
|
genre_counts: dict[str, int] = {}
|
||||||
dur_sel = "min(duration_s), max(duration_s)"
|
cr_counts: dict[str, int] = {}
|
||||||
else: # show library — same columns on plex_shows, no per-row duration
|
years: list[int] = []
|
||||||
base = "FROM plex_shows WHERE library_id = :lib"
|
ratings: list[float] = []
|
||||||
dur_sel = "NULL, NULL"
|
durs: list[int] = []
|
||||||
genres = db.execute(
|
|
||||||
text(
|
def collect(table: str, ids: list[int], with_dur: bool) -> None:
|
||||||
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
|
if not ids:
|
||||||
"GROUP BY g ORDER BY c DESC, g"
|
return
|
||||||
),
|
base = f"FROM {table} WHERE library_id = ANY(:ids)"
|
||||||
{"lib": lib.id},
|
for g, c in db.execute(
|
||||||
).all()
|
text(f"SELECT g, count(*) c FROM (SELECT jsonb_array_elements_text(genres) g {base}) x GROUP BY g"),
|
||||||
crs = db.execute(
|
{"ids": ids},
|
||||||
text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"),
|
).all():
|
||||||
{"lib": lib.id},
|
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||||
).all()
|
for cr, c in db.execute(
|
||||||
b = db.execute(
|
text(f"SELECT content_rating, count(*) c {base} AND content_rating IS NOT NULL GROUP BY content_rating"),
|
||||||
text(f"SELECT min(year), max(year), max(rating), {dur_sel} {base}"),
|
{"ids": ids},
|
||||||
{"lib": lib.id},
|
).all():
|
||||||
).first()
|
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 {
|
return {
|
||||||
"genres": [{"value": g, "count": c} for g, c in genres],
|
"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 crs],
|
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],
|
||||||
"year_min": b[0],
|
"year_min": min(years) if years else None,
|
||||||
"year_max": b[1],
|
"year_max": max(years) if years else None,
|
||||||
"rating_max": float(b[2]) if b[2] is not None else None,
|
"rating_max": max(ratings) if ratings else None,
|
||||||
"duration_min": b[3],
|
"duration_min": min(durs) if durs else None,
|
||||||
"duration_max": b[4],
|
"duration_max": max(durs) if durs else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1079,6 +1324,7 @@ def show_detail(
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||||
|
"library": _lib_key(db, sh.library_id),
|
||||||
"content_rating": sh.content_rating,
|
"content_rating": sh.content_rating,
|
||||||
"rating": sh.rating,
|
"rating": sh.rating,
|
||||||
"genres": sh.genres or [],
|
"genres": sh.genres or [],
|
||||||
|
|
@ -1636,6 +1882,7 @@ def item_detail(
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
|
"library": _lib_key(db, it.library_id),
|
||||||
"cast": rich.get("cast", []),
|
"cast": rich.get("cast", []),
|
||||||
"imdb_rating": rich.get("imdb_rating"),
|
"imdb_rating": rich.get("imdb_rating"),
|
||||||
"imdb_id": rich.get("imdb_id"),
|
"imdb_id": rich.get("imdb_id"),
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,10 @@ export default function App() {
|
||||||
const channelsView: ChannelsView =
|
const channelsView: ChannelsView =
|
||||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
||||||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||||
|
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||||
|
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
|
||||||
|
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
|
||||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||||
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
||||||
|
|
@ -740,8 +743,8 @@ export default function App() {
|
||||||
{page === "plex" && meQuery.data!.plex_enabled && (
|
{page === "plex" && meQuery.data!.plex_enabled && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlexSidebar
|
<PlexSidebar
|
||||||
library={plexLib}
|
scope={plexScope}
|
||||||
setLibrary={setPlexLib}
|
setScope={setPlexScope}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
setShow={setPlexShowFilter}
|
setShow={setPlexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
|
|
@ -829,7 +832,8 @@ export default function App() {
|
||||||
<PlexBrowse
|
<PlexBrowse
|
||||||
q={plexQ}
|
q={plexQ}
|
||||||
onClearSearch={() => setPlexQ("")}
|
onClearSearch={() => setPlexQ("")}
|
||||||
library={plexLib}
|
scope={plexScope}
|
||||||
|
setScope={setPlexScope}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
filters={plexFilters}
|
filters={plexFilters}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Film,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Star,
|
Star,
|
||||||
|
Tv2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,8 +23,8 @@ import {
|
||||||
type PlexCard,
|
type PlexCard,
|
||||||
type PlexCastMember,
|
type PlexCastMember,
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
type PlexPerson,
|
|
||||||
type PlexSeasonDetail,
|
type PlexSeasonDetail,
|
||||||
|
type PlexUnifiedResult,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
|
|
@ -51,7 +53,8 @@ type Sub =
|
||||||
type Props = {
|
type Props = {
|
||||||
q: string;
|
q: string;
|
||||||
onClearSearch: () => void;
|
onClearSearch: () => void;
|
||||||
library: string;
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
|
setScope: (v: string) => void;
|
||||||
show: string;
|
show: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
|
|
@ -73,7 +76,8 @@ function dur(n?: number | null): string {
|
||||||
export default function PlexBrowse({
|
export default function PlexBrowse({
|
||||||
q,
|
q,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
library,
|
scope,
|
||||||
|
setScope,
|
||||||
show,
|
show,
|
||||||
sort,
|
sort,
|
||||||
filters,
|
filters,
|
||||||
|
|
@ -133,12 +137,12 @@ export default function PlexBrowse({
|
||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
queryKey: ["plex-library", scope, dq, sort, show, filters],
|
||||||
enabled: !!library && sub.view.kind === "grid",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexBrowse({
|
api.plexLibrary({
|
||||||
library,
|
scope,
|
||||||
q: dq || undefined,
|
q: dq || undefined,
|
||||||
sort,
|
sort,
|
||||||
show,
|
show,
|
||||||
|
|
@ -154,21 +158,8 @@ export default function PlexBrowse({
|
||||||
|
|
||||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||||
|
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
||||||
// Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
|
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
||||||
// adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else).
|
|
||||||
const peopleQ = useQuery({
|
|
||||||
queryKey: ["plex-people", library, dq],
|
|
||||||
queryFn: () => api.plexPeople(library, dq),
|
|
||||||
enabled: !!library && dq.length >= 2 && sub.view.kind === "grid",
|
|
||||||
});
|
|
||||||
const people = peopleQ.data?.people ?? [];
|
|
||||||
function addPerson(p: PlexPerson) {
|
|
||||||
const key = p.kind === "director" ? "directors" : "actors";
|
|
||||||
const cur = filters[key];
|
|
||||||
if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] });
|
|
||||||
onClearSearch(); // switch from the name search to the person filter (clears the search box)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||||
const sentinel = useRef<HTMLDivElement>(null);
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -197,8 +188,40 @@ export default function PlexBrowse({
|
||||||
sub.open({ kind: "info", id: card.id });
|
sub.open({ kind: "info", id: card.id });
|
||||||
}
|
}
|
||||||
async function toggleWatched(card: PlexCard) {
|
async function toggleWatched(card: PlexCard) {
|
||||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
const next = card.status === "watched" ? "new" : "watched";
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
// Optimistically flip the card in every cached library page so the quick toggle reacts instantly
|
||||||
|
// and reliably BOTH ways (mark and un-mark), then reconcile with the server.
|
||||||
|
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((pg) => ({
|
||||||
|
...pg,
|
||||||
|
items: pg.items.map((it) => (it.id === card.id ? { ...it, status: next } : it)),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
await api.plexSetState(card.id, next).catch(() => {});
|
||||||
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
|
}
|
||||||
|
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||||
|
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
||||||
|
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
||||||
|
function applyFilter(patch: Partial<PlexFilters>) {
|
||||||
|
const merged = { ...filters } as unknown as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
const cur = merged[k];
|
||||||
|
if (Array.isArray(v) && Array.isArray(cur)) {
|
||||||
|
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
||||||
|
} else {
|
||||||
|
merged[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFilters(merged as unknown as PlexFilters);
|
||||||
|
if (patch.actors?.length || patch.directors?.length) setScope("both");
|
||||||
|
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
|
||||||
|
sub.open({ kind: "grid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.view.kind === "player") {
|
if (sub.view.kind === "player") {
|
||||||
|
|
@ -225,28 +248,10 @@ export default function PlexBrowse({
|
||||||
return (
|
return (
|
||||||
<PlexInfoView
|
<PlexInfoView
|
||||||
id={infoId}
|
id={infoId}
|
||||||
library={library}
|
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||||
onFilter={(patch) => {
|
onFilter={applyFilter}
|
||||||
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
|
|
||||||
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
|
|
||||||
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
|
|
||||||
// info page instead of leaving the Plex module.
|
|
||||||
const merged = { ...filters } as unknown as Record<string, unknown>;
|
|
||||||
for (const [k, v] of Object.entries(patch)) {
|
|
||||||
const cur = merged[k];
|
|
||||||
if (Array.isArray(v) && Array.isArray(cur)) {
|
|
||||||
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
|
||||||
} else {
|
|
||||||
merged[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFilters(merged as unknown as PlexFilters);
|
|
||||||
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
|
|
||||||
sub.open({ kind: "grid" });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -255,11 +260,11 @@ export default function PlexBrowse({
|
||||||
return (
|
return (
|
||||||
<PlexShowView
|
<PlexShowView
|
||||||
showId={showId}
|
showId={showId}
|
||||||
library={library}
|
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
||||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||||
|
onFilter={applyFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -281,48 +286,9 @@ export default function PlexBrowse({
|
||||||
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Virtual person cards for a name search — click to add them to the filter. */}
|
|
||||||
{people.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted mb-2">{t("plex.people.match")}</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{people.map((p) => {
|
|
||||||
const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${p.kind}:${p.name}`}
|
|
||||||
onClick={() => addPerson(p)}
|
|
||||||
disabled={added}
|
|
||||||
className={`flex items-center gap-2.5 rounded-xl border p-1.5 pr-3 text-left transition ${
|
|
||||||
added ? "border-accent bg-accent/10" : "border-border bg-card hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
|
||||||
{p.photo ? (
|
|
||||||
<img src={p.photo} alt="" className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="grid h-full w-full place-items-center text-sm opacity-40">
|
|
||||||
{p.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium">{p.name}</div>
|
|
||||||
<div className="text-xs text-muted">
|
|
||||||
{t(`plex.people.${p.kind}`)} · {t("plex.people.count", { count: p.count })}
|
|
||||||
{added ? ` · ${t("plex.people.added")}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 && episodes.length === 0 ? (
|
||||||
dq && plexFilterCount(filters) > 0 ? (
|
dq && plexFilterCount(filters) > 0 ? (
|
||||||
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
||||||
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
||||||
|
|
@ -339,6 +305,13 @@ export default function PlexBrowse({
|
||||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Titles grid — movies + shows mixed (visually tagged). */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<>
|
||||||
|
{episodes.length > 0 && (
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
|
|
@ -350,6 +323,20 @@ export default function PlexBrowse({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
||||||
|
{episodes.length > 0 && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
|
{episodes.map((ep) => (
|
||||||
|
<EpisodeCard key={ep.id} ep={ep} withShowTitle onPlay={() => onCard(ep)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={sentinel} className="h-8" />
|
<div ref={sentinel} className="h-8" />
|
||||||
|
|
@ -407,16 +394,14 @@ function PlexPosterCard({
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{/* Hover affordance: what a click does (play / resume / open show). */}
|
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
|
||||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
{isPlayable ? (
|
|
||||||
<div className="flex flex-col items-center gap-1 text-white">
|
<div className="flex flex-col items-center gap-1 text-white">
|
||||||
<Play className="w-10 h-10" fill="currentColor" />
|
<Play className="w-10 h-10" fill="currentColor" />
|
||||||
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
|
<span className="text-xs font-medium">
|
||||||
|
{card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||||
{isPlayable && (
|
{isPlayable && (
|
||||||
|
|
@ -467,13 +452,25 @@ function PlexPosterCard({
|
||||||
title={t(`plex.playable.${card.playable}`)}
|
title={t(`plex.playable.${card.playable}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||||
|
<span
|
||||||
|
className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90"
|
||||||
|
title={t(card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie")}
|
||||||
|
>
|
||||||
|
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||||
|
>
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -481,14 +478,12 @@ function PlexPosterCard({
|
||||||
|
|
||||||
function PlexInfoView({
|
function PlexInfoView({
|
||||||
id,
|
id,
|
||||||
library,
|
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
onFilter,
|
onFilter,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
library: string;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPlayItem: (id: string) => void;
|
onPlayItem: (id: string) => void;
|
||||||
|
|
@ -514,7 +509,7 @@ function PlexInfoView({
|
||||||
<PlexInfo
|
<PlexInfo
|
||||||
detail={q.data}
|
detail={q.data}
|
||||||
variant="page"
|
variant="page"
|
||||||
library={library}
|
library={q.data.library ?? undefined}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
|
|
@ -539,6 +534,16 @@ function epLabel(ep?: PlexCard | null): string | undefined {
|
||||||
return ep.title;
|
return ep.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A metadata value that filters the unified grid when clicked (if onClick is wired), else plain text.
|
||||||
|
function Fil({ onClick, className, children }: { onClick?: () => void; className?: string; children: ReactNode }) {
|
||||||
|
if (!onClick) return <span className={className}>{children}</span>;
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -613,25 +618,43 @@ function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CastStrip({ cast }: { cast: PlexCastMember[] }) {
|
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
{cast.map((c, i) => (
|
{cast.map((c, i) => {
|
||||||
<div key={i} className="w-20 shrink-0 text-center">
|
const inner = (
|
||||||
<div className="mx-auto h-20 w-20 overflow-hidden rounded-full border border-border bg-surface">
|
<>
|
||||||
|
<div className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}>
|
||||||
{c.thumb ? (
|
{c.thumb ? (
|
||||||
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid h-full w-full place-items-center opacity-40">{c.name.charAt(0)}</div>
|
<div className="grid h-full w-full place-items-center opacity-40">{c.name.charAt(0)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-xs font-medium line-clamp-2 leading-tight">{c.name}</div>
|
<div className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}>
|
||||||
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
|
{c.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return onFilter ? (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onFilter({ actors: [c.name] })}
|
||||||
|
title={t("plex.info.filterActor", { name: c.name })}
|
||||||
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="w-20 shrink-0 text-center">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
@ -666,7 +689,17 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void; onAdd: () => void }) {
|
function EpisodeCard({
|
||||||
|
ep,
|
||||||
|
onPlay,
|
||||||
|
onAdd,
|
||||||
|
withShowTitle,
|
||||||
|
}: {
|
||||||
|
ep: PlexCard;
|
||||||
|
onPlay: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
withShowTitle?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||||
const pct =
|
const pct =
|
||||||
|
|
@ -701,12 +734,21 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-1.5 flex items-start gap-1.5">
|
<div className="mt-1.5 flex items-start gap-1.5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{withShowTitle && ep.show_title && (
|
||||||
|
<div className="text-[11px] text-muted truncate">
|
||||||
|
{ep.show_title}
|
||||||
|
{ep.season_number != null && ep.episode_number != null
|
||||||
|
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-sm font-medium leading-tight">
|
<div className="text-sm font-medium leading-tight">
|
||||||
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||||
{ep.title}
|
{ep.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onAdd && (
|
||||||
<button
|
<button
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
title={t("plex.playlist.addEpisode")}
|
title={t("plex.playlist.addEpisode")}
|
||||||
|
|
@ -715,6 +757,7 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
||||||
>
|
>
|
||||||
<ListPlus className="w-4 h-4" />
|
<ListPlus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -722,18 +765,18 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
||||||
|
|
||||||
function PlexShowView({
|
function PlexShowView({
|
||||||
showId,
|
showId,
|
||||||
library,
|
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
onOpenSeason,
|
onOpenSeason,
|
||||||
onOpenShow,
|
onOpenShow,
|
||||||
|
onFilter,
|
||||||
}: {
|
}: {
|
||||||
showId: string;
|
showId: string;
|
||||||
library: string;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: (epRk: string, queue: string[]) => void;
|
onPlay: (epRk: string, queue: string[]) => void;
|
||||||
onOpenSeason: (seasonId: string) => void;
|
onOpenSeason: (seasonId: string) => void;
|
||||||
onOpenShow: (id: string) => void;
|
onOpenShow: (id: string) => void;
|
||||||
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -745,6 +788,7 @@ function PlexShowView({
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const show = d?.show;
|
const show = d?.show;
|
||||||
|
const showLib = show?.library ?? undefined;
|
||||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||||
const watched = show?.status === "watched";
|
const watched = show?.status === "watched";
|
||||||
async function markAll(w: boolean) {
|
async function markAll(w: boolean) {
|
||||||
|
|
@ -755,7 +799,7 @@ function PlexShowView({
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -776,22 +820,37 @@ function PlexShowView({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||||
{show.year && <span>{show.year}</span>}
|
{show.year != null && (
|
||||||
{show.content_rating && <span>· {show.content_rating}</span>}
|
<Fil onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
|
||||||
|
{show.year}
|
||||||
|
</Fil>
|
||||||
|
)}
|
||||||
|
{show.content_rating && (
|
||||||
|
<Fil onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
|
||||||
|
· {show.content_rating}
|
||||||
|
</Fil>
|
||||||
|
)}
|
||||||
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
|
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
|
||||||
{show.imdb_rating != null && (
|
{show.imdb_rating != null && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<button
|
||||||
|
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) }) : undefined}
|
||||||
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||||
|
>
|
||||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
{show.imdb_rating}
|
{show.imdb_rating}
|
||||||
</span>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{show.genres.length > 0 && (
|
{show.genres.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{show.genres.map((g) => (
|
{show.genres.map((g) => (
|
||||||
<span key={g} className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted">
|
<Fil
|
||||||
|
key={g}
|
||||||
|
className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||||
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||||
|
>
|
||||||
{g}
|
{g}
|
||||||
</span>
|
</Fil>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -824,7 +883,7 @@ function PlexShowView({
|
||||||
label={t("plex.playlist.addShow")}
|
label={t("plex.playlist.addShow")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isAdmin && library && (
|
{isAdmin && showLib && (
|
||||||
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
|
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -839,16 +898,16 @@ function PlexShowView({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{show.cast.length > 0 && <CastStrip cast={show.cast} />}
|
{show.cast.length > 0 && <CastStrip cast={show.cast} onFilter={onFilter} />}
|
||||||
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
|
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||||
{collOpen && show && (
|
{collOpen && show && showLib && (
|
||||||
<PlexCollectionEditor
|
<PlexCollectionEditor
|
||||||
item={{ id: show.id, title: show.title }}
|
item={{ id: show.id, title: show.title }}
|
||||||
library={library}
|
library={showLib}
|
||||||
memberOf={show.collection_keys}
|
memberOf={show.collection_keys}
|
||||||
onClose={() => setCollOpen(false)}
|
onClose={() => setCollOpen(false)}
|
||||||
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
|
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
|
||||||
|
|
@ -887,7 +946,7 @@ function PlexSeasonView({
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ export default function PlexInfo({
|
||||||
const setState = async (status: "new" | "watched") => {
|
const setState = async (status: "new" | "watched") => {
|
||||||
await api.plexSetState(detail.id, status).catch(() => {});
|
await api.plexSetState(detail.id, status).catch(() => {});
|
||||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||||
onStateChange?.();
|
onStateChange?.();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
|
||||||
|
|
||||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
||||||
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
||||||
|
|
@ -12,8 +11,8 @@ import { useDebounced } from "../lib/useDebounced";
|
||||||
// backend so the sidebar only offers what the library actually contains.
|
// backend so the sidebar only offers what the library actually contains.
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
library: string;
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
setLibrary: (v: string) => void;
|
setScope: (v: string) => void;
|
||||||
show: string;
|
show: string;
|
||||||
setShow: (v: string) => void;
|
setShow: (v: string) => void;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
|
@ -39,8 +38,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PlexSidebar({
|
export default function PlexSidebar({
|
||||||
library,
|
scope,
|
||||||
setLibrary,
|
setScope,
|
||||||
show,
|
show,
|
||||||
setShow,
|
setShow,
|
||||||
sort,
|
sort,
|
||||||
|
|
@ -52,7 +51,6 @@ export default function PlexSidebar({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
|
||||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
const [newPlaylist, setNewPlaylist] = useState("");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -64,33 +62,17 @@ export default function PlexSidebar({
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const libs = libsQ.data?.libraries ?? [];
|
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both).
|
||||||
const activeLib = libs.find((l) => l.key === library);
|
|
||||||
const isMovieLib = activeLib?.kind === "movie";
|
|
||||||
|
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: ["plex-facets", library],
|
queryKey: ["plex-facets", scope],
|
||||||
queryFn: () => api.plexFacets(library),
|
queryFn: () => api.plexFacets(scope),
|
||||||
enabled: !!library, // facets now come for movie AND show libraries (0052)
|
enabled: !!scope,
|
||||||
});
|
});
|
||||||
const facets = facetsQ.data;
|
const facets = facetsQ.data;
|
||||||
|
|
||||||
// Collections picker (searchable; the list is only fetched when none is active).
|
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
||||||
const [collSearch, setCollSearch] = useState("");
|
// collection"; there's no per-library picker in the unified cross-library scope.)
|
||||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
const fCount = plexFilterCount(filters);
|
||||||
const collectionsQ = useQuery({
|
|
||||||
queryKey: ["plex-collections", library, collSearchDeb],
|
|
||||||
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
|
||||||
enabled: !!library && !filters.collection, // collections apply to shows too
|
|
||||||
});
|
|
||||||
const collections = collectionsQ.data?.collections ?? [];
|
|
||||||
|
|
||||||
// Default to the first library once loaded (or if the stored one vanished).
|
|
||||||
useEffect(() => {
|
|
||||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
|
||||||
}, [libs, library, setLibrary]);
|
|
||||||
|
|
||||||
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
|
|
||||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||||
const anyActive = activeCount > 0;
|
const anyActive = activeCount > 0;
|
||||||
|
|
||||||
|
|
@ -103,7 +85,8 @@ export default function PlexSidebar({
|
||||||
setSort("added");
|
setSort("added");
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
||||||
|
const sorts = scope === "movie" ? MOVIE_SORTS : SHOW_SORTS;
|
||||||
const durBucketKey = DURATION_BUCKETS.find(
|
const durBucketKey = DURATION_BUCKETS.find(
|
||||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||||
)?.key;
|
)?.key;
|
||||||
|
|
@ -137,20 +120,19 @@ export default function PlexSidebar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||||
{/* Library scope */}
|
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||||
<Section label={t("plex.filter.library")}>
|
<Section label={t("plex.filter.scope")}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex gap-1">
|
||||||
{libs.map((l) => (
|
{(["both", "movie", "show"] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={l.key}
|
key={s}
|
||||||
onClick={() => setLibrary(l.key)}
|
onClick={() => setScope(s)}
|
||||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||||
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||||
<span className="flex-1 text-left truncate">{l.title}</span>
|
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||||
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -266,42 +248,18 @@ export default function PlexSidebar({
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
{/* Collection — active-collection chip only (set from an item's info page "Browse
|
||||||
|
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */}
|
||||||
|
{filters.collection && (
|
||||||
<Section label={t("plex.filter.collection")}>
|
<Section label={t("plex.filter.collection")}>
|
||||||
{filters.collection ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<RemovableChip
|
<RemovableChip
|
||||||
label={filters.collectionTitle || filters.collection}
|
label={filters.collectionTitle || filters.collection}
|
||||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
value={collSearch}
|
|
||||||
onChange={(e) => setCollSearch(e.target.value)}
|
|
||||||
placeholder={t("plex.filter.collectionSearch")}
|
|
||||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
|
||||||
/>
|
|
||||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
|
||||||
{collections.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
|
||||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
|
||||||
>
|
|
||||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
|
||||||
<span className="flex-1 truncate">{c.title}</span>
|
|
||||||
<span className="text-xs text-muted">{c.child_count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{collections.length === 0 && (
|
|
||||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* IMDb rating min */}
|
{/* IMDb rating min */}
|
||||||
<Section label={t("plex.filter.rating")}>
|
<Section label={t("plex.filter.rating")}>
|
||||||
|
|
@ -369,7 +327,7 @@ export default function PlexSidebar({
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||||
{isMovieLib && (
|
{scope === "movie" && (
|
||||||
<Section label={t("plex.filter.duration")}>
|
<Section label={t("plex.filter.duration")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
<Chip
|
<Chip
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
|
"scope": "Bibliothek",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "Alle",
|
||||||
|
"movie": "Filme",
|
||||||
|
"show": "Serien"
|
||||||
|
},
|
||||||
"show": "Anzeige",
|
"show": "Anzeige",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
|
|
@ -70,6 +76,10 @@
|
||||||
"markWatched": "Als gesehen markieren",
|
"markWatched": "Als gesehen markieren",
|
||||||
"markUnwatched": "Als ungesehen markieren",
|
"markUnwatched": "Als ungesehen markieren",
|
||||||
"seasons": "{{count}} Staffeln",
|
"seasons": "{{count}} Staffeln",
|
||||||
|
"unified": {
|
||||||
|
"titles": "Titel",
|
||||||
|
"episodes": "Folgen"
|
||||||
|
},
|
||||||
"series": {
|
"series": {
|
||||||
"seasons": "Staffeln",
|
"seasons": "Staffeln",
|
||||||
"backToShow": "Zurück zur Serie",
|
"backToShow": "Zurück zur Serie",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
"scope": "Library",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "All",
|
||||||
|
"movie": "Movies",
|
||||||
|
"show": "Shows"
|
||||||
|
},
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|
@ -70,6 +76,10 @@
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"seasons": "{{count}} seasons",
|
"seasons": "{{count}} seasons",
|
||||||
|
"unified": {
|
||||||
|
"titles": "Titles",
|
||||||
|
"episodes": "Episodes"
|
||||||
|
},
|
||||||
"series": {
|
"series": {
|
||||||
"seasons": "Seasons",
|
"seasons": "Seasons",
|
||||||
"backToShow": "Back to show",
|
"backToShow": "Back to show",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
|
"scope": "Könyvtár",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "Mind",
|
||||||
|
"movie": "Filmek",
|
||||||
|
"show": "Sorozatok"
|
||||||
|
},
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "Mind",
|
"all": "Mind",
|
||||||
|
|
@ -70,6 +76,10 @@
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"seasons": "{{count}} évad",
|
"seasons": "{{count}} évad",
|
||||||
|
"unified": {
|
||||||
|
"titles": "Címek",
|
||||||
|
"episodes": "Epizódok"
|
||||||
|
},
|
||||||
"series": {
|
"series": {
|
||||||
"seasons": "Évadok",
|
"seasons": "Évadok",
|
||||||
"backToShow": "Vissza a sorozathoz",
|
"backToShow": "Vissza a sorozathoz",
|
||||||
|
|
|
||||||
|
|
@ -663,6 +663,16 @@ export interface PlexBrowseResult {
|
||||||
limit: number;
|
limit: number;
|
||||||
items: PlexCard[];
|
items: PlexCard[];
|
||||||
}
|
}
|
||||||
|
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
||||||
|
// also matches episodes (the grouped "Episodes" section).
|
||||||
|
export interface PlexUnifiedResult {
|
||||||
|
scope: string; // movie | show | both
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
items: PlexCard[];
|
||||||
|
episodes: PlexCard[];
|
||||||
|
}
|
||||||
export interface PlexSeasonDetail {
|
export interface PlexSeasonDetail {
|
||||||
id: string; // season rating_key
|
id: string; // season rating_key
|
||||||
season_number: number | null;
|
season_number: number | null;
|
||||||
|
|
@ -682,6 +692,7 @@ export interface PlexShowDetail {
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: string;
|
art: string;
|
||||||
|
library?: string | null; // Plex section key (for the admin collection editor)
|
||||||
content_rating?: string | null;
|
content_rating?: string | null;
|
||||||
rating?: number | null;
|
rating?: number | null;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
|
|
@ -716,6 +727,7 @@ export interface PlexItemDetail {
|
||||||
playable: string; // direct | remux | transcode
|
playable: string; // direct | remux | transcode
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: string;
|
art: string;
|
||||||
|
library?: string | null; // Plex section key (for the admin collection editor)
|
||||||
cast: PlexCastMember[];
|
cast: PlexCastMember[];
|
||||||
imdb_rating?: number | null;
|
imdb_rating?: number | null;
|
||||||
imdb_id?: string | null;
|
imdb_id?: string | null;
|
||||||
|
|
@ -1164,16 +1176,16 @@ export const api = {
|
||||||
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
||||||
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
||||||
req("/api/plex/libraries"),
|
req("/api/plex/libraries"),
|
||||||
plexBrowse: (p: {
|
plexLibrary: (p: {
|
||||||
library: string;
|
scope: string; // movie | show | both
|
||||||
q?: string;
|
q?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
show?: string;
|
show?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
filters?: PlexFilters;
|
filters?: PlexFilters;
|
||||||
}): Promise<PlexBrowseResult> => {
|
}): Promise<PlexUnifiedResult> => {
|
||||||
const u = new URLSearchParams({ library: p.library });
|
const u = new URLSearchParams({ scope: p.scope });
|
||||||
if (p.q) u.set("q", p.q);
|
if (p.q) u.set("q", p.q);
|
||||||
if (p.sort) u.set("sort", p.sort);
|
if (p.sort) u.set("sort", p.sort);
|
||||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||||
|
|
@ -1196,10 +1208,10 @@ export const api = {
|
||||||
if (f.collection) u.set("collection", f.collection);
|
if (f.collection) u.set("collection", f.collection);
|
||||||
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
||||||
}
|
}
|
||||||
return req(`/api/plex/browse?${u.toString()}`);
|
return req(`/api/plex/library?${u.toString()}`);
|
||||||
},
|
},
|
||||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
plexFacets: (scope: string): Promise<PlexFacets> =>
|
||||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`),
|
||||||
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
||||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
||||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue