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
|
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])],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[] }> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue