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

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

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

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

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

View file

@ -18,7 +18,7 @@ from urllib.parse import quote
import httpx 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"),

View file

@ -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}

View file

@ -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 (

View file

@ -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?.();
}; };

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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[] }> =>