diff --git a/HISTORY.rst b/HISTORY.rst index f4980f5..0d54495 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,11 +2,18 @@ History ======= +v0.8.6 +------ +* Add support for getcount(), Workers: Use get_*_count to get the actual number of items. - tehkillerbee_ +* Only return warning if page itemtype (v2) is not implemented (Fixes: #362) - tehkillerbee_ +* Add legacy home endpoint for backwards compatibility - tehkillerbee_ +* Get playlist tracks, items count. Get playlist tracks paginated. - tehkillerbee_ + v0.8.5 ------ * Cleanup: Removed deprecated function(s). - tehkillerbee_ * Feature: MixV2: Add support for parsing mixes originating from PageCategoryV2. - tehkillerbee_ -* Feature: Add support for PageCategoryV2 as used on Home page. - tehkillerbee_, Nokse22_ +* Feature: Get home page using new v2 endpoint. Add support for PageCategoryV2 - tehkillerbee_, Nokse22_ * Feature: Add pagination workers from mopidy-tidal - tehkillerbee_, BlackLight_ * Fix(playlist): Improve v2 endpoint usage. - tehkillerbee_ * fix(playlist): More robust handling of the passed objects. - BlackLight_ diff --git a/docs/conf.py b/docs/conf.py index 75b6773..7cbf427 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "The tidalapi Developers" # The full version, including alpha/beta/rc tags -release = "0.8.5" +release = "0.8.6" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 3096903..bcd9084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [tool.poetry] name = "tidalapi" -version = "0.8.5" +version = "0.8.6" description = "Unofficial API for TIDAL music streaming service." authors = ["Thomas Amland "] maintainers = ["tehkillerbee "] license = "LGPL-3.0-or-later" readme = ["README.rst", "HISTORY.rst"] homepage = "https://tidalapi.netlify.app" -repository = "https://github.com/tamland/python-tidal" +repository = "https://github.com/EbbLabs/python-tidal" documentation = "https://tidalapi.netlify.app" classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/test_page.py b/tests/test_page.py index 3fb35ea..e3a3127 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -169,7 +169,7 @@ def test_genres(session): def test_moods(session): moods = session.moods() first = next(iter(moods)) - assert first.title == "Holidays" + assert first.title == "Holidays" or first.title == "For DJs" assert isinstance(next(iter(first.get())), tidalapi.Playlist) diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py index a292f13..58f9417 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -17,4 +17,4 @@ User, ) -__version__ = "0.8.5" +__version__ = "0.8.6" diff --git a/tidalapi/page.py b/tidalapi/page.py index 4bd3300..d3f09e9 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -18,6 +18,7 @@ """ import copy +import logging from dataclasses import dataclass from typing import ( TYPE_CHECKING, @@ -65,6 +66,8 @@ AllCategoriesV2 = Union[PageCategoriesV2] +log = logging.getLogger(__name__) + class Page: """ @@ -337,13 +340,16 @@ def __init__(self, session: "Session"): self.items: List[Any] = [] def parse(self, json_obj: "JsonObj"): - self.items = [self.get_item(item) for item in json_obj["items"]] + self.items = [ + self.get_item(item) for item in json_obj["items"] if item is not None + ] return self def get_item(self, json_obj: "JsonObj") -> Any: item_type = json_obj.get("type") if item_type not in self.item_types: - raise NotImplementedError(f"Item type '{item_type}' not implemented") + log.warning(f"Item type '{item_type}' not implemented") + return None return self.item_types[item_type](json_obj["data"]) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index daad691..c6922de 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -25,6 +25,7 @@ from tidalapi.exceptions import ObjectNotFound, TooManyRequests from tidalapi.types import ItemOrder, JsonObj, OrderDirection from tidalapi.user import LoggedInUser +from tidalapi.workers import get_items if TYPE_CHECKING: from tidalapi.artist import Artist @@ -161,6 +162,40 @@ def parse_factory(self, json_obj: JsonObj) -> "Playlist": self.parse(json_obj) return copy.copy(self.factory()) + def get_tracks_count( + self, + ) -> int: + """Get the total number of tracks in the playlist. + + This performs a minimal API request (limit=1) to fetch metadata about the tracks + without retrieving all of them. The API response contains 'totalNumberOfItems', + which represents the total items (tracks) available. + :return: The number of items available. + """ + params = {"limit": 1, "offset": 0} + + json_obj = self.request.map_request( + self._base_url % self.id + "/tracks", params=params + ) + return json_obj.get("totalNumberOfItems", 0) + + def get_items_count( + self, + ) -> int: + """Get the total number of items in the playlist. + + This performs a minimal API request (limit=1) to fetch metadata about the tracks + without retrieving all of them. The API response contains 'totalNumberOfItems', + which represents the total items (tracks) available. + :return: The number of items available. + """ + params = {"limit": 1, "offset": 0} + + json_obj = self.request.map_request( + self._base_url % self.id + "/items", params=params + ) + return json_obj.get("totalNumberOfItems", 0) + def tracks( self, limit: Optional[int] = None, @@ -195,6 +230,20 @@ def tracks( ) ) + def tracks_paginated( + self, + order: Optional[ItemOrder] = None, + order_direction: Optional[OrderDirection] = None, + ) -> List["Playlist"]: + """Get the tracks in the playlist, 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. + """ + count = self.get_tracks_count() + return get_items(self.tracks, count, order, order_direction) + def items( self, limit: int = 100, diff --git a/tidalapi/session.py b/tidalapi/session.py index 4dc2270..ab5aabc 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -1082,21 +1082,25 @@ def get_user( return user.User(session=self, user_id=user_id).factory() - def home(self) -> page.Page: + def home(self, use_legacy_endpoint: bool = False) -> page.Page: """ - Retrieves the Home page, as seen on https://listen.tidal.com + Retrieves the Home page, as seen on https://listen.tidal.com, using either the V2 or V1 (legacy) endpoint + :param use_legacy_endpoint: (Optional) Request Page from legacy endpoint. :return: A :class:`.Page` object with the :class:`.PageCategory` list from the home page """ - params = {"deviceType": "BROWSER", "locale": self.locale, "platform": "WEB"} - - json_obj = self.request.request( - "GET", - "home/feed/static", - base_url=self.config.api_v2_location, - params=params, - ).json() - return self.page.parse(json_obj) + if not use_legacy_endpoint: + params = {"deviceType": "BROWSER", "locale": self.locale, "platform": "WEB"} + + json_obj = self.request.request( + "GET", + "home/feed/static", + base_url=self.config.api_v2_location, + params=params, + ).json() + return self.page.parse(json_obj) + else: + return self.page.get("pages/home") def explore(self) -> page.Page: """ diff --git a/tidalapi/user.py b/tidalapi/user.py index 1aa380d..5249984 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -51,6 +51,8 @@ def list_validate(lst): if isinstance(lst, str): lst = [lst] + if isinstance(lst, int): + lst = [str(lst)] if len(lst) == 0: raise ValueError("An empty list was provided.") return lst @@ -555,7 +557,10 @@ def artists_paginated( :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) + count = self.session.user.favorites.get_artists_count() + return get_items( + self.session.user.favorites.artists, count, order, order_direction + ) def artists( self, @@ -587,6 +592,21 @@ def artists( ), ) + def get_artists_count( + self, + ) -> int: + """Get the total number of artists in the user's collection. + + This performs a minimal API request (limit=1) to fetch metadata about the + artists without retrieving all of them. The API response contains + 'totalNumberOfItems', which represents the total items (artists) available. + :return: The number of items available. + """ + params = {"limit": 1, "offset": 0} + + json_obj = self.requests.map_request(f"{self.base_url}/artists", params=params) + return json_obj.get("totalNumberOfItems", 0) + def albums_paginated( self, order: Optional[AlbumOrder] = None, @@ -598,7 +618,10 @@ def albums_paginated( :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) + count = self.session.user.favorites.get_artists_count() + return get_items( + self.session.user.favorites.albums, count, order, order_direction + ) def albums( self, @@ -628,19 +651,36 @@ def albums( ), ) + def get_albums_count( + self, + ) -> int: + """Get the total number of albums in the user's collection. + + This performs a minimal API request (limit=1) to fetch metadata about the albums + without retrieving all of them. The API response contains 'totalNumberOfItems', + which represents the total items (albums) available. + :return: The number of items available. + """ + params = {"limit": 1, "offset": 0} + + json_obj = self.requests.map_request(f"{self.base_url}/albums", params=params) + return json_obj.get("totalNumberOfItems", 0) + 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. + """Get the users favorite playlists, 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) + count = self.session.user.favorites.get_playlists_count() + return get_items( + self.session.user.favorites.playlists, count, order, order_direction + ) def playlists( self, @@ -666,10 +706,14 @@ def playlists( } if order: params["order"] = order.value + else: + params["order"] = PlaylistOrder.DateCreated.value if order_direction: params["orderDirection"] = order_direction.value + else: + params["orderDirection"] = OrderDirection.Descending.value - endpoint = "my-collection/playlists" + endpoint = "my-collection/playlists/folders" return cast( List["Playlist"], self.session.request.map_request( @@ -724,19 +768,41 @@ def playlist_folders( ), ) + def get_playlists_count(self) -> int: + """Get the total number of playlists in the user's root collection. + + This performs a minimal API request (limit=1) to fetch metadata about the + playlists without retrieving all of them. The API response contains + 'totalNumberOfItems', which represents the total playlists available. + :return: The number of items available. + """ + params = {"folderId": "root", "offset": 0, "limit": 1, "includeOnly": ""} + + endpoint = "my-collection/playlists/folders" + json_obj = self.session.request.map_request( + url=urljoin( + self.session.config.api_v2_location, + endpoint, + ), + params=params, + ) + return json_obj.get("totalNumberOfItems", 0) + 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. + """Get the users favorite tracks, 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) + count = self.session.user.favorites.get_tracks_count() + return get_items( + self.session.user.favorites.tracks, count, order, order_direction + ) def tracks( self, @@ -766,6 +832,21 @@ def tracks( ), ) + def get_tracks_count( + self, + ) -> int: + """Get the total number of tracks in the user's collection. + + This performs a minimal API request (limit=1) to fetch metadata about the tracks + without retrieving all of them. The API response contains 'totalNumberOfItems', + which represents the total items (tracks) available. + :return: The number of items available. + """ + params = {"limit": 1, "offset": 0} + + json_obj = self.requests.map_request(f"{self.base_url}/tracks", params=params) + return json_obj.get("totalNumberOfItems", 0) + def videos( self, limit: Optional[int] = None, diff --git a/tidalapi/workers.py b/tidalapi/workers.py index 657748b..adc1a47 100644 --- a/tidalapi/workers.py +++ b/tidalapi/workers.py @@ -6,18 +6,30 @@ def func_wrapper(args): - (f, offset, *args) = args + """ + args: tuple(func, limit, offset, *extra_args) + Returns list of (index, item) + """ + func, limit, offset, *extra_args = args try: - items = f(*args) + items = func(limit, offset, *extra_args) except Exception as e: - log.error("Failed to run %s(offset=%d, args=%s)", f, offset, args) + log.error( + "Failed to run %s(limit=%d, offset=%d, args=%s)", + func, + limit, + offset, + extra_args, + ) log.exception(e) items = [] - return list((i + offset, item) for i, item in enumerate(items)) + + return [(i + offset, item) for i, item in enumerate(items)] def get_items( func: Callable, + total_count: int, *args, parse: Callable = lambda _: _, chunk_size: int = 50, @@ -25,40 +37,14 @@ def get_items( ): """This function performs pagination on a function that supports `limit`/`offset` parameters and it runs API requests in parallel to speed things up.""" + offsets = list(range(0, total_count, chunk_size)) 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, - chunk_size, # limit - offset, # offset - *args, # extra args (e.g. order, order_direction) - ) - for offset in offsets - ], - ) - - new_items = [] - for results in pool_results: - new_items.extend(results) - remaining = len(new_items) - items.extend(new_items) + with ThreadPoolExecutor(processes) as pool: + args_list = [(func, chunk_size, offset, *args) for offset in offsets] - items = [_ for _ in items if _] - sorted_items = list( - map(lambda item: item[1], sorted(items, key=lambda item: item[0])) - ) + for page_items in pool.map(func_wrapper, args_list): + items.extend(page_items) - return list(map(parse, sorted_items)) + items = [item for _, item in sorted(items, key=lambda x: x[0])] + return list(map(parse, items))