From 9798e506cf7ff640417cb19dec959835ff2d9ef9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 17:35:37 +0200 Subject: [PATCH] feat(plex): apply the sidebar filters to the Discover rails too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover now behaves like the Browse grid — the active metadata filters (genre / rating / year / collection / studio / …) narrow every rail: Continue watching, Recently added, Surprise me, the genre tiles, and Because you watched. Same LibraryFilters / meta_filter_conds the grid uses, applied to each rail's movie + show query (movie duration included); a rail may come back empty, which the client already hides. Genre-language matches the grid. Because Discover honours filters itself, adding a filter no longer force-flips the Discover landing to the grid (only a header SEARCH does, since Discover has no search rail) — so you stay on Discover with narrowed rails. Frontend threads `filters` into api.plexDiscover/plexSurprise + their query keys (plexSurprise is variadic now) and the watched-toggle setQueryData; PlexDiscover reads them from usePlex. Tests: two discover filter-narrowing cases; PlexDiscover dom test updated for the provider default + 3-arg call. --- backend/app/routes/plex/discover.py | 123 +++++++++++++----- backend/tests/test_plex_discover.py | 24 ++++ frontend/src/components/PlexBrowse.tsx | 11 +- .../src/components/PlexDiscover.dom.test.tsx | 14 +- frontend/src/components/PlexDiscover.tsx | 19 ++- frontend/src/lib/api/plex.ts | 18 ++- frontend/src/lib/queryKeys.ts | 5 +- 7 files changed, 159 insertions(+), 55 deletions(-) diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 65e2c695..97ce7417 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -7,6 +7,11 @@ recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid card paths); the HU overlay coalesces only in the main grid. + +The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the +Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the +grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which +the client hides). Genre-language matches the grid; duration is movie-only. """ from fastapi import APIRouter, Depends, Query @@ -18,7 +23,13 @@ from app.db import get_db from app.models import PlexItem, PlexShow, PlexState, User from app.plex import rollup from app.plex.cards import movie_card, show_card -from app.plex.query import apply_show_state_filter, genres_col +from app.plex.query import ( + LibraryFilters, + apply_meta_filters, + apply_show_state_filter, + genres_col, + meta_filter_conds, +) from app.plex.tmdb import resolve_recs from app.routes.plex._shared import _content_libs, _norm_lang @@ -37,6 +48,16 @@ def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]: ) +def _movie_meta(q, p: dict, lang: str, f: LibraryFilters): + """Apply the shared metadata filters + movie-only duration to a movie query (same as the grid).""" + q = apply_meta_filters(q, PlexItem, p, lang) + if f.duration_min is not None: + q = q.where(PlexItem.duration_s >= f.duration_min) + if f.duration_max is not None: + q = q.where(PlexItem.duration_s <= f.duration_max) + return q + + def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]: sts = rollup.item_state_map(db, uid, [it.id for it in items]) return [movie_card(it, sts.get(it.id)) for it in items] @@ -48,13 +69,17 @@ def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]: def _recently_added( - db: Session, uid: int, movie_ids: list[int], show_ids: list[int] + db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters ) -> list[dict]: """Newest movies + shows, interleaved by `added_at` desc into one rail.""" movies = ( db.execute( - select(PlexItem) - .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)) + _movie_meta( + select(PlexItem).where( + PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids) + ), + p, lang, f, + ) .order_by(PlexItem.added_at.desc().nullslast()) .limit(RAIL_LIMIT) ).scalars().all() @@ -63,8 +88,9 @@ def _recently_added( ) shows = ( db.execute( - select(PlexShow) - .where(PlexShow.library_id.in_(show_ids)) + apply_meta_filters( + select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang + ) .order_by(PlexShow.added_at.desc().nullslast()) .limit(RAIL_LIMIT) ).scalars().all() @@ -92,14 +118,14 @@ def _recently_added( def _continue_watching( - db: Session, uid: int, movie_ids: list[int], show_ids: list[int] + db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters ) -> list[dict]: """Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first. A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes.""" rows: list[tuple[object, dict]] = [] # (resumed_at, card) if movie_ids: - mrows = db.execute( + mq = ( select(PlexItem, PlexState) .join( PlexState, @@ -116,6 +142,9 @@ def _continue_watching( PlexState.status.notin_(["watched", "hidden"]), ), ) + ) + mrows = db.execute( + _movie_meta(mq, p, lang, f) .order_by(PlexState.progress_updated_at.desc().nullslast()) .limit(RAIL_LIMIT) ).all() @@ -127,15 +156,15 @@ def _continue_watching( # its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared # `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition. prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() + sq = apply_show_state_filter( + select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id), + prog.c.total, + prog.c.watched, + prog.c.inprog, + "in_progress", + ).where(PlexShow.library_id.in_(show_ids)) srows = db.execute( - apply_show_state_filter( - select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id), - prog.c.total, - prog.c.watched, - prog.c.inprog, - "in_progress", - ) - .where(PlexShow.library_id.in_(show_ids)) + apply_meta_filters(sq, PlexShow, p, lang) .order_by(prog.c.resumed_at.desc().nullslast()) .limit(RAIL_LIMIT) ).all() @@ -147,17 +176,20 @@ def _continue_watching( def _genre_tiles( - db: Session, movie_ids: list[int], show_ids: list[int], lang: str + db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str ) -> list[dict]: """The most-common genres across the scope, as {value, count} tiles → tap to filter the grid. - The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.""" + The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid. + The active metadata filters narrow the counts too, so the tiles agree with the filtered rails.""" counts: dict[str, int] = {} for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)): if not ids: continue gcol = genres_col(model, lang) base = select(func.jsonb_array_elements_text(gcol).label("g")).where( - model.library_id.in_(ids), func.jsonb_typeof(gcol) == "array" + model.library_id.in_(ids), + func.jsonb_typeof(gcol) == "array", + *meta_filter_conds(model, p, lang), ) if model is PlexItem: base = base.where(model.kind == "movie") @@ -171,11 +203,12 @@ def _genre_tiles( def _because_you_watched( - db: Session, uid: int, movie_ids: list[int], show_ids: list[int] + db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters ) -> dict | None: """Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns - None (rail hidden) when there's no enriched watch history or none of its recs are in the library.""" + None (rail hidden) when there's no enriched watch history or none of its recs are in the library. + The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid.""" if not movie_ids: return None anchor = db.execute( @@ -207,15 +240,20 @@ def _because_you_watched( movies = { m.rating_key: m for m in db.execute( - select(PlexItem).where( - PlexItem.kind == "movie", PlexItem.rating_key.in_(rks) + _movie_meta( + select(PlexItem).where( + PlexItem.kind == "movie", PlexItem.rating_key.in_(rks) + ), + p, lang, f, ) ).scalars().all() } shows = { s.rating_key: s for s in db.execute( - select(PlexShow).where(PlexShow.rating_key.in_(rks)) + apply_meta_filters( + select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang + ) ).scalars().all() } ordered_movies = [movies[rk] for rk in rks if rk in movies] @@ -238,36 +276,48 @@ def _because_you_watched( def discover( scope: str = "both", lang: str = "en", + f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as - empty lists (the client hides them); `because_you_watched` is null when there's no anchor.""" + """The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active + sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched` + is null when there's no anchor.""" lang = _norm_lang(lang) + p = f.as_dict() movie_ids, show_ids = _scope_libs(db, scope) return { "scope": scope, - "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids), - "recently_added": _recently_added(db, user.id, movie_ids, show_ids), - "genres": _genre_tiles(db, movie_ids, show_ids, lang), - "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids), + "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f), + "recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f), + "genres": _genre_tiles(db, movie_ids, show_ids, p, lang), + "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f), } @router.get("/surprise") def surprise( scope: str = "both", + lang: str = "en", limit: int = Query(default=RAIL_LIMIT, ge=1, le=40), + f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and - shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type.""" + """A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by + the active filters. Movies and shows are drawn separately then interleaved so a lopsided library + doesn't crowd out one type.""" + lang = _norm_lang(lang) + p = f.as_dict() movie_ids, show_ids = _scope_libs(db, scope) movies = ( db.execute( - select(PlexItem) - .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)) + _movie_meta( + select(PlexItem).where( + PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids) + ), + p, lang, f, + ) .order_by(func.random()) .limit(limit) ).scalars().all() @@ -276,8 +326,9 @@ def surprise( ) shows = ( db.execute( - select(PlexShow) - .where(PlexShow.library_id.in_(show_ids)) + apply_meta_filters( + select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang + ) .order_by(func.random()) .limit(limit) ).scalars().all() diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index bc77d2cd..3976af94 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -200,3 +200,27 @@ def test_discover_scope_movie_excludes_shows(db, monkeypatch): assert all(c["type"] == "movie" for c in d["recently_added"]) # The show drops out of continue watching under the movie scope. assert _ids(d["continue_watching"]) == ["m3"] + + +def test_filters_narrow_every_discover_rail(db, monkeypatch): + # The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama + # filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop. + user = _seed(db) + d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json() + # Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out. + assert set(_ids(d["recently_added"])) == {"m1", "m3"} + # Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded). + assert _ids(d["continue_watching"]) == ["m3"] + # Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3). + assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2} + # Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail. + assert d["because_you_watched"] is None + + +def test_filters_narrow_surprise(db, monkeypatch): + user = _seed(db) + items = _get( + db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy" + ).json()["items"] + # Only the one Comedy movie qualifies under the filter. + assert _ids(items) == ["m2"] diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 69e4e427..72e38d6b 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -105,11 +105,12 @@ export default function PlexBrowse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [jumpTo]); - // Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on - // the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge, - // so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a - // Back to Discover with the search still in the box doesn't immediately re-flip. - const browseIntent = !!dq || plexFilterCount(filters) > 0; + // Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on + // the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back + // to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip + // trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so + // adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid. + const browseIntent = !!dq; const prevIntent = useRef(browseIntent); useEffect(() => { if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) { diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 7a204232..b1966a85 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -42,6 +42,17 @@ vi.mock("../lib/api", () => ({ plexSurprise: (...a: unknown[]) => plexSurprise(...a), plexSetState: (...a: unknown[]) => plexSetState(...a), }, + // PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs + // EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply. + EMPTY_PLEX_FILTERS: { + genres: [], + contentRatings: [], + directors: [], + actors: [], + studios: [], + keywords: [], + collections: [], + }, })); import PlexDiscover from "./PlexDiscover"; @@ -102,7 +113,8 @@ describe("PlexDiscover", () => { expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0); expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0); expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0); - expect(plexDiscover).toHaveBeenCalledWith("both", "en"); + // Now called with the active filters too (empty default here, no provider mounted). + expect(plexDiscover).toHaveBeenCalledWith("both", "en", expect.objectContaining({ genres: [] })); }); it("fires onGenre with the tile's value", async () => { diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index ad19b79b..4137cddf 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -6,6 +6,7 @@ import { qk } from "../lib/queryKeys"; import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api"; import { plexLang } from "../i18n"; import { toggleWatchState } from "../lib/plexWatch"; +import { usePlex } from "./PlexProvider"; import { Carousel } from "./Carousel"; import { PlexPosterCard } from "./plexCards"; import { LoadingState, StateMessage } from "./QueryState"; @@ -89,14 +90,17 @@ export default function PlexDiscover({ const { t } = useTranslation(); const qc = useQueryClient(); const lang = plexLang(); + // The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the + // query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same). + const { filters } = usePlex(); const q = useQuery({ - queryKey: qk.plexDiscover(scope, lang), - queryFn: () => api.plexDiscover(scope, lang), + queryKey: qk.plexDiscover(scope, lang, filters), + queryFn: () => api.plexDiscover(scope, lang, filters), }); // Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface. const surpriseQ = useQuery({ - queryKey: qk.plexSurprise(scope), - queryFn: () => api.plexSurprise(scope), + queryKey: qk.plexSurprise(scope, filters), + queryFn: () => api.plexSurprise(scope, undefined, filters, lang), staleTime: 0, }); @@ -107,7 +111,7 @@ export default function PlexDiscover({ optimistic: (next) => { const flip = (cards: PlexCard[]) => cards.map((c) => (c.id === card.id ? { ...c, status: next } : c)); - qc.setQueryData(qk.plexDiscover(scope, lang), (old) => + qc.setQueryData(qk.plexDiscover(scope, lang, filters), (old) => old ? { ...old, @@ -119,8 +123,9 @@ export default function PlexDiscover({ } : old, ); - qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) => - old ? { ...old, items: flip(old.items) } : old, + qc.setQueryData<{ scope: string; items: PlexCard[] }>( + qk.plexSurprise(scope, filters), + (old) => (old ? { ...old, items: flip(old.items) } : old), ); }, resync: () => { diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index dd94397b..920e3846 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -463,16 +463,26 @@ export const plexApi = { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }), }), - // Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary. - plexDiscover: (scope: string, lang?: string): Promise => { + // Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the + // active sidebar `filters` narrow every rail (so Discover behaves like the Browse grid). + plexDiscover: (scope: string, lang?: string, filters?: PlexFilters): Promise => { const u = new URLSearchParams({ scope }); if (lang === "hu") u.set("lang", "hu"); + if (filters) appendPlexFilters(u, filters); return req(`/api/plex/discover?${u.toString()}`); }, - // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch). - plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => { + // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by + // the active filters like the other rails. + plexSurprise: ( + scope: string, + limit?: number, + filters?: PlexFilters, + lang?: string, + ): Promise<{ scope: string; items: PlexCard[] }> => { const u = new URLSearchParams({ scope }); if (limit) u.set("limit", String(limit)); + if (lang === "hu") u.set("lang", "hu"); + if (filters) appendPlexFilters(u, filters); return req(`/api/plex/surprise?${u.toString()}`); }, plexShow: (id: string, lang?: string): Promise => diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 08d69d50..69561050 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -97,8 +97,9 @@ export const qk = { // both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle // refetches it alone. plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], - plexSurprise: (scope?: string) => - scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const), + // Variadic like plexDiscover so it can key by scope + the active filters; `plexSurprise()` (no args) + // stays a prefix for the reshuffle/watched-toggle invalidations. + plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest], plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.