Skip to content

Commit 21ac312

Browse files
authored
Aligned search with new pageinated api (#85) (#89)
* Aligned search with new pageinated api: - Added endpoint to search for items and remove old endpoint - Added tests for new endpoint - Removed old frontend components, now using unified list component for albums and tracks * Preserve state when going back to search * Fix typing
1 parent 244f77a commit 21ac312

File tree

7 files changed

+606
-359
lines changed

7 files changed

+606
-359
lines changed

backend/beets_flask/server/routes/library/resources.py

Lines changed: 133 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Any,
1717
Awaitable,
1818
Callable,
19+
Literal,
1920
ParamSpec,
2021
Sequence,
2122
TypedDict,
@@ -158,20 +159,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response:
158159
return make_response
159160

160161

161-
@resource_bp.route("/item/<int:id>", methods=["GET", "DELETE", "PATCH"])
162-
@resource(Item, patchable=True)
163-
async def item(id: int):
164-
item = g.lib.get_item(id)
165-
if not item:
166-
raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.")
167-
168-
return item
169-
170-
171-
@resource_bp.route("/item/query/<path:query>", methods=["GET", "DELETE", "PATCH"])
172-
@resource_query(Item, patchable=True)
173-
async def item_query(query: str):
174-
return g.lib.items(query)
162+
# ---------------------------------- Albums ---------------------------------- #
175163

176164

177165
@resource_bp.route("/album/<int:id>", methods=["GET", "DELETE", "PATCH"])
@@ -183,57 +171,6 @@ async def album(id: int):
183171
return item
184172

185173

186-
@resource_bp.route("/album/query/<path:query>", methods=["GET", "DELETE", "PATCH"])
187-
@resource_query(Album, patchable=False)
188-
async def album_query(query: str):
189-
return g.lib.albums(query)
190-
191-
192-
# Artists are handled slightly differently, as they are not a beets model but can be
193-
# derived from the items.
194-
@resource_bp.route("/artist/<path:artist_name>/albums", methods=["GET"])
195-
async def albums_by_artist(artist_name: str):
196-
"""Get all items for a specific artist."""
197-
log.debug(f"Album query for artist '{artist_name}'")
198-
199-
with g.lib.transaction() as tx:
200-
rows = tx.query(
201-
f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0",
202-
(artist_name,),
203-
)
204-
205-
expanded = expanded_response()
206-
minimal = minimal_response()
207-
208-
return jsonify(
209-
[
210-
_rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal)
211-
for row in rows
212-
]
213-
)
214-
215-
216-
# Items by artist are handled slightly differently, as they are not a beets model but can be
217-
# derived from the items.
218-
@resource_bp.route("/artist/<path:artist_name>/items", methods=["GET"])
219-
async def items_by_artist(artist_name: str):
220-
"""Get all items for a specific artist."""
221-
log.debug(f"Item query for artist '{artist_name}'")
222-
223-
with g.lib.transaction() as tx:
224-
rows = tx.query(
225-
f"SELECT id FROM items WHERE instr(artist, ?) > 0",
226-
(artist_name,),
227-
)
228-
229-
expanded = expanded_response()
230-
minimal = minimal_response()
231-
232-
return jsonify(
233-
[_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows]
234-
)
235-
236-
237174
@resource_bp.route("/album/bf_id/<string:bf_id>", methods=["GET"])
238175
@resource(Album, patchable=False)
239176
async def album_by_bf_id(bf_id: str):
@@ -292,7 +229,6 @@ async def all_albums(query: str = ""):
292229

293230
sub_query = parse_query_string(query, Album)
294231

295-
start = time.perf_counter()
296232
paginated_query = PaginatedQuery(
297233
cursor=cursor,
298234
sub_query=sub_query,
@@ -322,6 +258,128 @@ async def all_albums(query: str = ""):
322258
)
323259

324260

