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

@ -246,7 +246,10 @@ export default function App() {
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
@ -740,8 +743,8 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}>
<PlexSidebar
library={plexLib}
setLibrary={setPlexLib}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
@ -829,7 +832,8 @@ export default function App() {
<PlexBrowse
q={plexQ}
onClearSearch={() => setPlexQ("")}
library={plexLib}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}