From 1d2c280964b9c3b79cc1a5397d1d4cee70287663 Mon Sep 17 00:00:00 2001 From: "Bradon Kanyid (rattboi)" Date: Mon, 13 Apr 2026 05:28:19 +0000 Subject: [PATCH] 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. --- tests/test_user.py | 8 +++++ tidalapi/user.py | 76 ++++++++++++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 3733d3c..c38a3da 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -65,6 +65,14 @@ def test_get_user_playlists(session): assert playlist_ids | favourite_ids == both_ids +def test_get_user_playlists_paginated(session): + expected_count = session.user.favorites.get_playlists_count() + all_playlists = session.user.favorites.playlists_paginated() + assert len(all_playlists) == expected_count + unique_ids = set(x.id for x in all_playlists) + assert len(unique_ids) == expected_count + + def test_get_playlist_folders(session): folder = session.user.create_folder(title="testfolder") assert folder diff --git a/tidalapi/user.py b/tidalapi/user.py index 1e534e8..2a07335 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -671,16 +671,31 @@ def playlists_paginated( order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: - """Get the users favorite playlists, using pagination. + """Get the users favorite playlists, using cursor-based pagination. + + The v2 my-collection/playlists/folders endpoint uses cursor-based + pagination. Each response includes a ``cursor`` field that must be + passed to the next request to retrieve the following page. :param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" :return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists. """ - count = self.session.user.favorites.get_playlists_count() - return get_items( - self.session.user.favorites.playlists, count, order, order_direction - ) + playlists: List["Playlist"] = [] + cursor: Optional[str] = None + + while True: + items = self.playlists( + cursor=cursor, + order=order, + order_direction=order_direction, + ) + playlists.extend(items) + cursor = self._last_playlists_cursor + if not cursor or not items: + break + + return playlists def playlists( self, @@ -688,14 +703,18 @@ def playlists( offset: int = 0, order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, + cursor: Optional[str] = None, ) -> List["Playlist"]: - """Get the users favorite playlists (v2 endpoint), relative to the root folder + """Get the users favorite playlists (v2 endpoint), relative to the root folder. This function is limited to 50 by TIDAL, requiring pagination. :param limit: The number of playlists you want returned (Note: Cannot exceed 50) - :param offset: The index of the first playlist to fetch + :param offset: The index of the first playlist to fetch. Note: this parameter is + ignored by the TIDAL API for this endpoint. Use ``cursor`` for pagination. :param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" + :param cursor: Cursor for fetching the next page of results. Obtained from + :attr:`_last_playlists_cursor` after a previous call to this method. :return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists. """ params = { @@ -704,6 +723,8 @@ def playlists( "limit": limit, "includeOnly": "PLAYLIST", # Include only PLAYLIST types, FOLDER will be ignored } + if cursor: + params["cursor"] = cursor if order: params["order"] = order.value else: @@ -714,17 +735,13 @@ def playlists( params["orderDirection"] = OrderDirection.Descending.value endpoint = "my-collection/playlists/folders" - return cast( - List["Playlist"], - self.session.request.map_request( - url=urljoin( - self.session.config.api_v2_location, - endpoint, - ), - params=params, - parse=self.session.parse_playlist, - ), + url = urljoin(self.session.config.api_v2_location, endpoint) + json_obj = self.session.request.request("GET", url, params).json() + items = self.session.request.map_json( + json_obj, parse=self.session.parse_playlist ) + self._last_playlists_cursor = json_obj.get("cursor") + return cast(List["Playlist"], items) def playlist_folders( self, @@ -733,14 +750,19 @@ def playlist_folders( order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, parent_folder_id: str = "root", + cursor: Optional[str] = None, ) -> List["Folder"]: """Get a list of folders created by the user. :param limit: The number of playlists you want returned (Note: Cannot exceed 50) - :param offset: The index of the first playlist folder to fetch + :param offset: The index of the first playlist folder to fetch. Note: this + parameter is ignored by the TIDAL API for this endpoint. Use ``cursor`` + for pagination. :param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder + :param cursor: Cursor for fetching the next page of results. Obtained from + :attr:`_last_folders_cursor` after a previous call to this method. :return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders. """ params = { @@ -750,23 +772,19 @@ def playlist_folders( "order": "NAME", "includeOnly": "FOLDER", } + if cursor: + params["cursor"] = cursor if order: params["order"] = order.value if order_direction: params["orderDirection"] = order_direction.value endpoint = "my-collection/playlists/folders" - return cast( - List["Folder"], - self.session.request.map_request( - url=urljoin( - self.session.config.api_v2_location, - endpoint, - ), - params=params, - parse=self.session.parse_folder, - ), - ) + url = urljoin(self.session.config.api_v2_location, endpoint) + json_obj = self.session.request.request("GET", url, params).json() + items = self.session.request.map_json(json_obj, parse=self.session.parse_folder) + self._last_folders_cursor = json_obj.get("cursor") + return cast(List["Folder"], items) def get_playlists_count(self) -> int: """Get the total number of playlists in the user's root collection.