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))
|
conds.append(model.studio.in_(stds))
|
||||||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
||||||
conds.append(model.keywords.contains([kw]))
|
conds.append(model.keywords.contains([kw]))
|
||||||
if p.get("collection"):
|
csel = split_csv(p.get("collections"))
|
||||||
conds.append(model.collection_keys.contains([p["collection"]]))
|
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
|
return conds
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +176,8 @@ class LibraryFilters:
|
|||||||
)
|
)
|
||||||
studios: str | None = None
|
studios: str | None = None
|
||||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
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:
|
def as_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -192,7 +195,8 @@ class LibraryFilters:
|
|||||||
"actor_mode": self.actor_mode,
|
"actor_mode": self.actor_mode,
|
||||||
"studios": self.studios,
|
"studios": self.studios,
|
||||||
"keywords": self.keywords,
|
"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"}
|
||||||
@@ -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).
|
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
||||||
function applyFilter(patch: Partial<PlexFilters>) {
|
function applyFilter(patch: Partial<PlexFilters>) {
|
||||||
const merged = { ...filters } as unknown as Record<string, unknown>;
|
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)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
const cur = merged[k];
|
const cur = merged[k];
|
||||||
if (Array.isArray(v) && Array.isArray(cur)) {
|
if (Array.isArray(v) && Array.isArray(cur)) {
|
||||||
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
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 {
|
} else {
|
||||||
merged[k] = v;
|
merged[k] = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -693,7 +693,12 @@ export default function PlexInfo({
|
|||||||
</h2>
|
</h2>
|
||||||
{onFilter && (
|
{onFilter && (
|
||||||
<button
|
<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"
|
className="shrink-0 text-xs text-muted hover:text-accent"
|
||||||
>
|
>
|
||||||
{t("plex.info.browseCollection")}
|
{t("plex.info.browseCollection")}
|
||||||
|
|||||||
@@ -123,9 +123,20 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
const filters = useMemo<PlexFilters>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
try {
|
try {
|
||||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
// 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.
|
// into the facets query key and be re-persisted on every filter change. Also migrate the legacy
|
||||||
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
|
// single `collection`/`collectionTitle` into the multi-select `collections`/`collectionTitles`.
|
||||||
return { ...EMPTY_PLEX_FILTERS, ...rest };
|
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 {
|
} catch {
|
||||||
return EMPTY_PLEX_FILTERS;
|
return EMPTY_PLEX_FILTERS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
const source: MediaSourceKind = "plex";
|
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 lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||||
@@ -141,9 +143,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
title: t("plex.filter.collection"),
|
title: t("plex.filter.collection"),
|
||||||
body: (
|
body: (
|
||||||
<CollectionPicker
|
<CollectionPicker
|
||||||
value={filters.collection}
|
values={filters.collections}
|
||||||
title={filters.collectionTitle}
|
titles={filters.collectionTitles}
|
||||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
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
|
// The collection filter (E5 S7 → combobox; now multi-select): a combi-input matching the genre/studio
|
||||||
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
|
// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic <Combobox>
|
||||||
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on
|
||||||
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
|
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
|
||||||
// the chip drops it.
|
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
|
||||||
function CollectionPicker({
|
function CollectionPicker({
|
||||||
value,
|
values,
|
||||||
title,
|
titles,
|
||||||
onPick,
|
mode,
|
||||||
|
onChange,
|
||||||
|
onModeChange,
|
||||||
}: {
|
}: {
|
||||||
value: string | null | undefined;
|
values: string[];
|
||||||
title: string | null | undefined;
|
titles: Record<string, string> | undefined;
|
||||||
onPick: (id: string | null, title: string | null) => void;
|
mode: "any" | "all";
|
||||||
|
onChange: (values: string[], titles: Record<string, string>) => void;
|
||||||
|
onModeChange: (mode: "any" | "all") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -401,25 +409,31 @@ function CollectionPicker({
|
|||||||
label: c.title,
|
label: c.title,
|
||||||
count: c.child_count ?? undefined,
|
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 (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
label={t("plex.filter.collection")}
|
label={t("plex.filter.collection")}
|
||||||
multiple={false}
|
|
||||||
clientFilter={false} // the server already narrows to `search`
|
clientFilter={false} // the server already narrows to `search`
|
||||||
options={options}
|
options={options}
|
||||||
selected={value ? [value] : []}
|
selected={values}
|
||||||
selectedLabels={value && title ? { [value]: title } : undefined}
|
selectedLabels={titles}
|
||||||
query={search}
|
query={search}
|
||||||
onQueryChange={setSearch}
|
onQueryChange={setSearch}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
loading={q.isFetching}
|
loading={q.isFetching}
|
||||||
placeholder={t("plex.filter.collectionSearch")}
|
placeholder={t("plex.filter.collectionSearch")}
|
||||||
emptyText={t("plex.filter.collectionEmpty")}
|
emptyText={t("plex.filter.collectionEmpty")}
|
||||||
|
matchMode={mode === "all" ? "and" : "or"}
|
||||||
|
onMatchModeChange={(m) => onModeChange(m === "and" ? "all" : "any")}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
const id = next[0] ?? null;
|
// Carry only the selected ids' titles forward (drop titles for de-selected ones).
|
||||||
const picked = options.find((o) => o.value === id);
|
const nextTitles: Record<string, string> = {};
|
||||||
onPick(id, id ? (picked?.label ?? title ?? id) : null);
|
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) }),
|
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (filters.collection) {
|
for (const c of filters.collections) {
|
||||||
out.push({
|
out.push({
|
||||||
key: "collection",
|
key: `collection:${c}`,
|
||||||
label: filters.collectionTitle ?? t("plex.filter.collection"),
|
label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||||
remove: () => patch({ collection: null, collectionTitle: null }),
|
remove: () => patch({ collections: filters.collections.filter((x) => x !== c) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (show !== "all") {
|
if (show !== "all") {
|
||||||
|
|||||||
@@ -239,8 +239,9 @@ export interface PlexFilters {
|
|||||||
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
||||||
studios: string[]; // OR — from any selected studio
|
studios: string[]; // OR — from any selected studio
|
||||||
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
||||||
collection?: string | null; // a single Plex collection (rating_key)
|
collections: string[]; // Plex collection rating_keys; a title can be in several
|
||||||
collectionTitle?: string | null; // its title, for the active-filter chip
|
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 = {
|
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||||
genres: [],
|
genres: [],
|
||||||
@@ -249,6 +250,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
|||||||
actors: [],
|
actors: [],
|
||||||
studios: [],
|
studios: [],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
|
collections: [],
|
||||||
};
|
};
|
||||||
export function plexFilterCount(f: PlexFilters): number {
|
export function plexFilterCount(f: PlexFilters): number {
|
||||||
return (
|
return (
|
||||||
@@ -262,7 +264,7 @@ export function plexFilterCount(f: PlexFilters): number {
|
|||||||
f.actors.length +
|
f.actors.length +
|
||||||
f.studios.length +
|
f.studios.length +
|
||||||
f.keywords.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
|
// 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.actorMode === "all") u.set("actor_mode", "all");
|
||||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||||
if (f.keywords?.length) u.set("keywords", f.keywords.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 {
|
export interface PlexFacets {
|
||||||
genres: { value: string; count: number }[];
|
genres: { value: string; count: number }[];
|
||||||
|
|||||||
Reference in New Issue
Block a user