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
+7 -4
View File
@@ -239,8 +239,9 @@ export interface PlexFilters {
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
collectionTitles?: Record<string, string>; // rating_key → title, for the active-filter chips
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
genres: [],
@@ -249,6 +250,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
actors: [],
studios: [],
keywords: [],
collections: [],
};
export function plexFilterCount(f: PlexFilters): number {
return (
@@ -262,7 +264,7 @@ export function plexFilterCount(f: PlexFilters): number {
f.actors.length +
f.studios.length +
f.keywords.length +
(f.collection ? 1 : 0)
f.collections.length
);
}
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
@@ -283,7 +285,8 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collection) u.set("collection", f.collection);
if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all");
}
export interface PlexFacets {
genres: { value: string; count: number }[];