From f81e67282f9521f80ad3a71ee436348a5cc63078 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 22:33:18 +0200 Subject: [PATCH 1/8] Move "data" field validation/fallback for consistency. --- tidalapi/playlist.py | 6 ++++-- tidalapi/session.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 6ba76843..daad6913 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -88,12 +88,14 @@ def __init__(self, session: "Session", playlist_id: Optional[str]): self._etag = request.headers["etag"] self.parse(request.json()) - def parse(self, json_obj: JsonObj) -> "Playlist": + def parse(self, obj: JsonObj) -> "Playlist": """Parses a playlist from tidal, replaces the current playlist object. - :param json_obj: Json data returned from api.tidal.com containing a playlist + :param obj: Json data returned from api.tidal.com containing a playlist :return: Returns a copy of the original :exc: 'Playlist': object """ + json_obj = obj.get("data", obj) + self.id = json_obj["uuid"] self.trn = f"trn:playlist:{self.id}" self.name = json_obj["title"] diff --git a/tidalapi/session.py b/tidalapi/session.py index c4b3e2fb..910b1104 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -361,7 +361,7 @@ def parse_v2_mix(self, obj: JsonObj) -> mix.Mix: def parse_playlist(self, obj: JsonObj) -> playlist.Playlist: """Parse a playlist from the given response.""" # Note: When parsing playlists from v2 response, "data" field must be parsed - return self.playlist().parse(obj.get("data", obj)) + return self.playlist().parse(obj) def parse_folder(self, obj: JsonObj) -> playlist.Folder: """Parse an album from the given response.""" From faa76d36660416fc33cfe4e7b6320705beb5b984 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 22:34:02 +0200 Subject: [PATCH 2/8] Playlists: Ensure only PLAYLIST types are returned when fetching from v2 endpoint --- tidalapi/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tidalapi/user.py b/tidalapi/user.py index 1f6c79ae..251a40cd 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -650,14 +650,14 @@ def playlists( "folderId": "root", "offset": offset, "limit": limit, - "includeOnly": "", + "includeOnly": "PLAYLIST", # Include only PLAYLIST types, FOLDER will be ignored } if order: params["order"] = order.value if order_direction: params["orderDirection"] = order_direction.value - endpoint = "my-collection/playlists/folders" + endpoint = "my-collection/playlists" return cast( List["Playlist"], self.session.request.map_request( From f86a2fc03d6761e1a9679670e3cb599888d5e8d6 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 22:34:33 +0200 Subject: [PATCH 3/8] Moved get playlist_folders() helper for consistency. --- tests/test_user.py | 4 +-- tidalapi/user.py | 78 ++++++++++++++++++++++++++-------------------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 8cd33636..c313452c 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -68,10 +68,10 @@ def test_get_user_playlists(session): def test_get_playlist_folders(session): folder = session.user.create_folder(title="testfolder") assert folder - folder_ids = [folder.id for folder in session.user.playlist_folders()] + folder_ids = [folder.id for folder in session.user.favorites.playlist_folders()] assert folder.id in folder_ids folder.remove() - folder_ids = [folder.id for folder in session.user.playlist_folders()] + folder_ids = [folder.id for folder in session.user.favorites.playlist_folders()] assert folder.id not in folder_ids diff --git a/tidalapi/user.py b/tidalapi/user.py index 251a40cd..8470fb6b 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -159,36 +159,6 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: ), ) - def playlist_folders( - self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root" - ) -> List["Folder"]: - """Get a list of folders created by the user. - - :param offset: The amount of items you want returned. - :param limit: The index of the first item you want included. - :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder - :return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders. - """ - params = { - "folderId": parent_folder_id, - "offset": offset, - "limit": limit, - "order": "NAME", - "includeOnly": "FOLDER", - } - 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, - ), - ) - def public_playlists( self, offset: int = 0, limit: int = 50 ) -> List[Union["Playlist", "UserPlaylist"]]: @@ -638,10 +608,10 @@ def playlists( order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: - """Get the users favorite playlists (v2 endpoint) + """Get the users favorite playlists (v2 endpoint), relative to the root folder - :param limit: Optional; The amount of playlists you want returned. - :param offset: The index of the first playlist you want included. + :param limit: Optional; The number of playlists you want returned (Note: Cannot exceed 50) + :param offset: The index of the first playlist to fetch :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. @@ -670,6 +640,48 @@ def playlists( ), ) + def playlist_folders( + self, + limit: Optional[int] = 50, + offset: int = 0, + order: Optional[PlaylistOrder] = None, + order_direction: Optional[OrderDirection] = None, + parent_folder_id: str = "root", + ) -> List["Folder"]: + """Get a list of folders created by the user. + + :param limit: Optional; The number of playlists you want returned (Note: Cannot exceed 50) + :param offset: The index of the first playlist folder to fetch + :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 + :return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders. + """ + params = { + "folderId": parent_folder_id, + "offset": offset, + "limit": limit, + "order": "NAME", + "includeOnly": "FOLDER", + } + 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, + ), + ) + def tracks( self, limit: Optional[int] = None, From 227a46413aba561c0185ef06cdd208dcf776044a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 23:14:08 +0200 Subject: [PATCH 4/8] Add workers from mopidy-tidal --- tidalapi/workers.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tidalapi/workers.py diff --git a/tidalapi/workers.py b/tidalapi/workers.py new file mode 100644 index 00000000..9b29fa30 --- /dev/null +++ b/tidalapi/workers.py @@ -0,0 +1,59 @@ +from concurrent.futures import ThreadPoolExecutor +from typing import Callable + + +def func_wrapper(args): + (f, offset, *args) = args + items = f(*args) + return list((i + offset, item) for i, item in enumerate(items)) + + +def get_items( + func: Callable, + *args, + parse: Callable = lambda _: _, + chunk_size: int = 50, + processes: int = 5, +): + """ + This function performs pagination on a function that supports + `limit`/`offset` parameters and it runs API requests in parallel to speed + things up. + """ + items = [] + offsets = [-chunk_size] + remaining = chunk_size * processes + + with ThreadPoolExecutor( + processes, thread_name_prefix=f"mopidy-tidal-{func.__name__}-" + ) as pool: + while remaining == chunk_size * processes: + offsets = [offsets[-1] + chunk_size * (i + 1) for i in range(processes)] + + pool_results = pool.map( + func_wrapper, + [ + ( + func, + offset, + *args, + chunk_size, # limit + offset, # offset + ) + for offset in offsets + ], + ) + + new_items = [] + for results in pool_results: + new_items.extend(results) + + remaining = len(new_items) + items.extend(new_items) + + items = [_ for _ in items if _] + sorted_items = list( + map(lambda item: item[1], sorted(items, key=lambda item: item[0])) + ) + + return list(map(parse, sorted_items)) From f8c151f069792e7a8a74606aff5b58a943859146 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 23:14:26 +0200 Subject: [PATCH 5/8] Rearrange args (order, order_direction should be last) --- tidalapi/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tidalapi/workers.py b/tidalapi/workers.py index 9b29fa30..b5a12e21 100644 --- a/tidalapi/workers.py +++ b/tidalapi/workers.py @@ -36,9 +36,9 @@ def get_items( ( func, offset, - *args, chunk_size, # limit offset, # offset + *args, # extra args (e.g. order, order_direction) ) for offset in offsets ], From ec3b40d33f746799333b881fc5430ae4bcfa9e2a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 23:14:49 +0200 Subject: [PATCH 6/8] Add paginated getters for artists, albums, playlists and tracks --- tidalapi/user.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tidalapi/user.py b/tidalapi/user.py index 8470fb6b..298db28e 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -37,6 +37,7 @@ PlaylistOrder, VideoOrder, ) +from tidalapi.workers import get_items if TYPE_CHECKING: from tidalapi.album import Album @@ -543,6 +544,19 @@ def remove_folders_playlists( ) return response.ok + def artists_paginated( + self, + order: Optional[ArtistOrder] = None, + order_direction: Optional[OrderDirection] = None, + ) -> List["Artist"]: + """Get the users favorite artists, using pagination + + :param order: Optional; A :class:`ArtistOrder` describing the ordering type when returning the user favorite artists. 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.artist.Artist` objects containing the favorite artists. + """ + return get_items(self.session.user.favorites.artists, order, order_direction) + def artists( self, limit: Optional[int] = None, @@ -573,6 +587,19 @@ def artists( ), ) + def albums_paginated( + self, + order: Optional[AlbumOrder] = None, + order_direction: Optional[OrderDirection] = None, + ) -> List["Album"]: + """Get the users favorite albums, using pagination + + :param order: Optional; A :class:`AlbumOrder` describing the ordering type when returning the user favorite albums. 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.album.Album` objects containing the favorite albums. + """ + return get_items(self.session.user.favorites.albums, order, order_direction) + def albums( self, limit: Optional[int] = None, @@ -601,6 +628,19 @@ def albums( ), ) + def playlists_paginated( + self, + order: Optional[PlaylistOrder] = None, + order_direction: Optional[OrderDirection] = None, + ) -> List["Playlist"]: + """Get the users favorite playlists relative to the root folder, using 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" + :return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists. + """ + return get_items(self.session.user.favorites.playlists, order, order_direction) + def playlists( self, limit: Optional[int] = 50, @@ -609,6 +649,7 @@ def playlists( order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: """Get the users favorite playlists (v2 endpoint), relative to the root folder + This function is limited to 50 by TIDAL, requiring pagination. :param limit: Optional; The number of playlists you want returned (Note: Cannot exceed 50) :param offset: The index of the first playlist to fetch @@ -682,6 +723,19 @@ def playlist_folders( ), ) + def tracks_paginated( + self, + order: Optional[ItemOrder] = None, + order_direction: Optional[OrderDirection] = None, + ) -> List["Playlist"]: + """Get the users favorite playlists relative to the root folder, using pagination + + :param order: Optional; A :class:`ItemOrder` describing the ordering type when returning the user favorite tracks. 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 tracks. + """ + return get_items(self.session.user.favorites.tracks, order, order_direction) + def tracks( self, limit: Optional[int] = None, From ba20831cc7f8ce0b2f3a967be1e1eb00a9678fca Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 29 Jul 2025 23:24:11 +0200 Subject: [PATCH 7/8] Formatting Formatting fixes --- tidalapi/user.py | 10 ++++++---- tidalapi/workers.py | 7 ++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tidalapi/user.py b/tidalapi/user.py index 298db28e..1aa380d6 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -549,7 +549,7 @@ def artists_paginated( order: Optional[ArtistOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Artist"]: - """Get the users favorite artists, using pagination + """Get the users favorite artists, using pagination. :param order: Optional; A :class:`ArtistOrder` describing the ordering type when returning the user favorite artists. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" @@ -592,7 +592,7 @@ def albums_paginated( order: Optional[AlbumOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Album"]: - """Get the users favorite albums, using pagination + """Get the users favorite albums, using pagination. :param order: Optional; A :class:`AlbumOrder` describing the ordering type when returning the user favorite albums. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" @@ -633,7 +633,8 @@ def playlists_paginated( order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: - """Get the users favorite playlists relative to the root folder, using pagination + """Get the users favorite playlists relative to the root folder, using + 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" @@ -728,7 +729,8 @@ def tracks_paginated( order: Optional[ItemOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: - """Get the users favorite playlists relative to the root folder, using pagination + """Get the users favorite playlists relative to the root folder, using + pagination. :param order: Optional; A :class:`ItemOrder` describing the ordering type when returning the user favorite tracks. eg.: "NAME, "DATE" :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" diff --git a/tidalapi/workers.py b/tidalapi/workers.py index b5a12e21..0836012b 100644 --- a/tidalapi/workers.py +++ b/tidalapi/workers.py @@ -15,11 +15,8 @@ def get_items( chunk_size: int = 50, processes: int = 5, ): - """ - This function performs pagination on a function that supports - `limit`/`offset` parameters and it runs API requests in parallel to speed - things up. - """ + """This function performs pagination on a function that supports `limit`/`offset` + parameters and it runs API requests in parallel to speed things up.""" items = [] offsets = [-chunk_size] remaining = chunk_size * processes From 07cedb77d18baa05aabb8beec7b57d15e7937584 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 30 Jul 2025 23:26:02 +0200 Subject: [PATCH 8/8] Apply suggestions from code review Improve robustness, reduce number of workers to 2. Co-authored-by: Fabio Manganiello Formatting --- tidalapi/workers.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tidalapi/workers.py b/tidalapi/workers.py index 0836012b..657748b9 100644 --- a/tidalapi/workers.py +++ b/tidalapi/workers.py @@ -1,10 +1,18 @@ +import logging from concurrent.futures import ThreadPoolExecutor from typing import Callable +log = logging.getLogger(__name__) + def func_wrapper(args): (f, offset, *args) = args - items = f(*args) + try: + items = f(*args) + except Exception as e: + log.error("Failed to run %s(offset=%d, args=%s)", f, offset, args) + log.exception(e) + items = [] return list((i + offset, item) for i, item in enumerate(items)) @@ -13,7 +21,7 @@ def get_items( *args, parse: Callable = lambda _: _, chunk_size: int = 50, - processes: int = 5, + processes: int = 2, ): """This function performs pagination on a function that supports `limit`/`offset` parameters and it runs API requests in parallel to speed things up."""