From 5f42ff61c832babb35ba451be84e0962acdd38bd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 00:06:11 +0200 Subject: [PATCH] feat(plex): a Surprise reshuffle deals the previous draw last The rail draws three unseen titles per kind under the sidebar filters; on a small filtered pool every reshuffle dealt the same few again. The redraw now sends the draw on screen (exclude=), the server deals the rest of the pool first and tops up from the excluded titles only when the pool is short, so a small pool still fills the rail. (The two discover.py baseline entries went with the rewritten draw.) --- backend/.basedpyright/baseline.json | 16 ---- backend/app/routes/plex/discover.py | 80 +++++++++++-------- backend/tests/test_plex_discover.py | 15 ++++ .../src/components/PlexDiscover.dom.test.tsx | 3 + frontend/src/components/PlexDiscover.tsx | 15 +++- frontend/src/lib/api/plex.ts | 4 +- 6 files changed, 82 insertions(+), 51 deletions(-) diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 1987e095..a49c0c91 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -1134,22 +1134,6 @@ "endColumn": 48, "lineCount": 1 } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 39, - "endColumn": 45, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 38, - "endColumn": 43, - "lineCount": 1 - } } ], "./app/routes/plex/playback.py": [ diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 1201c01c..0875d6d5 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -402,11 +402,31 @@ def discover( } +def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list: + """`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a + reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave + short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail.""" + fresh = ( + db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per)) + .scalars() + .all() + if exclude + else [] + ) + if len(fresh) == per: + return list(fresh) + taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else [] + rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars() + return [*fresh, *rest.all()] + + @router.get("/surprise") def surprise( scope: str = "both", lang: str = "en", limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40), + # The previous draw's rating keys: a reshuffle deals other titles first. + exclude: list[str] = Query(default=[], max_length=40), f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), @@ -432,23 +452,21 @@ def surprise( ) .exists() ) - movies = ( - db.execute( - _movie_meta( - select(PlexItem).where( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_ids), - ~watched_movie, - ), - p, - lang, - f, - ) - .order_by(func.random()) - .limit(per) - ) - .scalars() - .all() + movies = _draw( + db, + _movie_meta( + select(PlexItem).where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_ids), + ~watched_movie, + ), + p, + lang, + f, + ), + PlexItem.rating_key, + per, + exclude, ) shows = [] if show_ids: @@ -458,21 +476,19 @@ def surprise( not_fully_watched = or_( prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total ) - shows = ( - db.execute( - apply_meta_filters( - select(PlexShow) - .outerjoin(prog, prog.c.show_id == PlexShow.id) - .where(PlexShow.library_id.in_(show_ids), not_fully_watched), - PlexShow, - p, - lang, - ) - .order_by(func.random()) - .limit(per) - ) - .scalars() - .all() + shows = _draw( + db, + apply_meta_filters( + select(PlexShow) + .outerjoin(prog, prog.c.show_id == PlexShow.id) + .where(PlexShow.library_id.in_(show_ids), not_fully_watched), + PlexShow, + p, + lang, + ), + PlexShow.rating_key, + per, + exclude, ) mcards = _movie_cards(db, user.id, movies, lang) scards = _show_cards(db, user.id, shows, lang) diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index e3b4c52b..d80ba1a0 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -316,6 +316,21 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_d assert len([c for c in items if c["type"] == "show"]) <= 3 +def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep): + # The ↻ sends the previous draw: what is left of the pool comes first, and a pool smaller than a + # draw is topped up from the excluded titles rather than leaving the rail short. + user = _seed(db) + + def movies(url: str) -> list[str]: + items = _get(db, user, monkeypatch, override_dep, url).json()["items"] + return [c["id"] for c in items if c["type"] == "movie"] + + for _ in range(5): # random order: one lucky pass would prove nothing + got = movies("/api/plex/surprise?scope=movie&exclude=m2") + assert got[0] == "m3" and set(got) == {"m2", "m3"} + assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"} + + def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep): user = _seed(db) d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json() diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 4dd6537a..f0a4fdca 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -218,6 +218,9 @@ describe("PlexDiscover", () => { fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" })); await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2)); expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched + // The first draw asks for anything; the redraw hands over the one on screen to deal it last. + expect(plexSurprise.mock.calls[0]?.[4]).toEqual([]); + expect(plexSurprise.mock.calls[1]?.[4]).toEqual(["sp1"]); }); it("shows the empty message when every rail is empty", async () => { diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 00d6c2d8..d6e93936 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -119,9 +119,20 @@ export default function PlexDiscover({ queryFn: () => api.plexDiscover(scope, lang, filters), }); // Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface. + // A redraw hands over the draw on screen, so a small filtered pool deals its other titles first. + const surpriseKey = qk.plexSurprise(scope, lang, filters); const surpriseQ = useQuery({ - queryKey: qk.plexSurprise(scope, lang, filters), - queryFn: () => api.plexSurprise(scope, undefined, filters, lang), + queryKey: surpriseKey, + queryFn: () => { + const shown = qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []; + return api.plexSurprise( + scope, + undefined, + filters, + lang, + shown.map((c) => c.id), + ); + }, staleTime: 0, }); diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index eddb7988..e538ad6a 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -625,15 +625,17 @@ export const plexApi = { return req(`/api/plex/discover?${u.toString()}`); }, // 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. + // the active filters like the other rails. `exclude` is the previous draw: dealt last, not first. plexSurprise: ( scope: string, limit?: number, filters?: PlexFilters, lang?: string, + exclude: string[] = [], ): Promise<{ scope: string; items: PlexCard[] }> => { const u = new URLSearchParams({ scope }); if (limit) u.set("limit", String(limit)); + for (const id of exclude) u.append("exclude", id); if (lang === "hu") u.set("lang", "hu"); if (filters) appendPlexFilters(u, filters); return req(`/api/plex/surprise?${u.toString()}`);