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:
parent
736db017e4
commit
017be5f8ca
3 changed files with 119 additions and 49 deletions
|
|
@ -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])],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1210,8 +1210,26 @@ export const api = {
|
|||
}
|
||||
return req(`/api/plex/library?${u.toString()}`);
|
||||
},
|
||||
plexFacets: (scope: string): Promise<PlexFacets> =>
|
||||
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<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[] }> =>
|
||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue