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:
parent
b3af04a997
commit
5759deac20
3 changed files with 72 additions and 16 deletions
|
|
@ -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`."""
|
||||
"""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)}%"))
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
{/* 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")}>
|
||||
|
|
|
|||
|
|
@ -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<PlexCollection> =>
|
||||
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue