feat(plex): make the Collection filter multi-select with Any/All
A title can belong to several Plex collections, so the collection filter becomes multi-select combining with Any (OR, default) / All (AND) — the same shape as the genre/actor filters. Backend (query.py): `collection` (single) → `collections` CSV + `collection_mode` on LibraryFilters; meta_filter_conds ORs/ANDs `collection_keys.contains` per id. Frontend: PlexFilters `collection`/`collectionTitle` → `collections` + optional `collectionMode` + `collectionTitles` map; CollectionPicker is now a multi-select Combobox with the any/all match footer; one chip per collection (usePlexActive- Filters); the info-page "Browse collection" strip unions into the list; applyFilter now shallow-merges label maps so adding one keeps the others' titles. PlexProvider migrates a legacy persisted single `collection` into the new shape on read. Test: test_plex_collections_filter (single / any-union / all-intersection).
This commit is contained in:
@@ -69,7 +69,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||
@@ -141,9 +143,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
title: t("plex.filter.collection"),
|
||||
body: (
|
||||
<CollectionPicker
|
||||
value={filters.collection}
|
||||
title={filters.collectionTitle}
|
||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||||
values={filters.collections}
|
||||
titles={filters.collectionTitles}
|
||||
mode={filters.collectionMode ?? "any"}
|
||||
onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })}
|
||||
onModeChange={(m) => patch({ collectionMode: m })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -373,19 +377,23 @@ function PlaylistPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
|
||||
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
|
||||
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
|
||||
// the chip drops it.
|
||||
// The collection filter (E5 S7 → combobox; now multi-select): a combi-input matching the genre/studio
|
||||
// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic <Combobox>
|
||||
// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
|
||||
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
|
||||
function CollectionPicker({
|
||||
value,
|
||||
title,
|
||||
onPick,
|
||||
values,
|
||||
titles,
|
||||
mode,
|
||||
onChange,
|
||||
onModeChange,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
title: string | null | undefined;
|
||||
onPick: (id: string | null, title: string | null) => void;
|
||||
values: string[];
|
||||
titles: Record<string, string> | undefined;
|
||||
mode: "any" | "all";
|
||||
onChange: (values: string[], titles: Record<string, string>) => void;
|
||||
onModeChange: (mode: "any" | "all") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -401,25 +409,31 @@ function CollectionPicker({
|
||||
label: c.title,
|
||||
count: c.child_count ?? undefined,
|
||||
}));
|
||||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||||
// selected collection's title (the fetched list is narrowed by the current search).
|
||||
const labelMap: Record<string, string> = { ...titles };
|
||||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={t("plex.filter.collection")}
|
||||
multiple={false}
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={value ? [value] : []}
|
||||
selectedLabels={value && title ? { [value]: title } : undefined}
|
||||
selected={values}
|
||||
selectedLabels={titles}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
matchMode={mode === "all" ? "and" : "or"}
|
||||
onMatchModeChange={(m) => onModeChange(m === "and" ? "all" : "any")}
|
||||
onChange={(next) => {
|
||||
const id = next[0] ?? null;
|
||||
const picked = options.find((o) => o.value === id);
|
||||
onPick(id, id ? (picked?.label ?? title ?? id) : null);
|
||||
// Carry only the selected ids' titles forward (drop titles for de-selected ones).
|
||||
const nextTitles: Record<string, string> = {};
|
||||
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
|
||||
onChange(next, nextTitles);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user