Skip to content

Commit 1d2c280

Browse files
committed
fix: opt-in cursor-based pagination for playlists and folders
The v2 my-collection/playlists/folders endpoint uses cursor-based pagination, but playlists() and playlist_folders() were using offset-based requests via map_request(). This caused all pages to return the same first 50 results, since the offset parameter is silently ignored by the API. Note: I believe this means some of the existing tests that try to use offset-based calls to playlist() are inaccurately passing in sitations they shouldn't. I left those tests as-is for now, but added one specifically to test cursor-based pagination. I added a new defaulted parameter at the end of the function arguments in order to try to not break any API interfaces. Unfortunately, that meant hiding a little state in the class to remember the previous cursor from pagination call to call. Switched to raw request + map_json to preserve the cursor from the JSON response. Add an optional cursor parameter to playlists() and playlist_folders(). Rewrite playlists_paginated() to loop using the cursor instead of parallel offset-based fetching via get_items(). Unfortunately because each call returns the next cursor, I don't believe parallel fetch is possible anymore.
1 parent 899e6b3 commit 1d2c280

2 files changed

Lines changed: 55 additions & 29 deletions

File tree

tests/test_user.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ def test_get_user_playlists(session):
6565
assert playlist_ids | favourite_ids == both_ids
6666

6767

68+
def test_get_user_playlists_paginated(session):
69+
expected_count = session.user.favorites.get_playlists_count()
70+
all_playlists = session.user.favorites.playlists_paginated()
71+
assert len(all_playlists) == expected_count
72+
unique_ids = set(x.id for x in all_playlists)
73+
assert len(unique_ids) == expected_count
74+
75+
6876
def test_get_playlist_folders(session):
6977
folder = session.user.create_folder(title="testfolder")
7078
assert folder

tidalapi/user.py

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -671,31 +671,50 @@ def playlists_paginated(
671671
order: Optional[PlaylistOrder] = None,
672672
order_direction: Optional[OrderDirection] = None,
673673
) -> List["Playlist"]:
674-
"""Get the users favorite playlists, using pagination.
674+
"""Get the users favorite playlists, using cursor-based pagination.
675+
676+
The v2 my-collection/playlists/folders endpoint uses cursor-based
677+
pagination. Each response includes a ``cursor`` field that must be
678+
passed to the next request to retrieve the following page.
675679
676680
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
677681
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
678682
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
679683
"""
680-
count = self.session.user.favorites.get_playlists_count()
681-
return get_items(
682-
self.session.user.favorites.playlists, count, order, order_direction
683-
)
684+
playlists: List["Playlist"] = []
685+
cursor: Optional[str] = None
686+
687+
while True:
688+
items = self.playlists(
689+
cursor=cursor,
690+
order=order,
691+
order_direction=order_direction,
692+
)
693+
playlists.extend(items)
694+
cursor = self._last_playlists_cursor
695+
if not cursor or not items:
696+
break
697+
698+
return playlists
684699

685700
def playlists(
686701
self,
687702
limit: int = 50,
688703
offset: int = 0,
689704
order: Optional[PlaylistOrder] = None,
690705
order_direction: Optional[OrderDirection] = None,
706+
cursor: Optional[str] = None,
691707
) -> List["Playlist"]:
692-
"""Get the users favorite playlists (v2 endpoint), relative to the root folder
708+
"""Get the users favorite playlists (v2 endpoint), relative to the root folder.
693709
This function is limited to 50 by TIDAL, requiring pagination.
694710
695711
:param limit: The number of playlists you want returned (Note: Cannot exceed 50)
696-
:param offset: The index of the first playlist to fetch
712+
:param offset: The index of the first playlist to fetch. Note: this parameter is
713+
ignored by the TIDAL API for this endpoint. Use ``cursor`` for pagination.
697714
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
698715
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
716+
:param cursor: Cursor for fetching the next page of results. Obtained from
717+
:attr:`_last_playlists_cursor` after a previous call to this method.
699718
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
700719
"""
701720
params = {
@@ -704,6 +723,8 @@ def playlists(
704723
"limit": limit,
705724
"includeOnly": "PLAYLIST", # Include only PLAYLIST types, FOLDER will be ignored
706725
}
726+
if cursor:
727+
params["cursor"] = cursor
707728
if order:
708729
params["order"] = order.value
709730
else:
@@ -714,17 +735,13 @@ def playlists(
714735
params["orderDirection"] = OrderDirection.Descending.value
715736

716737
endpoint = "my-collection/playlists/folders"
717-
return cast(
718-
List["Playlist"],
719-
self.session.request.map_request(
720-
url=urljoin(
721-
self.session.config.api_v2_location,
722-
endpoint,
723-
),
724-
params=params,
725-
parse=self.session.parse_playlist,
726-
),
738+
url = urljoin(self.session.config.api_v2_location, endpoint)
739+
json_obj = self.session.request.request("GET", url, params).json()
740+
items = self.session.request.map_json(
741+
json_obj, parse=self.session.parse_playlist
727742
)
743+
self._last_playlists_cursor = json_obj.get("cursor")
744+
return cast(List["Playlist"], items)
728745

729746
def playlist_folders(
730747
self,
@@ -733,14 +750,19 @@ def playlist_folders(
733750
order: Optional[PlaylistOrder] = None,
734751
order_direction: Optional[OrderDirection] = None,
735752
parent_folder_id: str = "root",
753+
cursor: Optional[str] = None,
736754
) -> List["Folder"]:
737755
"""Get a list of folders created by the user.
738756
739757
:param limit: The number of playlists you want returned (Note: Cannot exceed 50)
740-
:param offset: The index of the first playlist folder to fetch
758+
:param offset: The index of the first playlist folder to fetch. Note: this
759+
parameter is ignored by the TIDAL API for this endpoint. Use ``cursor``
760+
for pagination.
741761
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
742762
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
743763
:param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
764+
:param cursor: Cursor for fetching the next page of results. Obtained from
765+
:attr:`_last_folders_cursor` after a previous call to this method.
744766
:return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
745767
"""
746768
params = {
@@ -750,23 +772,19 @@ def playlist_folders(
750772
"order": "NAME",
751773
"includeOnly": "FOLDER",
752774
}
775+
if cursor:
776+
params["cursor"] = cursor
753777
if order:
754778
params["order"] = order.value
755779
if order_direction:
756780
params["orderDirection"] = order_direction.value
757781

758782
endpoint = "my-collection/playlists/folders"
759-
return cast(
760-
List["Folder"],
761-
self.session.request.map_request(
762-
url=urljoin(
763-
self.session.config.api_v2_location,
764-
endpoint,
765-
),
766-
params=params,
767-
parse=self.session.parse_folder,
768-
),
769-
)
783+
url = urljoin(self.session.config.api_v2_location, endpoint)
784+
json_obj = self.session.request.request("GET", url, params).json()
785+
items = self.session.request.map_json(json_obj, parse=self.session.parse_folder)
786+
self._last_folders_cursor = json_obj.get("cursor")
787+
return cast(List["Folder"], items)
770788

771789
def get_playlists_count(self) -> int:
772790
"""Get the total number of playlists in the user's root collection.

0 commit comments

Comments
 (0)