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:
@@ -1134,22 +1134,6 @@
|
|||||||
"endColumn": 48,
|
"endColumn": 48,
|
||||||
"lineCount": 1
|
"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": [
|
"./app/routes/plex/playback.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")
|
@router.get("/surprise")
|
||||||
def surprise(
|
def surprise(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
lang: str = "en",
|
lang: str = "en",
|
||||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
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(),
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -432,23 +452,21 @@ def surprise(
|
|||||||
)
|
)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
movies = (
|
movies = _draw(
|
||||||
db.execute(
|
db,
|
||||||
_movie_meta(
|
_movie_meta(
|
||||||
select(PlexItem).where(
|
select(PlexItem).where(
|
||||||
PlexItem.kind == "movie",
|
PlexItem.kind == "movie",
|
||||||
PlexItem.library_id.in_(movie_ids),
|
PlexItem.library_id.in_(movie_ids),
|
||||||
~watched_movie,
|
~watched_movie,
|
||||||
),
|
),
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
f,
|
f,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexItem.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
shows = []
|
shows = []
|
||||||
if show_ids:
|
if show_ids:
|
||||||
@@ -458,21 +476,19 @@ def surprise(
|
|||||||
not_fully_watched = or_(
|
not_fully_watched = or_(
|
||||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||||
)
|
)
|
||||||
shows = (
|
shows = _draw(
|
||||||
db.execute(
|
db,
|
||||||
apply_meta_filters(
|
apply_meta_filters(
|
||||||
select(PlexShow)
|
select(PlexShow)
|
||||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||||
PlexShow,
|
PlexShow,
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexShow.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
mcards = _movie_cards(db, user.id, movies, lang)
|
mcards = _movie_cards(db, user.id, movies, lang)
|
||||||
scards = _show_cards(db, user.id, shows, lang)
|
scards = _show_cards(db, user.id, shows, lang)
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||||
user = _seed(db)
|
user = _seed(db)
|
||||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||||
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
||||||
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
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 () => {
|
it("shows the empty message when every rail is empty", async () => {
|
||||||
|
|||||||
@@ -119,9 +119,20 @@ export default function PlexDiscover({
|
|||||||
queryFn: () => api.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.
|
// 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({
|
const surpriseQ = useQuery({
|
||||||
queryKey: qk.plexSurprise(scope, lang, filters),
|
queryKey: surpriseKey,
|
||||||
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
queryFn: () => {
|
||||||
|
const shown = qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? [];
|
||||||
|
return api.plexSurprise(
|
||||||
|
scope,
|
||||||
|
undefined,
|
||||||
|
filters,
|
||||||
|
lang,
|
||||||
|
shown.map((c) => c.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -625,15 +625,17 @@ export const plexApi = {
|
|||||||
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), narrowed by
|
// 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: (
|
plexSurprise: (
|
||||||
scope: string,
|
scope: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
filters?: PlexFilters,
|
filters?: PlexFilters,
|
||||||
lang?: string,
|
lang?: string,
|
||||||
|
exclude: string[] = [],
|
||||||
): Promise<{ scope: string; items: PlexCard[] }> => {
|
): 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));
|
||||||
|
for (const id of exclude) u.append("exclude", id);
|
||||||
if (lang === "hu") u.set("lang", "hu");
|
if (lang === "hu") u.set("lang", "hu");
|
||||||
if (filters) appendPlexFilters(u, filters);
|
if (filters) appendPlexFilters(u, filters);
|
||||||
return req(`/api/plex/surprise?${u.toString()}`);
|
return req(`/api/plex/surprise?${u.toString()}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user