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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user