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:
@@ -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
|
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
|
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.
|
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
|
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.models import PlexItem, PlexShow, PlexState, User
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import movie_card, show_card
|
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.plex.tmdb import resolve_recs
|
||||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
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]:
|
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])
|
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]
|
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(
|
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]:
|
) -> list[dict]:
|
||||||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||||
movies = (
|
movies = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexItem)
|
_movie_meta(
|
||||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||||
|
),
|
||||||
|
p, lang, f,
|
||||||
|
)
|
||||||
.order_by(PlexItem.added_at.desc().nullslast())
|
.order_by(PlexItem.added_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -63,8 +88,9 @@ def _recently_added(
|
|||||||
)
|
)
|
||||||
shows = (
|
shows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexShow)
|
apply_meta_filters(
|
||||||
.where(PlexShow.library_id.in_(show_ids))
|
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||||
|
)
|
||||||
.order_by(PlexShow.added_at.desc().nullslast())
|
.order_by(PlexShow.added_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -92,14 +118,14 @@ def _recently_added(
|
|||||||
|
|
||||||
|
|
||||||
def _continue_watching(
|
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]:
|
) -> list[dict]:
|
||||||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
"""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."""
|
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)
|
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
|
||||||
|
|
||||||
if movie_ids:
|
if movie_ids:
|
||||||
mrows = db.execute(
|
mq = (
|
||||||
select(PlexItem, PlexState)
|
select(PlexItem, PlexState)
|
||||||
.join(
|
.join(
|
||||||
PlexState,
|
PlexState,
|
||||||
@@ -116,6 +142,9 @@ def _continue_watching(
|
|||||||
PlexState.status.notin_(["watched", "hidden"]),
|
PlexState.status.notin_(["watched", "hidden"]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
mrows = db.execute(
|
||||||
|
_movie_meta(mq, p, lang, f)
|
||||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).all()
|
).all()
|
||||||
@@ -127,15 +156,15 @@ def _continue_watching(
|
|||||||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
# 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.
|
# `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()
|
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(
|
srows = db.execute(
|
||||||
apply_show_state_filter(
|
apply_meta_filters(sq, PlexShow, p, lang)
|
||||||
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))
|
|
||||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).all()
|
).all()
|
||||||
@@ -147,17 +176,20 @@ def _continue_watching(
|
|||||||
|
|
||||||
|
|
||||||
def _genre_tiles(
|
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]:
|
) -> list[dict]:
|
||||||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
"""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] = {}
|
counts: dict[str, int] = {}
|
||||||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||||
if not ids:
|
if not ids:
|
||||||
continue
|
continue
|
||||||
gcol = genres_col(model, lang)
|
gcol = genres_col(model, lang)
|
||||||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
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:
|
if model is PlexItem:
|
||||||
base = base.where(model.kind == "movie")
|
base = base.where(model.kind == "movie")
|
||||||
@@ -171,11 +203,12 @@ def _genre_tiles(
|
|||||||
|
|
||||||
|
|
||||||
def _because_you_watched(
|
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:
|
) -> dict | None:
|
||||||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
"""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
|
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:
|
if not movie_ids:
|
||||||
return None
|
return None
|
||||||
anchor = db.execute(
|
anchor = db.execute(
|
||||||
@@ -207,15 +240,20 @@ def _because_you_watched(
|
|||||||
movies = {
|
movies = {
|
||||||
m.rating_key: m
|
m.rating_key: m
|
||||||
for m in db.execute(
|
for m in db.execute(
|
||||||
select(PlexItem).where(
|
_movie_meta(
|
||||||
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
||||||
|
),
|
||||||
|
p, lang, f,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
}
|
}
|
||||||
shows = {
|
shows = {
|
||||||
s.rating_key: s
|
s.rating_key: s
|
||||||
for s in db.execute(
|
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()
|
).scalars().all()
|
||||||
}
|
}
|
||||||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||||
@@ -238,36 +276,48 @@ def _because_you_watched(
|
|||||||
def discover(
|
def discover(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
lang: str = "en",
|
lang: str = "en",
|
||||||
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as
|
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||||
empty lists (the client hides them); `because_you_watched` is null when there's no anchor."""
|
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)
|
lang = _norm_lang(lang)
|
||||||
|
p = f.as_dict()
|
||||||
movie_ids, show_ids = _scope_libs(db, scope)
|
movie_ids, show_ids = _scope_libs(db, scope)
|
||||||
return {
|
return {
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"continue_watching": _continue_watching(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),
|
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
"genres": _genre_tiles(db, movie_ids, show_ids, lang),
|
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids),
|
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/surprise")
|
@router.get("/surprise")
|
||||||
def surprise(
|
def surprise(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
|
lang: str = "en",
|
||||||
limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
|
limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
|
||||||
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and
|
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||||||
shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type."""
|
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)
|
movie_ids, show_ids = _scope_libs(db, scope)
|
||||||
movies = (
|
movies = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexItem)
|
_movie_meta(
|
||||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||||
|
),
|
||||||
|
p, lang, f,
|
||||||
|
)
|
||||||
.order_by(func.random())
|
.order_by(func.random())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -276,8 +326,9 @@ def surprise(
|
|||||||
)
|
)
|
||||||
shows = (
|
shows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexShow)
|
apply_meta_filters(
|
||||||
.where(PlexShow.library_id.in_(show_ids))
|
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||||
|
)
|
||||||
.order_by(func.random())
|
.order_by(func.random())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|||||||
@@ -200,3 +200,27 @@ def test_discover_scope_movie_excludes_shows(db, monkeypatch):
|
|||||||
assert all(c["type"] == "movie" for c in d["recently_added"])
|
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||||||
# The show drops out of continue watching under the movie scope.
|
# The show drops out of continue watching under the movie scope.
|
||||||
assert _ids(d["continue_watching"]) == ["m3"]
|
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"]
|
||||||
|
|||||||
@@ -105,11 +105,12 @@ export default function PlexBrowse() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [jumpTo]);
|
}, [jumpTo]);
|
||||||
|
|
||||||
// Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on
|
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
|
||||||
// the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge,
|
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
|
||||||
// so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a
|
// to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
|
||||||
// Back to Discover with the search still in the box doesn't immediately re-flip.
|
// trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
|
||||||
const browseIntent = !!dq || plexFilterCount(filters) > 0;
|
// adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
|
||||||
|
const browseIntent = !!dq;
|
||||||
const prevIntent = useRef(browseIntent);
|
const prevIntent = useRef(browseIntent);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
|
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ vi.mock("../lib/api", () => ({
|
|||||||
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
|
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
|
||||||
plexSetState: (...a: unknown[]) => plexSetState(...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";
|
import PlexDiscover from "./PlexDiscover";
|
||||||
@@ -102,7 +113,8 @@ describe("PlexDiscover", () => {
|
|||||||
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("Rec One").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 () => {
|
it("fires onGenre with the tile's value", async () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import { usePlex } from "./PlexProvider";
|
||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
@@ -89,14 +90,17 @@ export default function PlexDiscover({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const lang = plexLang();
|
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({
|
const q = useQuery({
|
||||||
queryKey: qk.plexDiscover(scope, lang),
|
queryKey: qk.plexDiscover(scope, lang, filters),
|
||||||
queryFn: () => api.plexDiscover(scope, lang),
|
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||||
});
|
});
|
||||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||||
const surpriseQ = useQuery({
|
const surpriseQ = useQuery({
|
||||||
queryKey: qk.plexSurprise(scope),
|
queryKey: qk.plexSurprise(scope, filters),
|
||||||
queryFn: () => api.plexSurprise(scope),
|
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +111,7 @@ export default function PlexDiscover({
|
|||||||
optimistic: (next) => {
|
optimistic: (next) => {
|
||||||
const flip = (cards: PlexCard[]) =>
|
const flip = (cards: PlexCard[]) =>
|
||||||
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
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
|
||||||
? {
|
? {
|
||||||
...old,
|
...old,
|
||||||
@@ -119,8 +123,9 @@ export default function PlexDiscover({
|
|||||||
}
|
}
|
||||||
: old,
|
: old,
|
||||||
);
|
);
|
||||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) =>
|
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
||||||
old ? { ...old, items: flip(old.items) } : old,
|
qk.plexSurprise(scope, filters),
|
||||||
|
(old) => (old ? { ...old, items: flip(old.items) } : old),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
resync: () => {
|
resync: () => {
|
||||||
|
|||||||
@@ -463,16 +463,26 @@ export const plexApi = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||||
}),
|
}),
|
||||||
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary.
|
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the
|
||||||
plexDiscover: (scope: string, lang?: string): Promise<PlexDiscover> => {
|
// 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 });
|
const u = new URLSearchParams({ scope });
|
||||||
if (lang === "hu") u.set("lang", "hu");
|
if (lang === "hu") u.set("lang", "hu");
|
||||||
|
if (filters) appendPlexFilters(u, filters);
|
||||||
return req(`/api/plex/discover?${u.toString()}`);
|
return req(`/api/plex/discover?${u.toString()}`);
|
||||||
},
|
},
|
||||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch).
|
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
|
||||||
plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => {
|
// 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 });
|
const u = new URLSearchParams({ scope });
|
||||||
if (limit) u.set("limit", String(limit));
|
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()}`);
|
return req(`/api/plex/surprise?${u.toString()}`);
|
||||||
},
|
},
|
||||||
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ export const qk = {
|
|||||||
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
|
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
|
||||||
// refetches it alone.
|
// refetches it alone.
|
||||||
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
|
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
|
||||||
plexSurprise: (scope?: string) =>
|
// Variadic like plexDiscover so it can key by scope + the active filters; `plexSurprise()` (no args)
|
||||||
scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const),
|
// stays a prefix for the reshuffle/watched-toggle invalidations.
|
||||||
|
plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest],
|
||||||
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...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
|
// 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.
|
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
|
||||||
|
|||||||
Reference in New Issue
Block a user