diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 18ef4e8..db007b5 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -748,16 +748,25 @@ def _collection_card(c: PlexCollection) -> dict: @router.get("/collections") def collections( - library: str, + library: str | None = None, q: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """The library's mirrored collections as cards (most-populated first). Optional name filter `q`.""" - 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) + """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a + `library` plex_key → that one library's collections (used by the collection editor). WITHOUT one → + the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single + library selected).""" + 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() if term: query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index bfbb7b9..dce76cb 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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", ] 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({ )} - {/* 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 && ( -
+ {/* 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"). */} +
+ {filters.collection ? (
patch({ collection: null, collectionTitle: null })} />
-
- )} + ) : ( +
+ 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" + /> +
+ {collections.map((c) => ( + + ))} + {collections.length === 0 && ( + {t("plex.filter.collectionEmpty")} + )} +
+
+ )} +
{/* IMDb rating min */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 93bb997..719eb4b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1218,8 +1218,15 @@ export const api = { if (f) appendPlexFilters(u, f); return req(`/api/plex/facets?${u.toString()}`); }, - plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => - req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`), + // With a library plex_key → that library's collections (editor). Without one → the union across all + // 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) --- plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise => req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),