feat(plex): adaptive (faceted) filters — each filter narrows the others

The sidebar's filter chips/bounds now ADAPT to the active filters: /facets takes the
current filters and computes each group's available values with all the OTHER active
filters applied but not its own (standard faceted search). So picking e.g. IMDb rating
>=8 trims the genre/content-rating chips + year bounds to what's still reachable, and
picking a genre narrows them further — while a multi-select group (genres) still shows
options to add. Extracted _meta_filter_conds (shared by browse/unified/facets), and the
frontend passes the live filters to plexFacets (query keyed on them, previous chips kept
during refetch). Search text is intentionally not factored into facets.
This commit is contained in:
npeter83 2026-07-11 00:31:47 +02:00
parent 736db017e4
commit 017be5f8ca
3 changed files with 119 additions and 49 deletions

View file

@ -351,36 +351,42 @@ def _added_cutoff(within: str | None) -> datetime | None:
return datetime.now(timezone.utc) - timedelta(days=days) if days else None return datetime.now(timezone.utc) - timedelta(days=days) if days else None
def _apply_meta_filters(q, model, p: dict): def _meta_filter_conds(model, p: dict) -> list:
"""Apply the shared metadata filters (identical column names on plex_items + plex_shows) to a """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
query over `model`. `p` is the request's filter params dict. Duration is movie-only, applied by `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
the caller. Kept DRY so movies, shows, and the unified cross-library browse filter identically.""" list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
conds: list = []
gsel = _csv(p.get("genres")) gsel = _csv(p.get("genres"))
if gsel: if gsel:
conds = [model.genres.contains([g]) for g in gsel] gc = [model.genres.contains([g]) for g in gsel]
q = q.filter(and_(*conds) if p.get("genre_mode") == "all" else or_(*conds)) conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
crs = _csv(p.get("content_ratings")) crs = _csv(p.get("content_ratings"))
if crs: if crs:
q = q.filter(model.content_rating.in_(crs)) conds.append(model.content_rating.in_(crs))
if p.get("year_min") is not None: if p.get("year_min") is not None:
q = q.filter(model.year >= p["year_min"]) conds.append(model.year >= p["year_min"])
if p.get("year_max") is not None: if p.get("year_max") is not None:
q = q.filter(model.year <= p["year_max"]) conds.append(model.year <= p["year_max"])
if p.get("rating_min") is not None: if p.get("rating_min") is not None:
q = q.filter(model.rating >= p["rating_min"]) conds.append(model.rating >= p["rating_min"])
cutoff = _added_cutoff(p.get("added_within")) cutoff = _added_cutoff(p.get("added_within"))
if cutoff is not None: if cutoff is not None:
q = q.filter(model.added_at >= cutoff) conds.append(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND for d in _csv(p.get("directors")): # AND
q = q.filter(model.directors.contains([d])) conds.append(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND for a in _csv(p.get("actors")): # AND
q = q.filter(model.cast_names.contains([a])) conds.append(model.cast_names.contains([a]))
stds = _csv(p.get("studios")) stds = _csv(p.get("studios"))
if stds: # OR if stds: # OR
q = q.filter(model.studio.in_(stds)) conds.append(model.studio.in_(stds))
if p.get("collection"): if p.get("collection"):
q = q.filter(model.collection_keys.contains([p["collection"]])) conds.append(model.collection_keys.contains([p["collection"]]))
return q return conds
def _apply_meta_filters(q, model, p: dict):
conds = _meta_filter_conds(model, p)
return q.filter(*conds) if conds else q
@router.get("/browse") @router.get("/browse")
@ -737,12 +743,27 @@ def unified_library(
@router.get("/facets") @router.get("/facets")
def facets( def facets(
scope: str = "both", scope: str = "both",
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Available filter values for the unified library, scoped to movie|show|both: genres + content """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
ratings (with counts merged across the scope's libraries) and the year/rating/duration bounds, so filters (faceted search): each group's values/bounds are computed with all the OTHER active
the sidebar only offers what the scope actually contains. Duration bounds are movie-only.""" filters applied but NOT its own so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in facets track the sidebar filters.)"""
empty = { empty = {
"genres": [], "genres": [],
"content_ratings": [], "content_ratings": [],
@ -757,6 +778,22 @@ def facets(
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
if not movie_ids and not show_ids: if not movie_ids and not show_ids:
return empty return empty
p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
}
def conds(model, ids, pp, kind_movie, with_dur):
c = _meta_filter_conds(model, pp) + [model.library_id.in_(ids)]
if kind_movie:
c.append(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
c.append(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
c.append(model.duration_s <= pp["duration_max"])
return c
genre_counts: dict[str, int] = {} genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {} cr_counts: dict[str, int] = {}
@ -764,36 +801,49 @@ def facets(
ratings: list[float] = [] ratings: list[float] = []
durs: list[int] = [] durs: list[int] = []
def collect(table: str, ids: list[int], with_dur: bool) -> None: def per_table(model, ids, kind_movie: bool) -> None:
if not ids: if not ids:
return return
base = f"FROM {table} WHERE library_id = ANY(:ids)" # Genres — exclude the genre filter itself so you can keep adding genres.
for g, c in db.execute( gc = conds(model, ids, {**p, "genres": None}, kind_movie, True)
text(f"SELECT g, count(*) c FROM (SELECT jsonb_array_elements_text(genres) g {base}) x GROUP BY g"), expanded = db.query(func.jsonb_array_elements_text(model.genres).label("g")).filter(*gc).subquery()
{"ids": ids}, for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all():
).all():
genre_counts[g] = genre_counts.get(g, 0) + c genre_counts[g] = genre_counts.get(g, 0) + c
for cr, c in db.execute( # Content ratings — exclude its own.
text(f"SELECT content_rating, count(*) c {base} AND content_rating IS NOT NULL GROUP BY content_rating"), cc = conds(model, ids, {**p, "content_ratings": None}, kind_movie, True)
{"ids": ids}, for cr, c in (
).all(): db.query(model.content_rating, func.count())
.filter(*cc, model.content_rating.isnot(None))
.group_by(model.content_rating)
.all()
):
cr_counts[cr] = cr_counts.get(cr, 0) + c cr_counts[cr] = cr_counts.get(cr, 0) + c
dur_sel = ", min(duration_s), max(duration_s)" if with_dur else "" # Year bounds — exclude the year filter.
b = db.execute(text(f"SELECT min(year), max(year), max(rating){dur_sel} {base}"), {"ids": ids}).first() by = db.query(func.min(model.year), func.max(model.year)).filter(
if b[0] is not None: *conds(model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True)
years.append(b[0]) ).first()
if b[1] is not None: if by[0] is not None:
years.append(b[1]) years.append(by[0])
if b[2] is not None: if by[1] is not None:
ratings.append(float(b[2])) years.append(by[1])
if with_dur: # Rating max — exclude the rating filter.
if b[3] is not None: br = db.query(func.max(model.rating)).filter(
durs.append(b[3]) *conds(model, ids, {**p, "rating_min": None}, kind_movie, True)
if b[4] is not None: ).scalar()
durs.append(b[4]) if br is not None:
ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter.
if kind_movie:
bd = db.query(func.min(model.duration_s), func.max(model.duration_s)).filter(
*conds(model, ids, p, kind_movie, False)
).first()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
collect("plex_items", movie_ids, with_dur=True) per_table(PlexItem, movie_ids, True)
collect("plex_shows", show_ids, with_dur=False) per_table(PlexShow, show_ids, False)
return { return {
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: (-kv[1], kv[0]))], "genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: (-kv[1], kv[0]))],
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])], "content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],

View file

@ -62,11 +62,13 @@ export default function PlexSidebar({
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id); onOpenPlaylist(pl.id);
} }
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both). // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters
// (faceted search) — so picking one filter narrows what the others offer. Refetches on any change.
const facetsQ = useQuery({ const facetsQ = useQuery({
queryKey: ["plex-facets", scope], queryKey: ["plex-facets", scope, filters],
queryFn: () => api.plexFacets(scope), queryFn: () => api.plexFacets(scope, filters),
enabled: !!scope, enabled: !!scope,
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
}); });
const facets = facetsQ.data; const facets = facetsQ.data;

View file

@ -1210,8 +1210,26 @@ export const api = {
} }
return req(`/api/plex/library?${u.toString()}`); return req(`/api/plex/library?${u.toString()}`);
}, },
plexFacets: (scope: string): Promise<PlexFacets> => // Facets ADAPT to the active filters (faceted search) — pass them so each group narrows the others.
req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`), plexFacets: (scope: string, f?: PlexFilters): Promise<PlexFacets> => {
const u = new URLSearchParams({ scope });
if (f) {
if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
if (f.addedWithin) u.set("added_within", f.addedWithin);
if (f.directors?.length) u.set("directors", f.directors.join(","));
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
}
return req(`/api/plex/facets?${u.toString()}`);
},
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> => plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`), req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>