From 736db017e4387e1668eb43cfe04b1c4d239ec1ba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 00:15:49 +0200 Subject: [PATCH] feat(plex): unify movies + shows into one cross-library browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/plex.py | 321 ++++++++++++++++++--- frontend/src/App.tsx | 12 +- frontend/src/components/PlexBrowse.tsx | 353 ++++++++++++++---------- frontend/src/components/PlexInfo.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 104 +++---- frontend/src/i18n/locales/de/plex.json | 10 + frontend/src/i18n/locales/en/plex.json | 10 + frontend/src/i18n/locales/hu/plex.json | 10 + frontend/src/lib/api.ts | 26 +- 9 files changed, 579 insertions(+), 269 deletions(-) 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 ( - - ); - })} -
-
- )} - {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 ( + + ); +} + function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( + ) : ( +
+ {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 && ( - + )}
{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 (