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.)
This commit is contained in:
2026-10-09 00:06:11 +02:00
parent 6dd82ef5e2
commit 5f42ff61c8
6 changed files with 82 additions and 51 deletions
-16
View File
@@ -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": [
+48 -32
View File
@@ -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)
+15
View File
@@ -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()
@@ -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 () => {
+13 -2
View File
@@ -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,
});
+3 -1
View File
@@ -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()}`);