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

@ -748,16 +748,25 @@ def _collection_card(c: PlexCollection) -> dict:
@router.get("/collections") @router.get("/collections")
def collections( def collections(
library: str, library: str | None = None,
q: str | None = None, q: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The library's mirrored collections as cards (most-populated first). Optional name filter `q`.""" """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() `library` plex_key that one library's collections (used by the collection editor). WITHOUT one →
if lib is None: the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
return {"collections": []} library selected)."""
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) if library is not None:
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None:
return {"collections": []}
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
else:
enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)]
if not enabled_ids:
return {"collections": []}
query = db.query(PlexCollection).filter(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip() term = (q or "").strip()
if term: if term:
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; 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 // 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 // full metadata filter set (genre / rating / year / duration / added / content rating + the
@ -76,8 +77,21 @@ export default function PlexSidebar({
}); });
const facets = facetsQ.data; const facets = facetsQ.data;
// (Collection filtering: the active-collection chip is set from an item info page's "Browse // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the
// collection"; there's no per-library picker in the unified cross-library scope.) // 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 fCount = plexFilterCount(filters);
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0); const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
const anyActive = activeCount > 0; const anyActive = activeCount > 0;
@ -257,18 +271,44 @@ export default function PlexSidebar({
</Section> </Section>
)} )}
{/* Collection active-collection chip only (set from an item's info page "Browse {/* Collection the active one shows as a chip; otherwise a searchable picker over the union
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */} of all enabled libraries' collections (also settable from an item info page's "Browse
{filters.collection && ( collection"). */}
<Section label={t("plex.filter.collection")}> <Section label={t("plex.filter.collection")}>
{filters.collection ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<RemovableChip <RemovableChip
label={filters.collectionTitle || filters.collection} label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })} onRemove={() => patch({ collection: null, collectionTitle: null })}
/> />
</div> </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 */} {/* IMDb rating min */}
<Section label={t("plex.filter.rating")}> <Section label={t("plex.filter.rating")}>

View file

@ -1218,8 +1218,15 @@ export const api = {
if (f) appendPlexFilters(u, f); if (f) appendPlexFilters(u, f);
return req(`/api/plex/facets?${u.toString()}`); return req(`/api/plex/facets?${u.toString()}`);
}, },
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => // With a library plex_key → that library's collections (editor). Without one → the union across all
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`), // enabled libraries (the unified-scope sidebar picker).
plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
const u = new URLSearchParams();
if (library) u.set("library", library);
if (q) u.set("q", q);
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// --- Collection editing (admin only; writes back to Plex) --- // --- Collection editing (admin only; writes back to Plex) ---
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> => plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }), req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),