feat(plex): restore Collections filter as a cross-library union picker

The searchable Collections picker was dropped from the sidebar when browsing
went unified (movies+shows across libraries) because collections are per-library
and there's no single selected library. Bring it back library-agnostic: GET
/api/plex/collections `library` is now optional — without it the endpoint unions
collections across all enabled libraries (with it, the old single-library path the
collection editor uses). The sidebar re-adds the searchable list (chip when one is
active). Its query key is ["plex-collections","union",<search>] so a search term
equal to a library plex_key can't collide with the editor's ["plex-collections",<key>],
while the shared prefix keeps editor invalidation refreshing the picker.
This commit is contained in:
npeter83 2026-07-11 03:30:33 +02:00
parent b3af04a997
commit 5759deac20
3 changed files with 72 additions and 16 deletions

View file

@ -3,6 +3,7 @@ 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
@ -76,8 +77,21 @@ export default function PlexSidebar({
});
const facets = facetsQ.data;
// (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.)
// Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the
// unified scope has no single library). Only fetched while no collection is active (else the chip
// shows instead). Applying one sets the collection filter; the actual filtering already flows through
// /library + /facets by rating_key.
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
// "union" disambiguates from the editor's per-library ["plex-collections", <plex_key>] key (a search
// term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working.
queryKey: ["plex-collections", "union", collSearchDeb],
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
});
const collections = collectionsQ.data?.collections ?? [];
const fCount = plexFilterCount(filters);
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
const anyActive = activeCount > 0;
@ -257,18 +271,44 @@ export default function PlexSidebar({
</Section>
)}
{/* 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")}>
{/* Collection the active one shows as a chip; otherwise a searchable picker over the union
of all enabled libraries' collections (also settable from an item info page's "Browse
collection"). */}
<Section label={t("plex.filter.collection")}>
{filters.collection ? (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
</Section>
)}
) : (
<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>
{/* IMDb rating min */}
<Section label={t("plex.filter.rating")}>