From f6932c2bf195c450b01e94e3e4e592b43bc9e7f2 Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Fri, 8 Mar 2024 21:32:09 +0100 Subject: [PATCH] get_episodes_playlist (#561) * get_episodes_playlist * fix minor issues --- README.rst | 1 + docs/source/reference.rst | 1 + tests/mixins/test_podcasts.py | 4 + ytmusicapi/mixins/playlists.py | 44 +----- ytmusicapi/mixins/podcasts.py | 21 +++ ytmusicapi/parsers/playlists.py | 238 +++++++++++++++++++------------- ytmusicapi/parsers/podcasts.py | 2 +- 7 files changed, 174 insertions(+), 137 deletions(-) diff --git a/README.rst b/README.rst index 42e534b6..2b2efc3c 100644 --- a/README.rst +++ b/README.rst @@ -63,6 +63,7 @@ Features * get podcasts * get episodes * get channels +* get episodes playlists | **Uploads**: diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 056eef63..d568663b 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -81,6 +81,7 @@ Podcasts .. automethod:: YTMusic.get_channel_episodes .. automethod:: YTMusic.get_podcast .. automethod:: YTMusic.get_episode +.. automethod:: YTMusic.get_episodes_playlist Uploads ------- diff --git a/tests/mixins/test_podcasts.py b/tests/mixins/test_podcasts.py index 63935fc8..9abb7cdc 100644 --- a/tests/mixins/test_podcasts.py +++ b/tests/mixins/test_podcasts.py @@ -43,3 +43,7 @@ def test_many_episodes(self, yt): for result in results: result = yt.get_episode(result["videoId"]) assert len(result["description"].text) > 0 + + def test_get_episodes_playlist(self, yt_brand): + playlist = yt_brand.get_episodes_playlist() + assert len(playlist["episodes"]) > 90 diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index b59b46f0..8dc7d16b 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,7 +1,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from ytmusicapi.continuations import * -from ytmusicapi.helpers import sum_total_duration, to_int +from ytmusicapi.helpers import sum_total_duration from ytmusicapi.navigation import * from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist from ytmusicapi.parsers.playlists import * @@ -108,44 +108,9 @@ def get_playlist( response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"]) playlist = {"id": results["playlistId"]} - own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"] - if not own_playlist: - header = response["header"]["musicDetailHeaderRenderer"] - playlist["privacy"] = "PUBLIC" - else: - header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"] - playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"] - header = header["header"]["musicDetailHeaderRenderer"] - playlist["owned"] = own_playlist - - playlist["title"] = nav(header, TITLE_TEXT) - playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED) - playlist["description"] = nav(header, DESCRIPTION, True) - run_count = len(nav(header, SUBTITLE_RUNS)) - if run_count > 1: - playlist["author"] = { - "name": nav(header, SUBTITLE2), - "id": nav(header, [*SUBTITLE_RUNS, 2, *NAVIGATION_BROWSE_ID], True), - } - if run_count == 5: - playlist["year"] = nav(header, SUBTITLE3) - - playlist["views"] = None - playlist["duration"] = None - if "runs" in header["secondSubtitle"]: - second_subtitle_runs = header["secondSubtitle"]["runs"] - has_views = (len(second_subtitle_runs) > 3) * 2 - playlist["views"] = None if not has_views else to_int(second_subtitle_runs[0]["text"]) - has_duration = (len(second_subtitle_runs) > 1) * 2 - playlist["duration"] = ( - None if not has_duration else second_subtitle_runs[has_views + has_duration]["text"] - ) - song_count = second_subtitle_runs[has_views + 0]["text"].split(" ") - song_count = to_int(song_count[0]) if len(song_count) > 1 else 0 - else: - song_count = len(results["contents"]) - - playlist["trackCount"] = song_count + playlist.update(parse_playlist_header(response)) + if playlist["trackCount"] is None: + playlist["trackCount"] = len(results["contents"]) request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) @@ -154,6 +119,7 @@ def get_playlist( playlist["related"] = [] if "continuations" in section_list: additionalParams = get_continuation_params(section_list) + own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"] if own_playlist and (suggestions_limit > 0 or related): parse_func = lambda results: parse_playlist_items(results) suggested = request_func(additionalParams) diff --git a/ytmusicapi/mixins/podcasts.py b/ytmusicapi/mixins/podcasts.py index 012a9fdb..ddff56ed 100644 --- a/ytmusicapi/mixins/podcasts.py +++ b/ytmusicapi/mixins/podcasts.py @@ -4,6 +4,7 @@ from ytmusicapi.mixins._protocol import MixinProtocol from ytmusicapi.navigation import * from ytmusicapi.parsers.browsing import parse_content_list +from ytmusicapi.parsers.playlists import parse_playlist_header from ytmusicapi.parsers.podcasts import * from ._utils import * @@ -228,3 +229,23 @@ def get_episode(self, videoId: str) -> Dict: episode["description"] = Description.from_runs(description_runs) return episode + + def get_episodes_playlist(self, playlist_id: str = "RDPN") -> Dict: + """ + Get all episodes in an episodes playlist. Currently the only known playlist is the + "New Episodes" auto-generated playlist + + :param playlist_id: Playlist ID, defaults to "RDPN", the id of the New Episodes playlist + :return: Dictionary in the format of :py:func:`get_podcast` + """ + browseId = "VL" + playlist_id if not playlist_id.startswith("VL") else playlist_id + body = {"browseId": browseId} + endpoint = "browse" + response = self._send_request(endpoint, body) + playlist = parse_playlist_header(response) + + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) + parse_func = lambda contents: parse_content_list(contents, parse_episode, MMRIR) + playlist["episodes"] = parse_func(results["contents"]) + + return playlist diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index ebbb7b9e..3e31af65 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,114 +1,158 @@ from typing import List, Optional +from ..helpers import to_int from .songs import * +def parse_playlist_header(response: Dict) -> Dict[str, Any]: + playlist: Dict[str, Any] = {} + own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"] + if not own_playlist: + header = response["header"]["musicDetailHeaderRenderer"] + playlist["privacy"] = "PUBLIC" + else: + header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"] + playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"] + header = header["header"]["musicDetailHeaderRenderer"] + playlist["owned"] = own_playlist + + playlist["title"] = nav(header, TITLE_TEXT) + playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED) + playlist["description"] = nav(header, DESCRIPTION, True) + run_count = len(nav(header, SUBTITLE_RUNS)) + if run_count > 1: + playlist["author"] = { + "name": nav(header, SUBTITLE2), + "id": nav(header, [*SUBTITLE_RUNS, 2, *NAVIGATION_BROWSE_ID], True), + } + if run_count == 5: + playlist["year"] = nav(header, SUBTITLE3) + + playlist["views"] = None + playlist["duration"] = None + playlist["trackCount"] = None + if "runs" in header["secondSubtitle"]: + second_subtitle_runs = header["secondSubtitle"]["runs"] + has_views = (len(second_subtitle_runs) > 3) * 2 + playlist["views"] = None if not has_views else to_int(second_subtitle_runs[0]["text"]) + has_duration = (len(second_subtitle_runs) > 1) * 2 + playlist["duration"] = ( + None if not has_duration else second_subtitle_runs[has_views + has_duration]["text"] + ) + song_count = second_subtitle_runs[has_views + 0]["text"].split(" ") + song_count = to_int(song_count[0]) if len(song_count) > 1 else 0 + playlist["trackCount"] = song_count + + return playlist + + def parse_playlist_items(results, menu_entries: Optional[List[List]] = None, is_album=False): songs = [] for result in results: if MRLIR not in result: continue data = result[MRLIR] + song = parse_playlist_item(data, menu_entries, is_album) + if song: + songs.append(song) - videoId = setVideoId = None - like = None - feedback_tokens = None - library_status = None - - # if the item has a menu, find its setVideoId - if "menu" in data: - for item in nav(data, MENU_ITEMS): - if "menuServiceItemRenderer" in item: - menu_service = nav(item, MENU_SERVICE) - if "playlistEditEndpoint" in menu_service: - setVideoId = nav( - menu_service, ["playlistEditEndpoint", "actions", 0, "setVideoId"], True - ) - videoId = nav( - menu_service, ["playlistEditEndpoint", "actions", 0, "removedVideoId"], True - ) - - if TOGGLE_MENU in item: - feedback_tokens = parse_song_menu_tokens(item) - library_status = parse_song_library_status(item) - - # if item is not playable, the videoId was retrieved above - if nav(data, PLAY_BUTTON, none_if_absent=True) is not None: - if "playNavigationEndpoint" in nav(data, PLAY_BUTTON): - videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"] - - if "menu" in data: - like = nav(data, MENU_LIKE_STATUS, True) - - title = get_item_text(data, 0) - if title == "Song deleted": - continue - - flex_column_count = len(data["flexColumns"]) - - artists = parse_song_artists(data, 1) - - album = parse_song_album(data, flex_column_count - 1) if not is_album else None - - views = get_item_text(data, 2) if flex_column_count == 4 or is_album else None - - duration = None - if "fixedColumns" in data: - if "simpleText" in get_fixed_column_item(data, 0)["text"]: - duration = get_fixed_column_item(data, 0)["text"]["simpleText"] - else: - duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] - - thumbnails = None - if "thumbnail" in data: - thumbnails = nav(data, THUMBNAILS) - - isAvailable = True - if "musicItemRendererDisplayPolicy" in data: - isAvailable = ( - data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT" - ) - - isExplicit = nav(data, BADGE_LABEL, True) is not None - - videoType = nav( - data, - [*MENU_ITEMS, 0, MNIR, "navigationEndpoint", *NAVIGATION_VIDEO_TYPE], - True, - ) - - song = { - "videoId": videoId, - "title": title, - "artists": artists, - "album": album, - "likeStatus": like, - "inLibrary": library_status, - "thumbnails": thumbnails, - "isAvailable": isAvailable, - "isExplicit": isExplicit, - "videoType": videoType, - "views": views, - } - - if is_album: - song["trackNumber"] = int(nav(data, ["index", "runs", 0, "text"])) if isAvailable else None - - if duration: - song["duration"] = duration - song["duration_seconds"] = parse_duration(duration) - if setVideoId: - song["setVideoId"] = setVideoId - if feedback_tokens: - song["feedbackTokens"] = feedback_tokens - - if menu_entries: - for menu_entry in menu_entries: - song[menu_entry[-1]] = nav(data, MENU_ITEMS + menu_entry) + return songs - songs.append(song) - return songs +def parse_playlist_item( + data: Dict, menu_entries: Optional[List[List]] = None, is_album=False +) -> Optional[Dict]: + videoId = setVideoId = None + like = None + feedback_tokens = None + library_status = None + + # if the item has a menu, find its setVideoId + if "menu" in data: + for item in nav(data, MENU_ITEMS): + if "menuServiceItemRenderer" in item: + menu_service = nav(item, MENU_SERVICE) + if "playlistEditEndpoint" in menu_service: + setVideoId = nav(menu_service, ["playlistEditEndpoint", "actions", 0, "setVideoId"], True) + videoId = nav( + menu_service, ["playlistEditEndpoint", "actions", 0, "removedVideoId"], True + ) + + if TOGGLE_MENU in item: + feedback_tokens = parse_song_menu_tokens(item) + library_status = parse_song_library_status(item) + + # if item is not playable, the videoId was retrieved above + if nav(data, PLAY_BUTTON, none_if_absent=True) is not None: + if "playNavigationEndpoint" in nav(data, PLAY_BUTTON): + videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"] + + if "menu" in data: + like = nav(data, MENU_LIKE_STATUS, True) + + title = get_item_text(data, 0) + if title == "Song deleted": + return None + + flex_column_count = len(data["flexColumns"]) + + artists = parse_song_artists(data, 1) + + album = parse_song_album(data, flex_column_count - 1) if not is_album else None + + views = get_item_text(data, 2) if flex_column_count == 4 or is_album else None + + duration = None + if "fixedColumns" in data: + if "simpleText" in get_fixed_column_item(data, 0)["text"]: + duration = get_fixed_column_item(data, 0)["text"]["simpleText"] + else: + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] + + thumbnails = nav(data, THUMBNAILS, True) + + isAvailable = True + if "musicItemRendererDisplayPolicy" in data: + isAvailable = data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT" + + isExplicit = nav(data, BADGE_LABEL, True) is not None + + videoType = nav( + data, + [*MENU_ITEMS, 0, MNIR, "navigationEndpoint", *NAVIGATION_VIDEO_TYPE], + True, + ) + + song = { + "videoId": videoId, + "title": title, + "artists": artists, + "album": album, + "likeStatus": like, + "inLibrary": library_status, + "thumbnails": thumbnails, + "isAvailable": isAvailable, + "isExplicit": isExplicit, + "videoType": videoType, + "views": views, + } + + if is_album: + song["trackNumber"] = int(nav(data, ["index", "runs", 0, "text"])) if isAvailable else None + + if duration: + song["duration"] = duration + song["duration_seconds"] = parse_duration(duration) + if setVideoId: + song["setVideoId"] = setVideoId + if feedback_tokens: + song["feedbackTokens"] = feedback_tokens + + if menu_entries: + for menu_entry in menu_entries: + song[menu_entry[-1]] = nav(data, MENU_ITEMS + menu_entry) + + return song def validate_playlist_id(playlistId: str) -> str: diff --git a/ytmusicapi/parsers/podcasts.py b/ytmusicapi/parsers/podcasts.py index 20836ff4..031975ce 100644 --- a/ytmusicapi/parsers/podcasts.py +++ b/ytmusicapi/parsers/podcasts.py @@ -111,7 +111,7 @@ def parse_episode(data): videoId = nav(data, ["onTap", *WATCH_VIDEO_ID], True) browseId = nav(data, [*TITLE, *NAVIGATION_BROWSE_ID], True) videoType = nav(data, ["onTap", *NAVIGATION_VIDEO_TYPE], True) - index = nav(data, ["onTap", "watchEndpoint", "index"]) + index = nav(data, ["onTap", "watchEndpoint", "index"], True) return { "index": index, "title": title,