diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index e90ef1c..aeba013 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -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"),
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0a573e6..70297ba 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(null); // sidebar → open a playlist
@@ -740,8 +743,8 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && (
setPlexQ("")}
- library={plexLib}
+ scope={plexScope}
+ setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index f859d0b..46dee7e 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -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(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>({ 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) {
+ const merged = { ...filters } as unknown as Record;
+ 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 (
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;
- 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 (
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 })}
- {/* Virtual person cards for a name search — click to add them to the filter. */}
- {people.length > 0 && (
-
-
{t("plex.people.match")}
-
- {people.map((p) => {
- const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
- return (
-
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"
- }`}
- >
-
- {p.photo ? (
-
- ) : (
-
- {p.name.charAt(0)}
-
- )}
-
-
-
{p.name}
-
- {t(`plex.people.${p.kind}`)} · {t("plex.people.count", { count: p.count })}
- {added ? ` · ${t("plex.people.added")}` : ""}
-
-
-
- );
- })}
-
-
- )}
-
{browseQ.isLoading ? (
{t("plex.loading")}
- ) : 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({
{dq ? t("plex.noMatches") : t("plex.empty")}
)
) : (
-
- {items.map((c) => (
-
onCard(c)}
- onInfo={() => onInfo(c)}
- onToggleWatched={toggleWatched}
- />
- ))}
-
+ <>
+ {/* Titles grid — movies + shows mixed (visually tagged). */}
+ {items.length > 0 && (
+ <>
+ {episodes.length > 0 && (
+ {t("plex.unified.titles")}
+ )}
+
+ {items.map((c) => (
+
onCard(c)}
+ onInfo={() => onInfo(c)}
+ onToggleWatched={toggleWatched}
+ />
+ ))}
+
+ >
+ )}
+ {/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
+ {episodes.length > 0 && (
+
+ {t("plex.unified.episodes")}
+
+ {episodes.map((ep) => (
+ onCard(ep)} />
+ ))}
+
+
+ )}
+ >
)}
@@ -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). */}
- {isPlayable ? (
-
-
-
{inProgress ? t("plex.resume") : t("plex.play")}
-
- ) : (
-
{t("plex.openShow")}
- )}
+
+
+
+ {card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
+
+
{/* 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). */}
+
+ {card.type === "show" ? : }
+
{pct > 0 && (
)}
- {card.title}
+
+ {card.title}
+
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
);
@@ -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({
void; className?: string; children: ReactNode }) {
+ if (!onClick) return {children} ;
+ return (
+
+ {children}
+
+ );
+}
+
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
void }
);
}
-function CastStrip({ cast }: { cast: PlexCastMember[] }) {
+function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void }) {
const { t } = useTranslation();
return (
{t("plex.info.cast")}
- {cast.map((c, i) => (
-
-
- {c.thumb ? (
-
- ) : (
-
{c.name.charAt(0)}
- )}
+ {cast.map((c, i) => {
+ const inner = (
+ <>
+
+ {c.thumb ? (
+
+ ) : (
+
{c.name.charAt(0)}
+ )}
+
+
+ {c.name}
+
+ {c.role &&
{c.role}
}
+ >
+ );
+ return onFilter ? (
+
onFilter({ actors: [c.name] })}
+ title={t("plex.info.filterActor", { name: c.name })}
+ className="group/cast w-20 shrink-0 text-center"
+ >
+ {inner}
+
+ ) : (
+
+ {inner}
-
{c.name}
- {c.role &&
{c.role}
}
-
- ))}
+ );
+ })}
);
@@ -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;
+ {withShowTitle && ep.show_title && (
+
+ {ep.show_title}
+ {ep.season_number != null && ep.episode_number != null
+ ? ` · S${ep.season_number}·E${ep.episode_number}`
+ : ""}
+
+ )}
- {ep.episode_number}.
+ {!withShowTitle && {ep.episode_number}. }
{ep.title}
{dur(ep.duration_seconds)}
-
-
-
+ {onAdd && (
+
+
+
+ )}
);
@@ -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) => 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({
{show.title}
- {show.year && {show.year} }
- {show.content_rating && · {show.content_rating} }
+ {show.year != null && (
+ onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
+ {show.year}
+
+ )}
+ {show.content_rating && (
+ onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
+ · {show.content_rating}
+
+ )}
{show.season_count != null && · {t("plex.seasons", { count: show.season_count })} }
{show.imdb_rating != null && (
-
+ onFilter({ ratingMin: Math.floor(show.imdb_rating!) }) : undefined}
+ className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
+ >
·
{show.imdb_rating}
-
+
)}
{show.genres.length > 0 && (
{show.genres.map((g) => (
-
+ onFilter({ genres: [g] }) : undefined}
+ >
{g}
-
+
))}
)}
@@ -824,7 +883,7 @@ function PlexShowView({
label={t("plex.playlist.addShow")}
/>
)}
- {isAdmin && library && (
+ {isAdmin && showLib && (
setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
)}
@@ -839,16 +898,16 @@ function PlexShowView({
))}
- {show.cast.length > 0 && }
+ {show.cast.length > 0 && }
{d.related.length > 0 && }
>
)}
{addTarget && setAddTarget(null)} />}
- {collOpen && show && (
+ {collOpen && show && showLib && (
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 (
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 248a8e8..8ae5878 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -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?.();
};
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index a1333fe..f39a467 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -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 (
- {/* Library scope */}
-
-
- {libs.map((l) => (
+ {/* Scope — one unified library, filtered to movies, shows, or both. */}
+
+
+ {(["both", "movie", "show"] as const).map((s) => (
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" ? : }
- {l.title}
- {l.count.toLocaleString()}
+ {s === "movie" ? : s === "show" ? : }
+ {t(`plex.filter.scopeOpt.${s}`)}
))}
@@ -266,42 +248,18 @@ export default function PlexSidebar({
)}
- {/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
-
- {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 && (
+
patch({ collection: null, collectionTitle: null })}
/>
- ) : (
-
-
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"
- />
-
- {collections.map((c) => (
- 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"
- >
-
- {c.title}
- {c.child_count}
-
- ))}
- {collections.length === 0 && (
- {t("plex.filter.collectionEmpty")}
- )}
-
-
- )}
-
+
+ )}
{/* IMDb rating min */}
@@ -369,7 +327,7 @@ export default function PlexSidebar({
{/* Duration buckets — movie-only (a show has no single runtime) */}
- {isMovieLib && (
+ {scope === "movie" && (
> => 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 => {
- const u = new URLSearchParams({ library: p.library });
+ }): Promise => {
+ 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 =>
- req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
+ plexFacets: (scope: string): Promise =>
+ 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[] }> =>