From a60164edc4b2521a1ba0f5779990ab5c35d10154 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 08:18:25 +0200 Subject: [PATCH] feat(plex): make the Collection filter multi-select with Any/All MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/plex/query.py | 12 ++-- backend/tests/test_plex_collections_filter.py | 72 +++++++++++++++++++ frontend/src/components/PlexBrowse.tsx | 5 ++ frontend/src/components/PlexInfo.tsx | 7 +- frontend/src/components/PlexProvider.tsx | 17 ++++- frontend/src/components/PlexSidebar.tsx | 56 +++++++++------ .../src/components/usePlexActiveFilters.ts | 8 +-- frontend/src/lib/api/plex.ts | 11 +-- 8 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 backend/tests/test_plex_collections_filter.py diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 71734322..21f33825 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -142,8 +142,10 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list: conds.append(model.studio.in_(stds)) for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5) conds.append(model.keywords.contains([kw])) - if p.get("collection"): - conds.append(model.collection_keys.contains([p["collection"]])) + csel = split_csv(p.get("collections")) + if csel: # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode + cc = [model.collection_keys.contains([c]) for c in csel] + conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc)) return conds @@ -174,7 +176,8 @@ class LibraryFilters: ) studios: str | None = None keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral - collection: str | None = None + collections: str | None = None # CSV of collection rating_keys + collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND) def as_dict(self) -> dict: return { @@ -192,7 +195,8 @@ class LibraryFilters: "actor_mode": self.actor_mode, "studios": self.studios, "keywords": self.keywords, - "collection": self.collection, + "collections": self.collections, + "collection_mode": self.collection_mode, } diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py new file mode 100644 index 00000000..4b046c1d --- /dev/null +++ b/backend/tests/test_plex_collections_filter.py @@ -0,0 +1,72 @@ +"""Piece 1a: the Plex collection filter is multi-select with any/all combine (a title can belong to +several collections). `collections=A,B` = ANY (OR, default); `&collection_mode=all` = ALL (AND). +DB lane (`db` fixture) — skipped without Postgres. +""" +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import PlexItem, PlexLibrary, User + + +def _seed(db): + """Three movies: one in collection A, one in B, one in both — so any/all are distinguishable.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + common = dict( + library_id=lib.id, + kind="movie", + people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + ) + db.add_all([ + PlexItem(rating_key="mA", title="Only A", collection_keys=["A"], + added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common), + PlexItem(rating_key="mB", title="Only B", collection_keys=["B"], + added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common), + PlexItem(rating_key="mAB", title="A and B", collection_keys=["A", "B"], + added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), **common), + ]) + user = User(email="plexcoll@example.com") + db.add(user) + db.commit() + return user + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) + finally: + app.dependency_overrides.clear() + + +def _titles(resp): + return {i["title"] for i in resp.json()["items"]} + + +def test_single_collection_filters_to_its_members(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A") + assert _titles(resp) == {"Only A", "A and B"} + + +def test_multiple_collections_any_is_the_union(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B") + assert _titles(resp) == {"Only A", "Only B", "A and B"} + + +def test_multiple_collections_all_is_the_intersection(db, monkeypatch): + user = _seed(db) + resp = _get( + db, user, monkeypatch, + "/api/plex/library?scope=movie&collections=A,B&collection_mode=all", + ) + assert _titles(resp) == {"A and B"} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 1a3f5550..69e4e427 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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) { const merged = { ...filters } as unknown as Record; + const isPlainObject = (x: unknown): x is Record => + 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; } diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 362d5d48..61cc1608 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -693,7 +693,12 @@ export default function PlexInfo({ {onFilter && (