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

@ -1,9 +1,8 @@
import { useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
// full metadata filter set (genre / rating / year / duration / added / content rating + the
@ -12,8 +11,8 @@ import { useDebounced } from "../lib/useDebounced";
// backend so the sidebar only offers what the library actually contains.
type Props = {
library: string;
setLibrary: (v: string) => void;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
@ -39,8 +38,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
];
export default function PlexSidebar({
library,
setLibrary,
scope,
setScope,
show,
setShow,
sort,
@ -52,7 +51,6 @@ export default function PlexSidebar({
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const qc = useQueryClient();
@ -64,33 +62,17 @@ export default function PlexSidebar({
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id);
}
const libs = libsQ.data?.libraries ?? [];
const activeLib = libs.find((l) => l.key === library);
const isMovieLib = activeLib?.kind === "movie";
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both).
const facetsQ = useQuery({
queryKey: ["plex-facets", library],
queryFn: () => api.plexFacets(library),
enabled: !!library, // facets now come for movie AND show libraries (0052)
queryKey: ["plex-facets", scope],
queryFn: () => api.plexFacets(scope),
enabled: !!scope,
});
const facets = facetsQ.data;
// Collections picker (searchable; the list is only fetched when none is active).
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: ["plex-collections", library, collSearchDeb],
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
enabled: !!library && !filters.collection, // collections apply to shows too
});
const collections = collectionsQ.data?.collections ?? [];
// Default to the first library once loaded (or if the stored one vanished).
useEffect(() => {
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
}, [libs, library, setLibrary]);
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
// collection"; there's no per-library picker in the unified cross-library scope.)
const fCount = plexFilterCount(filters);
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
const anyActive = activeCount > 0;
@ -103,7 +85,8 @@ export default function PlexSidebar({
setSort("added");
};
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
const sorts = scope === "movie" ? MOVIE_SORTS : SHOW_SORTS;
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
@ -137,20 +120,19 @@ export default function PlexSidebar({
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
{/* Library scope */}
<Section label={t("plex.filter.library")}>
<div className="flex flex-col gap-1">
{libs.map((l) => (
{/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.scope")}>
<div className="flex gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={l.key}
onClick={() => setLibrary(l.key)}
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
key={s}
onClick={() => setScope(s)}
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
<span className="flex-1 text-left truncate">{l.title}</span>
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
</button>
))}
</div>
@ -266,42 +248,18 @@ export default function PlexSidebar({
</Section>
)}
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
<Section label={t("plex.filter.collection")}>
{filters.collection ? (
{/* Collection active-collection chip only (set from an item's info page "Browse
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */}
{filters.collection && (
<Section label={t("plex.filter.collection")}>
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
)}
</div>
</div>
)}
</Section>
</Section>
)}
{/* IMDb rating min */}
<Section label={t("plex.filter.rating")}>
@ -369,7 +327,7 @@ export default function PlexSidebar({
</Section>
{/* Duration buckets — movie-only (a show has no single runtime) */}
{isMovieLib && (
{scope === "movie" && (
<Section label={t("plex.filter.duration")}>
<ChipRow>
<Chip