feat(plex): apply the sidebar filters to the Discover rails too

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.
This commit is contained in:
2026-08-22 17:35:37 +02:00
parent a60164edc4
commit 9798e506cf
7 changed files with 159 additions and 55 deletions
+87 -36
View File
@@ -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()
+24
View File
@@ -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"]
+6 -5
View File
@@ -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) {
@@ -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 () => {
+12 -7
View File
@@ -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<PlexDiscoverData>(qk.plexDiscover(scope, lang), (old) =>
qc.setQueryData<PlexDiscoverData>(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: () => {
+14 -4
View File
@@ -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<PlexDiscover> => {
// 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<PlexDiscover> => {
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<PlexShowDetail> =>
+3 -2
View File
@@ -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.