diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index 73aabfc9..6545a827 100644 --- a/tests/mixins/test_browsing.py +++ b/tests/mixins/test_browsing.py @@ -24,15 +24,22 @@ def test_get_artist(self, yt): assert len(results) >= 11 def test_get_artist_albums(self, yt): - artist = yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") + artist = yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") results = yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) assert len(results) == 100 results = yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) - assert len(results) < 100 + assert len(results) == 100 + + results_unsorted = yt.get_artist_albums( + artist["albums"]["browseId"], artist["albums"]["params"], limit=None + ) + assert len(results_unsorted) >= 300 - artist = yt.get_artist("UC6LfFqHnWV8iF94n54jwYGw") - results = yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"], limit=None) - assert len(results) >= 300 + results_sorted = yt.get_artist_albums( + artist["albums"]["browseId"], artist["albums"]["params"], limit=None, order="alphabetical order" + ) + assert len(results_sorted) >= 300 + assert results_sorted != results_unsorted def test_get_user(self, yt): results = yt.get_user("UC44hbeRoCZVVMVg5z0FfIww") diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 6035d900..3c9a084d 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,7 +1,10 @@ import re from typing import Any, Dict, List, Optional -from ytmusicapi.continuations import get_continuations +from ytmusicapi.continuations import ( + get_continuations, + get_reloadable_continuation_params, +) from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.browsing import parse_album, parse_content_list, parse_mixed_content, parse_playlist @@ -252,13 +255,16 @@ def get_artist(self, channelId: str) -> Dict: artist.update(self.parser.parse_artist_contents(results)) return artist - def get_artist_albums(self, channelId: str, params: str, limit: int | None = 100) -> List[Dict]: + def get_artist_albums( + self, channelId: str, params: str, limit: Optional[int] = 100, order: Optional[str] = None + ) -> List[Dict]: """ Get the full list of an artist's albums or singles :param channelId: browseId of the artist as returned by :py:func:`get_artist` :param params: params obtained by :py:func:`get_artist` :param limit: Number of albums to return. `None` retrieves them all. Default: 100 + :param order: Order of albums to return. Allowed values: 'Recency', 'Popularity', 'Alphabetical order'. Default: Default order. :return: List of albums in the format of :py:func:`get_library_albums`, except artists key is missing. @@ -266,14 +272,63 @@ def get_artist_albums(self, channelId: str, params: str, limit: int | None = 100 body = {"browseId": channelId, "params": params} endpoint = "browse" response = self._send_request(endpoint, body) - results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) + + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) + parse_func = lambda contents: parse_albums(contents) + + if order: + # pick the correct continuation from response depending on the order chosen + sort_options = nav( + response, + SINGLE_COLUMN_TAB + + SECTION + + HEADER_SIDE + + [ + "endItems", + 0, + "musicSortFilterButtonRenderer", + "menu", + "musicMultiSelectMenuRenderer", + "options", + ], + ) + continuation = next( + ( + nav( + option, + MULTI_SELECT + + [ + "selectedCommand", + "commandExecutorCommand", + "commands", + -1, + "browseSectionListReloadEndpoint", + ], + ) + for option in sort_options + if nav(option, MULTI_SELECT + TITLE_TEXT).lower() == order.lower() + ), + None, + ) + # if a valid order was provided, request continuation and replace original response + if continuation: + additionalParams = get_reloadable_continuation_params( + {"continuations": [continuation["continuation"]]} + ) + response = request_func(additionalParams) + results = nav(response, SECTION_LIST_CONTINUATION + CONTENT) + else: + raise ValueError(f"Invalid order parameter {order}") + + else: + # just use the results from the first request + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) + contents = nav(results, GRID_ITEMS, True) or nav(results, CAROUSEL_CONTENTS) albums = parse_albums(contents) results = nav(results, GRID, True) if "continuations" in results: - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) - parse_func = lambda contents: parse_albums(contents) remaining_limit = None if limit is None else (limit - len(albums)) albums.extend( get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 616add10..955d2ee7 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -7,8 +7,9 @@ TAB_1_CONTENT = ["tabs", 1, "tabRenderer", "content"] SINGLE_COLUMN = ["contents", "singleColumnBrowseResultsRenderer"] SINGLE_COLUMN_TAB = SINGLE_COLUMN + TAB_CONTENT -SECTION_LIST = ["sectionListRenderer", "contents"] -SECTION_LIST_ITEM = ["sectionListRenderer"] + CONTENT +SECTION = ["sectionListRenderer"] +SECTION_LIST = SECTION + ["contents"] +SECTION_LIST_ITEM = SECTION + CONTENT ITEM_SECTION = ["itemSectionRenderer"] + CONTENT MUSIC_SHELF = ["musicShelfRenderer"] GRID = ["gridRenderer"] @@ -58,7 +59,9 @@ TASTE_PROFILE_ARTIST = ["title", "runs"] SECTION_LIST_CONTINUATION = ["continuationContents", "sectionListContinuation"] MENU_PLAYLIST_ID = MENU_ITEMS + [0, "menuNavigationItemRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID +MULTI_SELECT = ["musicMultiSelectMenuItemRenderer"] HEADER_DETAIL = ["header", "musicDetailHeaderRenderer"] +HEADER_SIDE = ["header", "musicSideAlignedItemRenderer"] DESCRIPTION_SHELF = ["musicDescriptionShelfRenderer"] DESCRIPTION = ["description"] + RUN_TEXT CAROUSEL = ["musicCarouselShelfRenderer"]