261+
# Artists are handled slightly differently, as they are not a beets model but can be
262+
# derived from the items.
263+
@resource_bp.route("/artist/<path:artist_name>/albums", methods=["GET"])
264+
async def albums_by_artist(artist_name: str):
265+
"""Get all items for a specific artist."""
266+
log.debug(f"Album query for artist '{artist_name}'")
267+
268+
with g.lib.transaction() as tx:
269+
rows = tx.query(
270+
f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0",
271+
(artist_name,),
272+
)
273+
274+
expanded = expanded_response()
275+
minimal = minimal_response()
276+
277+
return jsonify(
278+
[
279+
_rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal)
280+
for row in rows
281+
]
282+
)
283+
284+
285+
# ----------------------------------- Items ---------------------------------- #
286+
287+
288+
@resource_bp.route("/item/<int:id>", methods=["GET", "DELETE", "PATCH"])
289+
@resource(Item, patchable=True)
290+
async def item(id: int):
291+
item = g.lib.get_item(id)
292+
if not item:
293+
raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.")
294+
295+
return item
296+
297+
298+
@resource_bp.route("/items", methods=["GET"], defaults={"query": ""})
299+
@resource_bp.route("/items/<path:query>", methods=["GET"])
300+
async def all_items(query: str = ""):
301+
"""Get all items in the library.
302+
303+
If a query is provided, it will be used to filter the items.
304+
"""
305+
log.debug(f"Item query: {query}")
306+
params = dict(request.args)
307+
cursor = pop_query_param(params, "cursor", Cursor.from_string, None)
308+
if cursor is None:
309+
order_by_column = pop_query_param(params, "order_by", str, "added")
310+
order_by_direction = pop_query_param(params, "order_dir", str, "DESC")
311+
cursor = Cursor(
312+
order_by_column=order_by_column,
313+
order_by_direction=order_by_direction,
314+
last_order_by_value=None,
315+
last_id=None,
316+
)
317+
318+
n_items = pop_query_param(
319+
params,
320+
"n_items",
321+
int,
322+
50, # Default number of items per page
323+
)
324+
325+
if len(params) > 0:
326+
raise InvalidUsageException(
327+
"Unexpected query parameters: , ".join(params.keys())
328+
)
329+
330+
sub_query = parse_query_string(query, Item)
331+
332+
paginated_query = PaginatedQuery(
333+
cursor=cursor,
334+
sub_query=sub_query,
335+
n_items=n_items,
336+
table="items",
337+
)
338+
items = list(g.lib.items(paginated_query, paginated_query))
339+
340+
# Update cursor
341+
next_url: str | None = None
342+
343+
total = paginated_query.total(g.lib)
344+
if len(items) == n_items and len(items) > 0:
345+
last_item = items[-1]
346+
347+
cursor.last_order_by_value = str(
348+
getattr(last_item, cursor.order_by_column, None)
349+
)
350+
cursor.last_id = str(last_item.id)
351+
next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}"
352+
353+
return jsonify(
354+
{
355+
"items": [_rep(item, expand=False, minimal=True) for item in items],
356+
"next": next_url,
357+
"total": total,
358+
}
359+
)
360+
361+
362+
# Items by artist are handled slightly differently, as they are not a beets model but can be
363+
# derived from the items.
364+
@resource_bp.route("/artist/<path:artist_name>/items", methods=["GET"])
365+
async def items_by_artist(artist_name: str):
366+
"""Get all items for a specific artist."""
367+
log.debug(f"Item query for artist '{artist_name}'")
368+
369+
with g.lib.transaction() as tx:
370+
rows = tx.query(
371+
f"SELECT id FROM items WHERE instr(artist, ?) > 0",
372+
(artist_name,),
373+
)
374+
375+
expanded = expanded_response()
376+
minimal = minimal_response()
377+
378+
return jsonify(
379+
[_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows]
380+
)
381+
382+
325383
# ----------------------------------- Util ----------------------------------- #
326384

327385

@@ -429,13 +487,20 @@ class PaginatedQuery(Query, Sort):
429487

430488
_sub_query: tuple[Query, Sort] | None
431489

490+
table: Literal["albums", "items"]
491+
432492
def __init__(
433-
self, cursor: Cursor, sub_query: tuple[Query, Sort], n_items=50
493+
self,
494+
cursor: Cursor,
495+
sub_query: tuple[Query, Sort],
496+
n_items=50,
497+
table: Literal["albums", "items"] = "albums",
434498
) -> None:
435499
super().__init__()
436500
self.n_items = n_items
437501
self.cursor = cursor
438502
self._sub_query = sub_query
503+
self.table = table
439504

440505
def clause(self) -> tuple[str | None, Sequence[Any]]:
441506
"""Return the SQL clause and values for the query."""
@@ -472,7 +537,7 @@ def total(self, lib: Library) -> int:
472537
vs = ()
473538

474539
with g.lib.transaction() as tx:
475-
count = tx.query(f"SELECT COUNT(*) FROM albums WHERE {cs}", vs)[0][0]
540+
count = tx.query(f"SELECT COUNT(*) FROM {self.table} WHERE {cs}", vs)[0][0]
476541
return count
477542

478543

backend/tests/integration/test_routes/test_library.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ async def test_get_artist(self, client: Client):
108108
# ----------------------------------- album ---------------------------------- #
109109

110110

111-
class TestAlbumsEndpoints(IsolatedBeetsLibraryMixin):
111+
class TestAlbumEndpoints(IsolatedBeetsLibraryMixin):
112112
"""Test class for the Albums endpoint in the API.
113113
114114
This class contains tests for retrieving albums and individual album details
@@ -264,7 +264,7 @@ async def test_with_query(
264264
# ----------------------------------- Items ---------------------------------- #
265265

266266

267-
class TestItemsEndpoint(IsolatedBeetsLibraryMixin):
267+
class TestItemEndpoint(IsolatedBeetsLibraryMixin):
268268
"""Test class for the Items endpoint in the API.
269269
270270
This class contains tests for retrieving items and individual item details
@@ -293,6 +293,40 @@ async def test_get_item(self, client: Client):
293293
assert data["id"] == item.id, "Data id does not match item id"
294294

295295

296+
class TestItemsPagination(IsolatedBeetsLibraryMixin):
297+
"""Test if pagination of items works as expected"""
298+
299+
@pytest.fixture(autouse=True)
300+
def items(self): # type: ignore
301+
"""Fixture to add items to the beets library before running tests."""
302+
nItems = 100
303+
if len(self.beets_lib.items()) == 0:
304+
for i in range(nItems):
305+
artist = "Even" if i % 2 == 0 else f"Odd"
306+
self.beets_lib.add(
307+
beets_lib_item(artist=f"{artist}", album=f"Album {i}")
308+
)
309+
310+
assert len(self.beets_lib.items()) == nItems
311+
312+
async def test_get_items(self, client: Client):
313+
"""Test the GET request to retrieve all items with pagination.
314+
315+
Asserts:
316+
- The response status code is 200.
317+
- The returned data contains the expected number of items.
318+
- The next cursor is provided for pagination.
319+
"""
320+
response = await client.get("/api_v1/library/items/?n_items=10")
321+
data = await response.get_json()
322+
assert response.status_code == 200, "Response status code is not 200"
323+
assert "items" in data, "Items are not provided in the response"
324+
assert len(data["items"]) == 10, "Data length is not 10"
325+
assert "next" in data, "Next cursor is not provided"
326+
assert "total" in data, "Total count is not provided"
327+
assert data["total"] == 100, "Total count does not match expected value"
328+
329+
296330
# ---------------------------------------------------------------------------- #
297331
# Test art #
298332
# ---------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)