From fe024ab29d76a36c66a03d9f628b83ab5ab9e80c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 00:45:05 +0200 Subject: [PATCH] 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. --- backend/app/routes/plex.py | 72 +++++++++++++++++-------- frontend/src/App.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 17 +++--- frontend/src/lib/api.ts | 8 +-- 4 files changed, 65 insertions(+), 34 deletions(-) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index ac2b18f..825cc8e 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -743,6 +743,7 @@ def unified_library( @router.get("/facets") def facets( scope: str = "both", + show: str = "all", genres: str | None = None, genre_mode: str = "any", content_ratings: str | None = None, @@ -785,15 +786,41 @@ def facets( "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)] + uid = user.id + + 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: - c.append(model.kind == "movie") + query = query.filter(model.kind == "movie") 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: - c.append(model.duration_s <= pp["duration_max"]) - return c + query = query.filter(model.duration_s <= pp["duration_max"]) + 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] = {} cr_counts: dict[str, int] = {} @@ -805,37 +832,36 @@ def facets( if not ids: return # 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() + expanded = wsq( + 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(): genre_counts[g] = genre_counts.get(g, 0) + c # 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() - ): + crq = wsq( + db.query(model.content_rating, func.count()), + model, ids, {**p, "content_ratings": None}, kind_movie, True, + ).filter(model.content_rating.isnot(None)).group_by(model.content_rating) + for cr, c in crq.all(): cr_counts[cr] = cr_counts.get(cr, 0) + c # 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) + by = wsq( + db.query(func.min(model.year), func.max(model.year)), + 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() + br = wsq(db.query(func.max(model.rating)), 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) + bd = wsq( + db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False ).first() if bd[0] is not None: durs.append(bd[0]) @@ -845,7 +871,7 @@ def facets( 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]))], + "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])], "year_min": min(years) if years else None, "year_max": max(years) if years else None, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70297ba..faf8f66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -251,7 +251,7 @@ export default function App() { const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both"); const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both"; 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(null); // sidebar → open a playlist // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}"); diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index d293108..28bbfce 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -65,8 +65,8 @@ export default function PlexSidebar({ // 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, filters], - queryFn: () => api.plexFacets(scope, filters), + queryKey: ["plex-facets", scope, show, filters], + queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, 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"; there's no per-library picker in the unified cross-library scope.) 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 patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -84,11 +84,14 @@ export default function PlexSidebar({ const clearAll = () => { setFilters(EMPTY_PLEX_FILTERS); setShow("all"); - setSort("added"); + setSort("title"); }; // 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( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; @@ -210,8 +213,8 @@ export default function PlexSidebar({ ))}
- {(["desc", "asc"] as const).map((d) => ( - patch({ sortDir: d })}> + {(["asc", "desc"] as const).map((d) => ( + patch({ sortDir: d })}> {t(`plex.filter.dir.${d}`)} ))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e4dfc54..59de070 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1206,13 +1206,15 @@ export const api = { 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); - 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()}`); }, - // Facets ADAPT to the active filters (faceted search) — pass them so each group narrows the others. - plexFacets: (scope: string, f?: PlexFilters): Promise => { + // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group + // narrows the others and the bounds match the visible result set. + plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => { const u = new URLSearchParams({ scope }); + if (show && show !== "all") u.set("show", show); if (f) { if (f.genres?.length) u.set("genres", f.genres.join(",")); if (f.genreMode === "all") u.set("genre_mode", "all");