def _get_auto_playlists(self): try: logger.debug("YTMusic loading auto playlists") response = self.api._send_request("browse", {}) tab = nav(response, SINGLE_COLUMN_TAB) browse = parse_auto_playlists(nav(tab, SECTION_LIST)) if "continuations" in tab["sectionListRenderer"]: request_func = lambda additionalParams: self.api._send_request( "browse", {}, additionalParams ) parse_func = lambda contents: parse_auto_playlists(contents) browse.extend( get_continuations( tab["sectionListRenderer"], "sectionListContinuation", 100, request_func, parse_func, ) ) # Delete empty sections for i in range(len(browse) - 1, 0, -1): if len(browse[i]["items"]) == 0: browse.pop(i) logger.info( "YTMusic loaded %d auto playlists sections", len(browse) ) self.library.ytbrowse = browse except Exception: logger.exception("YTMusic failed to load auto playlists") return None
def parse_library_artists(response, request_func, limit): results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), 'itemSectionRenderer') results = nav(results, ITEM_SECTION) if 'musicShelfRenderer' not in results: return [] results = results['musicShelfRenderer'] artists = parse_artists(results['contents']) if 'continuations' in results: parse_func = lambda contents: parse_artists(contents) artists.extend( get_continuations(results, 'musicShelfContinuation', limit - len(artists), request_func, parse_func)) return artists
def parse_library_albums(response, request_func, limit): results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), 'itemSectionRenderer') results = nav(results, ITEM_SECTION) if 'gridRenderer' not in results: return [] results = nav(results, GRID) albums = parse_albums(results['items']) if 'continuations' in results: parse_func = lambda contents: parse_albums(contents) albums.extend( get_continuations(results, 'gridContinuation', limit - len(albums), request_func, parse_func)) return albums
def get_home(self, limit=3) -> List[Dict]: """ Get the home page. The home page is structured as titled rows, returning 3 rows of music suggestions at a time. Content varies and may contain artist, album, song or playlist suggestions, sometimes mixed within the same row :param limit: Number of rows to return :return: List of dictionaries keyed with 'title' text and 'contents' list Example list:: [ { "title": "Your morning music", "contents": [ { //album result "title": "Sentiment", "year": "Said The Sky", "browseId": "MPREb_QtqXtd2xZMR", "thumbnails": [...] }, { //playlist result "title": "r/EDM top submissions 01/28/2022", "playlistId": "PLz7-xrYmULdSLRZGk-6GKUtaBZcgQNwel", "thumbnails": [...], "description": "redditEDM • 161 songs", "count": "161", "author": [ { "name": "redditEDM", "id": "UCaTrZ9tPiIGHrkCe5bxOGwA" } ] } ] }, { "title": "Your favorites", "contents": [ { //artist result "title": "Chill Satellite", "browseId": "UCrPLFBWdOroD57bkqPbZJog", "subscribers": "374", "thumbnails": [...] } { //album result "title": "Dragon", "year": "Two Steps From Hell", "browseId": "MPREb_M9aDqLRbSeg", "thumbnails": [...] } ] }, { "title": "Quick picks", "contents": [ { //song quick pick "title": "Gravity", "videoId": "EludZd6lfts", "artists": [{ "name": "yetep", "id": "UCSW0r7dClqCoCvQeqXiZBlg" }], "thumbnails": [...], "album": { "name": "Gravity", "id": "MPREb_D6bICFcuuRY" } }, { //video quick pick "title": "Gryffin & Illenium (feat. Daya) - Feel Good (L3V3LS Remix)", "videoId": "bR5l0hJDnX8", "artists": [ { "name": "L3V3LS", "id": "UCCVNihbOdkOWw_-ajIYhAbQ" } ], "thumbnails": [...], "views": "10M" } ] } ] """ endpoint = 'browse' body = {"browseId": "FEmusic_home"} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] home.extend(self.parser.parse_mixed_content(results)) section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) if 'continuations' in section_list: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: self.parser.parse_mixed_content(contents) home.extend( get_continuations(section_list, 'sectionListContinuation', limit - len(home), request_func, parse_func)) return home
def get_watch_playlist(self, videoId: str = None, playlistId: str = None, limit=25, params: str = None) -> Dict[str, Union[List[Dict]]]: """ Get a watch list of tracks. This watch playlist appears when you press play on a track in YouTube Music. Please note that the `INDIFFERENT` likeStatus of tracks returned by this endpoint may be either `INDIFFERENT` or `DISLIKE`, due to ambiguous data returned by YouTube Music. :param videoId: videoId of the played video :param playlistId: playlistId of the played playlist or album :param limit: minimum number of watch playlist items to return :param params: only used internally by :py:func:`get_watch_playlist_shuffle` :return: List of watch playlist items. The counterpart key is optional and only appears if a song has a corresponding video counterpart (UI song/video switcher). Example:: { "tracks": [ { "videoId": "9mWr4c_ig54", "title": "Foolish Of Me (feat. Jonathan Mendelsohn)", "length": "3:07", "thumbnail": [ { "url": "https://lh3.googleusercontent.com/ulK2YaLtOW0PzcN7ufltG6e4ae3WZ9Bvg8CCwhe6LOccu1lCKxJy2r5AsYrsHeMBSLrGJCNpJqXgwczk=w60-h60-l90-rj", "width": 60, "height": 60 }... ], "feedbackTokens": { "add": "AB9zfpIGg9XN4u2iJ...", "remove": "AB9zfpJdzWLcdZtC..." }, "likeStatus": "INDIFFERENT", "videoType": "MUSIC_VIDEO_TYPE_ATV", "artists": [ { "name": "Seven Lions", "id": "UCYd2yzYRx7b9FYnBSlbnknA" }, { "name": "Jason Ross", "id": "UCVCD9Iwnqn2ipN9JIF6B-nA" }, { "name": "Crystal Skies", "id": "UCTJZESxeZ0J_M7JXyFUVmvA" } ], "album": { "name": "Foolish Of Me", "id": "MPREb_C8aRK1qmsDJ" }, "year": "2020", "counterpart": { "videoId": "E0S4W34zFMA", "title": "Foolish Of Me [ABGT404] (feat. Jonathan Mendelsohn)", "length": "3:07", "thumbnail": [...], "feedbackTokens": null, "likeStatus": "LIKE", "artists": [ { "name": "Jason Ross", "id": null }, { "name": "Seven Lions", "id": null }, { "name": "Crystal Skies", "id": null } ], "views": "6.6K" } },... ], "playlistId": "RDAMVM4y33h81phKU", "lyrics": "MPLYt_HNNclO0Ddoc-17" } """ body = {'enablePersistentPlaylistPanel': True, 'isAudioOnly': True} if not videoId and not playlistId: raise Exception( "You must provide either a video id, a playlist id, or both") if videoId: body['videoId'] = videoId if not playlistId: playlistId = "RDAMVM" + videoId if not params: body['watchEndpointMusicSupportedConfigs'] = { 'watchEndpointMusicConfig': { 'hasPersistentPlaylistPanel': True, 'musicVideoType': "MUSIC_VIDEO_TYPE_ATV", } } body['playlistId'] = validate_playlist_id(playlistId) is_playlist = body['playlistId'].startswith('PL') or \ body['playlistId'].startswith('OLA') if params: body['params'] = params endpoint = 'next' response = self._send_request(endpoint, body) watchNextRenderer = nav(response, [ 'contents', 'singleColumnMusicWatchNextResultsRenderer', 'tabbedRenderer', 'watchNextTabbedResultsRenderer' ]) lyrics_browse_id = get_tab_browse_id(watchNextRenderer, 1) related_browse_id = get_tab_browse_id(watchNextRenderer, 2) results = nav( watchNextRenderer, TAB_CONTENT + ['musicQueueRenderer', 'content', 'playlistPanelRenderer']) playlist = next( filter( bool, map( lambda x: nav(x, ['playlistPanelVideoRenderer'] + NAVIGATION_PLAYLIST_ID, True), results['contents'])), None) tracks = parse_watch_playlist(results['contents']) if 'continuations' in results: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: parse_watch_playlist(contents) tracks.extend( get_continuations(results, 'playlistPanelContinuation', limit - len(tracks), request_func, parse_func, '' if is_playlist else 'Radio')) return dict(tracks=tracks, playlistId=playlist, lyrics=lyrics_browse_id, related=related_browse_id)
def search(self, query: str, filter: str = None, scope: str = None, limit: int = 20, ignore_spelling: bool = False) -> List[Dict]: """ Search YouTube music Returns results within the provided category. :param query: Query string, i.e. 'Oasis Wonderwall' :param filter: Filter for item types. Allowed values: ``songs``, ``videos``, ``albums``, ``artists``, ``playlists``, ``community_playlists``, ``featured_playlists``, ``uploads``. Default: Default search, including all types of items. :param scope: Search scope. Allowed values: ``library``, ``uploads``. Default: Search the public YouTube Music catalogue. :param limit: Number of search results to return Default: 20 :param ignore_spelling: Whether to ignore YTM spelling suggestions. If True, the exact search term will be searched for, and will not be corrected. This does not have any effect when the filter is set to ``uploads``. Default: False, will use YTM's default behavior of autocorrecting the search. :return: List of results depending on filter. resultType specifies the type of item (important for default search). albums, artists and playlists additionally contain a browseId, corresponding to albumId, channelId and playlistId (browseId=``VL``+playlistId) Example list for default search with one result per resultType for brevity. Normally there are 3 results per resultType and an additional ``thumbnails`` key:: [ { "category": "Top result", "resultType": "video", "videoId": "vU05Eksc_iM", "title": "Wonderwall", "artists": [ { "name": "Oasis", "id": "UCmMUZbaYdNH0bEd1PAlAqsA" } ], "views": "1.4M", "videoType": "MUSIC_VIDEO_TYPE_OMV", "duration": "4:38", "duration_seconds": 278 }, { "category": "Songs", "resultType": "song", "videoId": "ZrOKjDZOtkA", "title": "Wonderwall", "artists": [ { "name": "Oasis", "id": "UCmMUZbaYdNH0bEd1PAlAqsA" } ], "album": { "name": "(What's The Story) Morning Glory? (Remastered)", "id": "MPREb_9nqEki4ZDpp" }, "duration": "4:19", "duration_seconds": 259 "isExplicit": false, "feedbackTokens": { "add": null, "remove": null } }, { "category": "Albums", "resultType": "album", "browseId": "MPREb_9nqEki4ZDpp", "title": "(What's The Story) Morning Glory? (Remastered)", "type": "Album", "artist": "Oasis", "year": "1995", "isExplicit": false }, { "category": "Community playlists", "resultType": "playlist", "browseId": "VLPLK1PkWQlWtnNfovRdGWpKffO1Wdi2kvDx", "title": "Wonderwall - Oasis", "author": "Tate Henderson", "itemCount": "174" }, { "category": "Videos", "resultType": "video", "videoId": "bx1Bh8ZvH84", "title": "Wonderwall", "artists": [ { "name": "Oasis", "id": "UCmMUZbaYdNH0bEd1PAlAqsA" } ], "views": "386M", "duration": "4:38", "duration_seconds": 278 }, { "category": "Artists", "resultType": "artist", "browseId": "UCmMUZbaYdNH0bEd1PAlAqsA", "artist": "Oasis", "shuffleId": "RDAOkjHYJjL1a3xspEyVkhHAsg", "radioId": "RDEMkjHYJjL1a3xspEyVkhHAsg" } ] """ body = {'query': query} endpoint = 'search' search_results = [] filters = [ 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', 'videos' ] if filter and filter not in filters: raise Exception( "Invalid filter provided. Please use one of the following filters or leave out the parameter: " + ', '.join(filters)) scopes = ['library', 'uploads'] if scope and scope not in scopes: raise Exception( "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " + ', '.join(scopes)) params = get_search_params(filter, scope, ignore_spelling) if params: body['params'] = params response = self._send_request(endpoint, body) # no results if 'contents' not in response: return search_results if 'tabbedSearchResultsRenderer' in response['contents']: tab_index = 0 if not scope or filter else scopes.index(scope) + 1 results = response['contents']['tabbedSearchResultsRenderer'][ 'tabs'][tab_index]['tabRenderer']['content'] else: results = response['contents'] results = nav(results, SECTION_LIST) # no results if len(results) == 1 and 'itemSectionRenderer' in results: return search_results # set filter for parser if filter and 'playlists' in filter: filter = 'playlists' elif scope == scopes[1]: filter = scopes[1] for res in results: if 'musicShelfRenderer' in res: results = res['musicShelfRenderer']['contents'] original_filter = filter category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) if not filter and scope == scopes[0]: filter = category type = filter[:-1].lower() if filter else None search_results.extend( self.parser.parse_search_results(results, type, category)) filter = original_filter if 'continuations' in res['musicShelfRenderer']: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: self.parser.parse_search_results( contents, type, category) search_results.extend( get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', limit - len(search_results), request_func, parse_func)) return search_results
def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: """ Returns a list of playlist items :param playlistId: Playlist id :param limit: How many songs to return. Default: 100 :return: Dictionary with information about the playlist. The key ``tracks`` contains a List of playlistItem dictionaries Each item is in the following format:: { "id": "PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_", "privacy": "PUBLIC", "title": "New EDM This Week 03/13/2020", "thumbnails": [...] "description": "Weekly r/EDM new release roundup. Created with github.com/sigma67/spotifyplaylist_to_gmusic", "author": "sigmatics", "year": "2020", "duration": "6+ hours", "duration_seconds": 52651, "trackCount": 237, "tracks": [ { "videoId": "bjGppZKiuFE", "title": "Lost", "artists": [ { "name": "Guest Who", "id": "UCkgCRdnnqWnUeIH7EIc3dBg" }, { "name": "Kate Wild", "id": "UCwR2l3JfJbvB6aq0RnnJfWg" } ], "album": { "name": "Lost", "id": "MPREb_PxmzvDuqOnC" }, "duration": "2:58", "likeStatus": "INDIFFERENT", "thumbnails": [...], "isAvailable": True, "isExplicit": False, "feedbackTokens": { "add": "AB9zfpJxtvrU...", "remove": "AB9zfpKTyZ..." } ] } The setVideoId is the unique id of this playlist item and needed for moving/removing playlist items """ browseId = "VL" + playlistId if not playlistId.startswith( "VL") else playlistId body = {'browseId': browseId} endpoint = 'browse' 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['title'] = nav(header, TITLE_TEXT) playlist['thumbnails'] = nav(header, THUMBNAIL_CROPPED) playlist["description"] = nav(header, DESCRIPTION, True) run_count = len(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) song_count = to_int( unicodedata.normalize("NFKD", header['secondSubtitle']['runs'][0]['text'])) if len(header['secondSubtitle']['runs']) > 1: playlist['duration'] = header['secondSubtitle']['runs'][2]['text'] playlist['trackCount'] = song_count playlist['suggestions_token'] = nav( response, SINGLE_COLUMN_TAB + ['sectionListRenderer', 'contents', 1] + MUSIC_SHELF + RELOAD_CONTINUATION, True) playlist['tracks'] = [] if song_count > 0: playlist['tracks'].extend(parse_playlist_items( results['contents'])) songs_to_get = min(limit, song_count) if 'continuations' in results: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: parse_playlist_items(contents) playlist['tracks'].extend( get_continuations(results, 'musicPlaylistShelfContinuation', songs_to_get - len(playlist['tracks']), request_func, parse_func)) playlist['duration_seconds'] = sum_total_duration(playlist) return playlist