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"),
|
||||
|
|
|
|||
|
|
@ -246,7 +246,10 @@ export default function App() {
|
|||
const channelsView: ChannelsView =
|
||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||
// 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 [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||
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 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlexSidebar
|
||||
library={plexLib}
|
||||
setLibrary={setPlexLib}
|
||||
scope={plexScope}
|
||||
setScope={setPlexScope}
|
||||
show={plexShowFilter}
|
||||
setShow={setPlexShowFilter}
|
||||
sort={plexSort}
|
||||
|
|
@ -829,7 +832,8 @@ export default function App() {
|
|||
<PlexBrowse
|
||||
q={plexQ}
|
||||
onClearSearch={() => setPlexQ("")}
|
||||
library={plexLib}
|
||||
scope={plexScope}
|
||||
setScope={setPlexScope}
|
||||
show={plexShowFilter}
|
||||
sort={plexSort}
|
||||
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 { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
Film,
|
||||
Info,
|
||||
Layers,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Star,
|
||||
Tv2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
|
|
@ -21,8 +23,8 @@ import {
|
|||
type PlexCard,
|
||||
type PlexCastMember,
|
||||
type PlexFilters,
|
||||
type PlexPerson,
|
||||
type PlexSeasonDetail,
|
||||
type PlexUnifiedResult,
|
||||
} from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
|
@ -51,7 +53,8 @@ type Sub =
|
|||
type Props = {
|
||||
q: string;
|
||||
onClearSearch: () => void;
|
||||
library: string;
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
setScope: (v: string) => void;
|
||||
show: string;
|
||||
sort: string;
|
||||
filters: PlexFilters;
|
||||
|
|
@ -73,7 +76,8 @@ function dur(n?: number | null): string {
|
|||
export default function PlexBrowse({
|
||||
q,
|
||||
onClearSearch,
|
||||
library,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
sort,
|
||||
filters,
|
||||
|
|
@ -133,12 +137,12 @@ export default function PlexBrowse({
|
|||
}, [sub]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
||||
enabled: !!library && sub.view.kind === "grid",
|
||||
queryKey: ["plex-library", scope, dq, sort, show, filters],
|
||||
enabled: !!scope && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({
|
||||
library,
|
||||
api.plexLibrary({
|
||||
scope,
|
||||
q: dq || undefined,
|
||||
sort,
|
||||
show,
|
||||
|
|
@ -154,21 +158,8 @@ export default function PlexBrowse({
|
|||
|
||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||
|
||||
// Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
|
||||
// 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)
|
||||
}
|
||||
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
||||
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
||||
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -197,8 +188,40 @@ export default function PlexBrowse({
|
|||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
async function toggleWatched(card: PlexCard) {
|
||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
// 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") {
|
||||
|
|
@ -225,28 +248,10 @@ export default function PlexBrowse({
|
|||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
library={library}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||
onFilter={(patch) => {
|
||||
// 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" });
|
||||
}}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -255,11 +260,11 @@ export default function PlexBrowse({
|
|||
return (
|
||||
<PlexShowView
|
||||
showId={showId}
|
||||
library={library}
|
||||
onBack={sub.back}
|
||||
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||
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 })}
|
||||
</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 ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
) : items.length === 0 && episodes.length === 0 ? (
|
||||
dq && plexFilterCount(filters) > 0 ? (
|
||||
// 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".
|
||||
|
|
@ -339,17 +305,38 @@ export default function PlexBrowse({
|
|||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* 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">
|
||||
{items.map((c) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
/>
|
||||
))}
|
||||
</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" />
|
||||
|
|
@ -407,16 +394,14 @@ function PlexPosterCard({
|
|||
decoding="async"
|
||||
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">
|
||||
{isPlayable ? (
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-10 h-10" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-10 h-10" fill="currentColor" />
|
||||
<span className="text-xs font-medium">
|
||||
{card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||
{isPlayable && (
|
||||
|
|
@ -467,13 +452,25 @@ function PlexPosterCard({
|
|||
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 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</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>
|
||||
);
|
||||
|
|
@ -481,14 +478,12 @@ function PlexPosterCard({
|
|||
|
||||
function PlexInfoView({
|
||||
id,
|
||||
library,
|
||||
onBack,
|
||||
onPlay,
|
||||
onPlayItem,
|
||||
onFilter,
|
||||
}: {
|
||||
id: string;
|
||||
library: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string) => void;
|
||||
|
|
@ -514,7 +509,7 @@ function PlexInfoView({
|
|||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={library}
|
||||
library={q.data.library ?? undefined}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
|
|
@ -539,6 +534,16 @@ function epLabel(ep?: PlexCard | null): string | undefined {
|
|||
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 }) {
|
||||
return (
|
||||
<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();
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{cast.map((c, i) => (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
<div className="mx-auto h-20 w-20 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{c.thumb ? (
|
||||
<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>
|
||||
)}
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
<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 ? (
|
||||
<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>
|
||||
<div className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}>
|
||||
{c.name}
|
||||
</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 className="mt-1.5 text-xs font-medium line-clamp-2 leading-tight">{c.name}</div>
|
||||
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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 inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const pct =
|
||||
|
|
@ -701,20 +734,30 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
|||
</button>
|
||||
<div className="mt-1.5 flex items-start gap-1.5">
|
||||
<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">
|
||||
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
{onAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -722,18 +765,18 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
|||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
library,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onFilter,
|
||||
}: {
|
||||
showId: string;
|
||||
library: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[]) => void;
|
||||
onOpenSeason: (seasonId: string) => void;
|
||||
onOpenShow: (id: string) => void;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
|
@ -745,6 +788,7 @@ function PlexShowView({
|
|||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const watched = show?.status === "watched";
|
||||
async function markAll(w: boolean) {
|
||||
|
|
@ -755,7 +799,7 @@ function PlexShowView({
|
|||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -776,22 +820,37 @@ function PlexShowView({
|
|||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
{show.year && <span>{show.year}</span>}
|
||||
{show.content_rating && <span>· {show.content_rating}</span>}
|
||||
{show.year != null && (
|
||||
<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.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" />
|
||||
{show.imdb_rating}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{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}
|
||||
</span>
|
||||
</Fil>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -824,7 +883,7 @@ function PlexShowView({
|
|||
label={t("plex.playlist.addShow")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && library && (
|
||||
{isAdmin && showLib && (
|
||||
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -839,16 +898,16 @@ function PlexShowView({
|
|||
))}
|
||||
</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} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && (
|
||||
{collOpen && show && showLib && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: show.id, title: show.title }}
|
||||
library={library}
|
||||
library={showLib}
|
||||
memberOf={show.collection_keys}
|
||||
onClose={() => setCollOpen(false)}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
|
||||
|
|
@ -887,7 +946,7 @@ function PlexSeasonView({
|
|||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export default function PlexInfo({
|
|||
const setState = async (status: "new" | "watched") => {
|
||||
await api.plexSetState(detail.id, status).catch(() => {});
|
||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
onStateChange?.();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
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
|
||||
// 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.
|
||||
|
||||
type Props = {
|
||||
library: string;
|
||||
setLibrary: (v: string) => void;
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
setScope: (v: string) => void;
|
||||
show: string;
|
||||
setShow: (v: string) => void;
|
||||
sort: string;
|
||||
|
|
@ -39,8 +38,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
|||
];
|
||||
|
||||
export default function PlexSidebar({
|
||||
library,
|
||||
setLibrary,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
setShow,
|
||||
sort,
|
||||
|
|
@ -52,7 +51,6 @@ export default function PlexSidebar({
|
|||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const qc = useQueryClient();
|
||||
|
|
@ -64,33 +62,17 @@ export default function PlexSidebar({
|
|||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
|
||||
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both).
|
||||
const facetsQ = useQuery({
|
||||
queryKey: ["plex-facets", library],
|
||||
queryFn: () => api.plexFacets(library),
|
||||
enabled: !!library, // facets now come for movie AND show libraries (0052)
|
||||
queryKey: ["plex-facets", scope],
|
||||
queryFn: () => api.plexFacets(scope),
|
||||
enabled: !!scope,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
// Collections picker (searchable; the list is only fetched when none is active).
|
||||
const [collSearch, setCollSearch] = useState("");
|
||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||
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)
|
||||
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
||||
// collection"; there's no per-library picker in the unified cross-library scope.)
|
||||
const fCount = plexFilterCount(filters);
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
|
|
@ -103,7 +85,8 @@ export default function PlexSidebar({
|
|||
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(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
|
|
@ -137,20 +120,19 @@ export default function PlexSidebar({
|
|||
|
||||
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">
|
||||
{/* Library scope */}
|
||||
<Section label={t("plex.filter.library")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{libs.map((l) => (
|
||||
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||
<Section label={t("plex.filter.scope")}>
|
||||
<div className="flex gap-1">
|
||||
{(["both", "movie", "show"] as const).map((s) => (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => setLibrary(l.key)}
|
||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
||||
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
key={s}
|
||||
onClick={() => setScope(s)}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||
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" />}
|
||||
<span className="flex-1 text-left truncate">{l.title}</span>
|
||||
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
||||
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -266,42 +248,18 @@ export default function PlexSidebar({
|
|||
</Section>
|
||||
)}
|
||||
|
||||
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
||||
<Section label={t("plex.filter.collection")}>
|
||||
{filters.collection ? (
|
||||
{/* 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")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</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 */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
|
|
@ -369,7 +327,7 @@ export default function PlexSidebar({
|
|||
</Section>
|
||||
|
||||
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||
{isMovieLib && (
|
||||
{scope === "movie" && (
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
<ChipRow>
|
||||
<Chip
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@
|
|||
},
|
||||
"filter": {
|
||||
"library": "Bibliothek",
|
||||
"scope": "Bibliothek",
|
||||
"scopeOpt": {
|
||||
"both": "Alle",
|
||||
"movie": "Filme",
|
||||
"show": "Serien"
|
||||
},
|
||||
"show": "Anzeige",
|
||||
"showOpt": {
|
||||
"all": "Alle",
|
||||
|
|
@ -70,6 +76,10 @@
|
|||
"markWatched": "Als gesehen markieren",
|
||||
"markUnwatched": "Als ungesehen markieren",
|
||||
"seasons": "{{count}} Staffeln",
|
||||
"unified": {
|
||||
"titles": "Titel",
|
||||
"episodes": "Folgen"
|
||||
},
|
||||
"series": {
|
||||
"seasons": "Staffeln",
|
||||
"backToShow": "Zurück zur Serie",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@
|
|||
},
|
||||
"filter": {
|
||||
"library": "Library",
|
||||
"scope": "Library",
|
||||
"scopeOpt": {
|
||||
"both": "All",
|
||||
"movie": "Movies",
|
||||
"show": "Shows"
|
||||
},
|
||||
"show": "Show",
|
||||
"showOpt": {
|
||||
"all": "All",
|
||||
|
|
@ -70,6 +76,10 @@
|
|||
"markWatched": "Mark watched",
|
||||
"markUnwatched": "Mark unwatched",
|
||||
"seasons": "{{count}} seasons",
|
||||
"unified": {
|
||||
"titles": "Titles",
|
||||
"episodes": "Episodes"
|
||||
},
|
||||
"series": {
|
||||
"seasons": "Seasons",
|
||||
"backToShow": "Back to show",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@
|
|||
},
|
||||
"filter": {
|
||||
"library": "Könyvtár",
|
||||
"scope": "Könyvtár",
|
||||
"scopeOpt": {
|
||||
"both": "Mind",
|
||||
"movie": "Filmek",
|
||||
"show": "Sorozatok"
|
||||
},
|
||||
"show": "Megjelenítés",
|
||||
"showOpt": {
|
||||
"all": "Mind",
|
||||
|
|
@ -70,6 +76,10 @@
|
|||
"markWatched": "Megnézettnek jelöl",
|
||||
"markUnwatched": "Nem-nézettnek jelöl",
|
||||
"seasons": "{{count}} évad",
|
||||
"unified": {
|
||||
"titles": "Címek",
|
||||
"episodes": "Epizódok"
|
||||
},
|
||||
"series": {
|
||||
"seasons": "Évadok",
|
||||
"backToShow": "Vissza a sorozathoz",
|
||||
|
|
|
|||
|
|
@ -663,6 +663,16 @@ export interface PlexBrowseResult {
|
|||
limit: number;
|
||||
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 {
|
||||
id: string; // season rating_key
|
||||
season_number: number | null;
|
||||
|
|
@ -682,6 +692,7 @@ export interface PlexShowDetail {
|
|||
year?: number | null;
|
||||
thumb: string;
|
||||
art: string;
|
||||
library?: string | null; // Plex section key (for the admin collection editor)
|
||||
content_rating?: string | null;
|
||||
rating?: number | null;
|
||||
genres: string[];
|
||||
|
|
@ -716,6 +727,7 @@ export interface PlexItemDetail {
|
|||
playable: string; // direct | remux | transcode
|
||||
thumb: string;
|
||||
art: string;
|
||||
library?: string | null; // Plex section key (for the admin collection editor)
|
||||
cast: PlexCastMember[];
|
||||
imdb_rating?: number | null;
|
||||
imdb_id?: string | null;
|
||||
|
|
@ -1164,16 +1176,16 @@ export const api = {
|
|||
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
||||
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
||||
req("/api/plex/libraries"),
|
||||
plexBrowse: (p: {
|
||||
library: string;
|
||||
plexLibrary: (p: {
|
||||
scope: string; // movie | show | both
|
||||
q?: string;
|
||||
sort?: string;
|
||||
show?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
filters?: PlexFilters;
|
||||
}): Promise<PlexBrowseResult> => {
|
||||
const u = new URLSearchParams({ library: p.library });
|
||||
}): Promise<PlexUnifiedResult> => {
|
||||
const u = new URLSearchParams({ scope: p.scope });
|
||||
if (p.q) u.set("q", p.q);
|
||||
if (p.sort) u.set("sort", p.sort);
|
||||
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.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> =>
|
||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
||||
plexFacets: (scope: string): Promise<PlexFacets> =>
|
||||
req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`),
|
||||
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue