From 017be5f8ca6ab56d4c50829d7d28eb08bf270034 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 00:31:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20adaptive=20(faceted)=20filters=20?= =?UTF-8?q?=E2=80=94=20each=20filter=20narrows=20the=20others?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/plex.py | 138 ++++++++++++++++-------- frontend/src/components/PlexSidebar.tsx | 8 +- frontend/src/lib/api.ts | 22 +++- 3 files changed, 119 insertions(+), 49 deletions(-) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index aeba013..ac2b18f 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -351,36 +351,42 @@ def _added_cutoff(within: str | None) -> datetime | None: return datetime.now(timezone.utc) - timedelta(days=days) if days else None -def _apply_meta_filters(q, model, p: dict): - """Apply the shared metadata filters (identical column names on plex_items + plex_shows) to a - query over `model`. `p` is the request's filter params dict. Duration is movie-only, applied by - the caller. Kept DRY so movies, shows, and the unified cross-library browse filter identically.""" +def _meta_filter_conds(model, p: dict) -> list: + """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for + `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a + list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets.""" + conds: list = [] gsel = _csv(p.get("genres")) if gsel: - conds = [model.genres.contains([g]) for g in gsel] - q = q.filter(and_(*conds) if p.get("genre_mode") == "all" else or_(*conds)) + gc = [model.genres.contains([g]) for g in gsel] + conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) crs = _csv(p.get("content_ratings")) 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: - q = q.filter(model.year >= p["year_min"]) + conds.append(model.year >= p["year_min"]) 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: - q = q.filter(model.rating >= p["rating_min"]) + conds.append(model.rating >= p["rating_min"]) cutoff = _added_cutoff(p.get("added_within")) 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 - q = q.filter(model.directors.contains([d])) + conds.append(model.directors.contains([d])) 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")) if stds: # OR - q = q.filter(model.studio.in_(stds)) + conds.append(model.studio.in_(stds)) if p.get("collection"): - q = q.filter(model.collection_keys.contains([p["collection"]])) - return q + conds.append(model.collection_keys.contains([p["collection"]])) + 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") @@ -737,12 +743,27 @@ def unified_library( @router.get("/facets") def facets( 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), db: Session = Depends(get_db), ) -> dict: - """Available filter values for the unified library, scoped to movie|show|both: genres + content - ratings (with counts merged across the scope's libraries) and the year/rating/duration bounds, so - the sidebar only offers what the scope actually contains. Duration bounds are movie-only.""" + """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active + filters (faceted search): each group's values/bounds are computed with all the OTHER active + 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 = { "genres": [], "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 [] if not movie_ids and not show_ids: 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] = {} cr_counts: dict[str, int] = {} @@ -764,36 +801,49 @@ def facets( ratings: list[float] = [] 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: return - base = f"FROM {table} WHERE library_id = ANY(:ids)" - for g, c in db.execute( - text(f"SELECT g, count(*) c FROM (SELECT jsonb_array_elements_text(genres) g {base}) x GROUP BY g"), - {"ids": ids}, - ).all(): + # Genres — exclude the genre filter itself so you can keep adding genres. + gc = conds(model, ids, {**p, "genres": None}, kind_movie, True) + expanded = db.query(func.jsonb_array_elements_text(model.genres).label("g")).filter(*gc).subquery() + for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): genre_counts[g] = genre_counts.get(g, 0) + c - for cr, c in db.execute( - text(f"SELECT content_rating, count(*) c {base} AND content_rating IS NOT NULL GROUP BY content_rating"), - {"ids": ids}, - ).all(): + # Content ratings — exclude its own. + cc = conds(model, ids, {**p, "content_ratings": None}, kind_movie, True) + for cr, c in ( + 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 - dur_sel = ", min(duration_s), max(duration_s)" if with_dur else "" - b = db.execute(text(f"SELECT min(year), max(year), max(rating){dur_sel} {base}"), {"ids": ids}).first() - if b[0] is not None: - years.append(b[0]) - if b[1] is not None: - years.append(b[1]) - if b[2] is not None: - ratings.append(float(b[2])) - if with_dur: - if b[3] is not None: - durs.append(b[3]) - if b[4] is not None: - durs.append(b[4]) + # Year bounds — exclude the year filter. + by = db.query(func.min(model.year), func.max(model.year)).filter( + *conds(model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True) + ).first() + if by[0] is not None: + years.append(by[0]) + if by[1] is not None: + years.append(by[1]) + # Rating max — exclude the rating filter. + br = db.query(func.max(model.rating)).filter( + *conds(model, ids, {**p, "rating_min": None}, kind_movie, True) + ).scalar() + 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) - collect("plex_shows", show_ids, with_dur=False) + per_table(PlexItem, movie_ids, True) + per_table(PlexShow, show_ids, False) return { "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])], diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index f39a467..d293108 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -62,11 +62,13 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); 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({ - queryKey: ["plex-facets", scope], - queryFn: () => api.plexFacets(scope), + queryKey: ["plex-facets", scope, filters], + queryFn: () => api.plexFacets(scope, filters), enabled: !!scope, + placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) }); const facets = facetsQ.data; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f887719..e4dfc54 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1210,8 +1210,26 @@ export const api = { } return req(`/api/plex/library?${u.toString()}`); }, - plexFacets: (scope: string): Promise => - req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`), + // Facets ADAPT to the active filters (faceted search) — pass them so each group narrows the others. + plexFacets: (scope: string, f?: PlexFilters): Promise => { + 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[] }> => req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`), plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>