fix(plex): facet + sort refinements
- Facets now respect the watch-state filter too (In progress / Watched / Unwatched), so e.g. the Year bounds match the visible set (was showing the library min, not the filtered min). - Genre chips sorted alphabetically (was by count). - Sort chips ordered alphabetically by their localized label. - Ascending/Descending chips swapped (Ascending first) and the default sort direction is now Ascending. - Default sort is now Title (ascending) instead of Recently added / Descending.
This commit is contained in:
parent
017be5f8ca
commit
fe024ab29d
4 changed files with 65 additions and 34 deletions
|
|
@ -743,6 +743,7 @@ def unified_library(
|
||||||
@router.get("/facets")
|
@router.get("/facets")
|
||||||
def facets(
|
def facets(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
|
show: str = "all",
|
||||||
genres: str | None = None,
|
genres: str | None = None,
|
||||||
genre_mode: str = "any",
|
genre_mode: str = "any",
|
||||||
content_ratings: str | None = None,
|
content_ratings: str | None = None,
|
||||||
|
|
@ -785,15 +786,41 @@ def facets(
|
||||||
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
|
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
|
||||||
}
|
}
|
||||||
|
|
||||||
def conds(model, ids, pp, kind_movie, with_dur):
|
uid = user.id
|
||||||
c = _meta_filter_conds(model, pp) + [model.library_id.in_(ids)]
|
|
||||||
|
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
||||||
|
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
|
||||||
|
facet aggregation query, so the facets reflect exactly the visible result set."""
|
||||||
|
query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp))
|
||||||
if kind_movie:
|
if kind_movie:
|
||||||
c.append(model.kind == "movie")
|
query = query.filter(model.kind == "movie")
|
||||||
if with_dur and pp.get("duration_min") is not None:
|
if with_dur and pp.get("duration_min") is not None:
|
||||||
c.append(model.duration_s >= pp["duration_min"])
|
query = query.filter(model.duration_s >= pp["duration_min"])
|
||||||
if with_dur and pp.get("duration_max") is not None:
|
if with_dur and pp.get("duration_max") is not None:
|
||||||
c.append(model.duration_s <= pp["duration_max"])
|
query = query.filter(model.duration_s <= pp["duration_max"])
|
||||||
return c
|
if show in ("watched", "in_progress", "unwatched"):
|
||||||
|
st = aliased(PlexState)
|
||||||
|
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(st.status == "watched")
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
||||||
|
else:
|
||||||
|
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||||
|
else:
|
||||||
|
if show in ("watched", "in_progress", "unwatched"):
|
||||||
|
agg = _show_agg_subq(db, uid).subquery()
|
||||||
|
query = query.outerjoin(agg, agg.c.show_id == model.id)
|
||||||
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(tot > 0, wat >= tot)
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
|
||||||
|
else:
|
||||||
|
query = query.filter(wat == 0, inp == 0)
|
||||||
|
return query
|
||||||
|
|
||||||
genre_counts: dict[str, int] = {}
|
genre_counts: dict[str, int] = {}
|
||||||
cr_counts: dict[str, int] = {}
|
cr_counts: dict[str, int] = {}
|
||||||
|
|
@ -805,37 +832,36 @@ def facets(
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
# Genres — exclude the genre filter itself so you can keep adding genres.
|
# Genres — exclude the genre filter itself so you can keep adding genres.
|
||||||
gc = conds(model, ids, {**p, "genres": None}, kind_movie, True)
|
expanded = wsq(
|
||||||
expanded = db.query(func.jsonb_array_elements_text(model.genres).label("g")).filter(*gc).subquery()
|
db.query(func.jsonb_array_elements_text(model.genres).label("g")),
|
||||||
|
model, ids, {**p, "genres": None}, kind_movie, True,
|
||||||
|
).subquery()
|
||||||
for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all():
|
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
|
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||||
# Content ratings — exclude its own.
|
# Content ratings — exclude its own.
|
||||||
cc = conds(model, ids, {**p, "content_ratings": None}, kind_movie, True)
|
crq = wsq(
|
||||||
for cr, c in (
|
db.query(model.content_rating, func.count()),
|
||||||
db.query(model.content_rating, func.count())
|
model, ids, {**p, "content_ratings": None}, kind_movie, True,
|
||||||
.filter(*cc, model.content_rating.isnot(None))
|
).filter(model.content_rating.isnot(None)).group_by(model.content_rating)
|
||||||
.group_by(model.content_rating)
|
for cr, c in crq.all():
|
||||||
.all()
|
|
||||||
):
|
|
||||||
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||||
# Year bounds — exclude the year filter.
|
# Year bounds — exclude the year filter.
|
||||||
by = db.query(func.min(model.year), func.max(model.year)).filter(
|
by = wsq(
|
||||||
*conds(model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True)
|
db.query(func.min(model.year), func.max(model.year)),
|
||||||
|
model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True,
|
||||||
).first()
|
).first()
|
||||||
if by[0] is not None:
|
if by[0] is not None:
|
||||||
years.append(by[0])
|
years.append(by[0])
|
||||||
if by[1] is not None:
|
if by[1] is not None:
|
||||||
years.append(by[1])
|
years.append(by[1])
|
||||||
# Rating max — exclude the rating filter.
|
# Rating max — exclude the rating filter.
|
||||||
br = db.query(func.max(model.rating)).filter(
|
br = wsq(db.query(func.max(model.rating)), model, ids, {**p, "rating_min": None}, kind_movie, True).scalar()
|
||||||
*conds(model, ids, {**p, "rating_min": None}, kind_movie, True)
|
|
||||||
).scalar()
|
|
||||||
if br is not None:
|
if br is not None:
|
||||||
ratings.append(float(br))
|
ratings.append(float(br))
|
||||||
# Duration bounds (movies) — exclude the duration filter.
|
# Duration bounds (movies) — exclude the duration filter.
|
||||||
if kind_movie:
|
if kind_movie:
|
||||||
bd = db.query(func.min(model.duration_s), func.max(model.duration_s)).filter(
|
bd = wsq(
|
||||||
*conds(model, ids, p, kind_movie, False)
|
db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False
|
||||||
).first()
|
).first()
|
||||||
if bd[0] is not None:
|
if bd[0] is not None:
|
||||||
durs.append(bd[0])
|
durs.append(bd[0])
|
||||||
|
|
@ -845,7 +871,7 @@ def facets(
|
||||||
per_table(PlexItem, movie_ids, True)
|
per_table(PlexItem, movie_ids, True)
|
||||||
per_table(PlexShow, show_ids, 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[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])],
|
||||||
"year_min": min(years) if years else None,
|
"year_min": min(years) if years else None,
|
||||||
"year_max": max(years) if years else None,
|
"year_max": max(years) if years else None,
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ export default function App() {
|
||||||
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
|
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
|
||||||
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
|
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
|
||||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
|
||||||
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ export default function PlexSidebar({
|
||||||
// Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters
|
// 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.
|
// (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, filters],
|
queryKey: ["plex-facets", scope, show, filters],
|
||||||
queryFn: () => api.plexFacets(scope, filters),
|
queryFn: () => api.plexFacets(scope, filters, show),
|
||||||
enabled: !!scope,
|
enabled: !!scope,
|
||||||
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
|
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
|
||||||
});
|
});
|
||||||
|
|
@ -75,7 +75,7 @@ export default function PlexSidebar({
|
||||||
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
||||||
// collection"; there's no per-library picker in the unified cross-library scope.)
|
// collection"; there's no per-library picker in the unified cross-library scope.)
|
||||||
const fCount = plexFilterCount(filters);
|
const fCount = plexFilterCount(filters);
|
||||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
|
||||||
const anyActive = activeCount > 0;
|
const anyActive = activeCount > 0;
|
||||||
|
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
|
|
@ -84,11 +84,14 @@ export default function PlexSidebar({
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
setFilters(EMPTY_PLEX_FILTERS);
|
setFilters(EMPTY_PLEX_FILTERS);
|
||||||
setShow("all");
|
setShow("all");
|
||||||
setSort("added");
|
setSort("title");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
||||||
const sorts = scope === "movie" ? MOVIE_SORTS : SHOW_SORTS;
|
// Ordered alphabetically by their localized label.
|
||||||
|
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
||||||
|
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
||||||
|
);
|
||||||
const durBucketKey = DURATION_BUCKETS.find(
|
const durBucketKey = DURATION_BUCKETS.find(
|
||||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||||
)?.key;
|
)?.key;
|
||||||
|
|
@ -210,8 +213,8 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
<div className="mt-1.5 flex gap-1">
|
<div className="mt-1.5 flex gap-1">
|
||||||
{(["desc", "asc"] as const).map((d) => (
|
{(["asc", "desc"] as const).map((d) => (
|
||||||
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}>
|
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
|
||||||
{t(`plex.filter.dir.${d}`)}
|
{t(`plex.filter.dir.${d}`)}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1206,13 +1206,15 @@ export const api = {
|
||||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||||
if (f.collection) u.set("collection", f.collection);
|
if (f.collection) u.set("collection", f.collection);
|
||||||
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
|
||||||
}
|
}
|
||||||
return req(`/api/plex/library?${u.toString()}`);
|
return req(`/api/plex/library?${u.toString()}`);
|
||||||
},
|
},
|
||||||
// Facets ADAPT to the active filters (faceted search) — pass them so each group narrows the others.
|
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
||||||
plexFacets: (scope: string, f?: PlexFilters): Promise<PlexFacets> => {
|
// narrows the others and the bounds match the visible result set.
|
||||||
|
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
|
||||||
const u = new URLSearchParams({ scope });
|
const u = new URLSearchParams({ scope });
|
||||||
|
if (show && show !== "all") u.set("show", show);
|
||||||
if (f) {
|
if (f) {
|
||||||
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
||||||
if (f.genreMode === "all") u.set("genre_mode", "all");
|
if (f.genreMode === "all") u.set("genre_mode", "all");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue