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:
2026-08-22 08:18:25 +02:00
parent 6c7038cf1f
commit a60164edc4
8 changed files with 151 additions and 37 deletions
+5
View File
@@ -272,10 +272,15 @@ export default function PlexBrowse() {
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
function applyFilter(patch: Partial<PlexFilters>) {
const merged = { ...filters } as unknown as Record<string, unknown>;
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
typeof x === "object" && x !== null && !Array.isArray(x);
for (const [k, v] of Object.entries(patch)) {
const cur = merged[k];
if (Array.isArray(v) && Array.isArray(cur)) {
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
} else if (isPlainObject(v) && isPlainObject(cur)) {
// Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles.
merged[k] = { ...cur, ...v };
} else {
merged[k] = v;
}
+6 -1
View File
@@ -693,7 +693,12 @@ export default function PlexInfo({
</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
onClick={() =>
onFilter({
collections: [col.id],
collectionTitles: { [col.id]: col.title },
})
}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
+14 -3
View File
@@ -123,9 +123,20 @@ export function PlexProvider({ children }: { children: ReactNode }) {
const filters = useMemo<PlexFilters>(() => {
try {
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
// into the facets query key and be re-persisted on every filter change.
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
return { ...EMPTY_PLEX_FILTERS, ...rest };
// into the facets query key and be re-persisted on every filter change. Also migrate the legacy
// single `collection`/`collectionTitle` into the multi-select `collections`/`collectionTitles`.
const {
sortDir: _drop,
collection: legacyCollection,
collectionTitle: legacyTitle,
...rest
} = JSON.parse(filtersRaw);
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...rest };
if (legacyCollection && !merged.collections.length) {
merged.collections = [legacyCollection];
if (legacyTitle) merged.collectionTitles = { [legacyCollection]: legacyTitle };
}
return merged;
} catch {
return EMPTY_PLEX_FILTERS;
}
+35 -21
View File
@@ -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);
}}
/>
);
@@ -102,11 +102,11 @@ export function usePlexActiveFilters(): ActiveFilter[] {
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
});
}
if (filters.collection) {
for (const c of filters.collections) {
out.push({
key: "collection",
label: filters.collectionTitle ?? t("plex.filter.collection"),
remove: () => patch({ collection: null, collectionTitle: null }),
key: `collection:${c}`,
label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
remove: () => patch({ collections: filters.collections.filter((x) => x !== c) }),
});
}
if (show !== "all") {