From e6de18b8c0904de8979e8509a2e83fa85d4bf4ef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 21:32:49 +0200 Subject: [PATCH] perf(plex): the facets ask the episode rollup once per call, not once per show group A watch-state scope (watched / in progress / unwatched) joined the per-user episode rollup into each show facet query: five passes over the user's episodes per call, half of a filtered facets request. The shows in scope are now resolved once and every group filters on their ids; the grid-parity test holds the counts. Localdev: 99 -> 61 ms. --- backend/app/routes/plex/browse.py | 28 +++++++++++++------ backend/tests/test_plex_facets_grid_parity.py | 25 +++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index ab56d020..16acda1b 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -358,6 +358,24 @@ def facets( uid = user.id + # The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a + # full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups + # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call + # (E31 S4e). The same filter on the same rows, so the counts do not change + # (test_plex_facets_grid_parity). + shows_in_scope: list[int] | None = None + if show_ids and show in ("watched", "in_progress", "unwatched"): + agg = rollup.agg_subquery(db, uid).subquery() + in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id) + in_scope = apply_show_state_filter( + in_scope, + func.coalesce(agg.c.total, 0), + func.coalesce(agg.c.watched, 0), + func.coalesce(agg.c.inprog, 0), + show, + ) + shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_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.""" @@ -376,14 +394,8 @@ def facets( st = aliased(PlexState) query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) query = apply_movie_state_filter(query, st, show) - else: - if show in ("watched", "in_progress", "unwatched"): - agg = rollup.agg_subquery(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) - query = apply_show_state_filter(query, tot, wat, inp, show) + elif shows_in_scope is not None: + query = query.where(model.id.in_(shows_in_scope)) return query genre_counts: dict[str, int] = {} diff --git a/backend/tests/test_plex_facets_grid_parity.py b/backend/tests/test_plex_facets_grid_parity.py index 69780f4e..151bbc55 100644 --- a/backend/tests/test_plex_facets_grid_parity.py +++ b/backend/tests/test_plex_facets_grid_parity.py @@ -214,3 +214,28 @@ def test_hidden_movie_absent_from_grid_and_facets(db, as_user): facets = client.get("/api/plex/facets?scope=movie&show=all") crs = {c["value"]: c["count"] for c in facets.json()["content_ratings"]} assert crs.get("R") == 1, crs + + +def test_a_watch_state_scope_rolls_the_episodes_up_once(db, as_user): + """The per-user episode rollup behind a show's watch state is a pass over every episode; each show + facet group asked it again (E31 S4e: half of a filtered facets call). It is asked once now.""" + from sqlalchemy import event + + from app.db import engine + + seed = _seed(db) + client = as_user(seed["user"]) + rollups: list[str] = [] + + def count(conn, cursor, statement, *_): + if "GROUP BY plex_items.show_id" in statement: + rollups.append(statement) + + event.listen(engine, "before_cursor_execute", count) + try: + for show in ("watched", "in_progress", "unwatched"): + rollups.clear() + assert client.get(f"/api/plex/facets?scope=both&show={show}").status_code == 200 + assert len(rollups) == 1, show + finally: + event.remove(engine, "before_cursor_execute", count)