diff --git a/docs/source/conf.py b/docs/source/conf.py index 94c7cd0d..cf2ccd45 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,17 +13,17 @@ import os import sys -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, '../..') +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, "../..") from ytmusicapi import __version__ # noqa: E402 -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" # -- Project information ----------------------------------------------------- -project = 'ytmusicapi' -copyright = '2022, sigma67' -author = 'sigma67' +project = "ytmusicapi" +copyright = "2022, sigma67" +author = "sigma67" # The full version, including alpha/beta/rc tags version = __version__ @@ -38,17 +38,17 @@ extensions = ["sphinx.ext.autodoc"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] -html_theme = "sphinx_rtd_theme" \ No newline at end of file +html_theme = "sphinx_rtd_theme" diff --git a/pyproject.toml b/pyproject.toml index 60d7c2d4..185129b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ command_line = "-m unittest discover tests" [tool.ruff] #fix = true -line-length = 99 +line-length = 110 ignore = [ "F403", "F405", "F821", "E731" ] extend-select = [ "I", # isort diff --git a/tests/test.py b/tests/test.py index 3df2ff9b..0e1825f0 100644 --- a/tests/test.py +++ b/tests/test.py @@ -30,7 +30,7 @@ def get_resource(file: str) -> str: "user_code": "", "expires_in": 1800, "interval": 5, - "verification_url": "https://www.google.com/device" + "verification_url": "https://www.google.com/device", } oauth_filepath = get_resource(config["auth"]["oauth_file"]) @@ -40,7 +40,6 @@ def get_resource(file: str) -> str: class TestYTMusic(unittest.TestCase): - @classmethod def setUpClass(cls): warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) @@ -51,8 +50,7 @@ def setUpClass(cls): cls.yt_alt_oauth = YTMusic(browser_filepath, oauth_credentials=alt_oauth_creds) cls.yt_auth = YTMusic(browser_filepath, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) - cls.yt_empty = YTMusic(config["auth"]["headers_empty"], - config["auth"]["brand_account_empty"]) + cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]) def test_setup_browser(self): @@ -88,7 +86,7 @@ def test_oauth_tokens(self): self.assertIsNotNone(self.yt_oauth._token) # set reference file - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: first_json = json.load(f) # pull reference values from underlying token @@ -110,7 +108,7 @@ def test_oauth_tokens(self): # check token is propagating properly self.assertEqual(second_token, second_token_inner) - with open(oauth_filepath, 'r') as f2: + with open(oauth_filepath, "r") as f2: second_json = json.load(f2) # ensure token is updating local file @@ -119,7 +117,7 @@ def test_oauth_tokens(self): def test_oauth_custom_client(self): # ensure client works/ignores alt if browser credentials passed as auth self.assertNotEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: token_dict = json.load(f) # oauth token dict entry and alt self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) @@ -154,87 +152,74 @@ def test_search_filters(self): query = "hip hop playlist" results = self.yt_auth.search(query, filter="songs") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'song' for item in results)) + self.assertTrue(all(item["resultType"] == "song" for item in results)) results = self.yt_auth.search(query, filter="videos") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'video' for item in results)) + self.assertTrue(all(item["resultType"] == "video" for item in results)) results = self.yt_auth.search(query, filter="albums", limit=40) self.assertGreater(len(results), 20) - self.assertTrue(all(item['resultType'] == 'album' for item in results)) + self.assertTrue(all(item["resultType"] == "album" for item in results)) results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'artist' for item in results)) + self.assertTrue(all(item["resultType"] == "artist" for item in results)) results = self.yt_auth.search("classical music", filter="playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - results = self.yt_auth.search("clasic rock", - filter="community_playlists", - ignore_spelling=True) + results = self.yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("hip hop", filter="featured_playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("some user", filter="profiles") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'profile' for item in results)) + self.assertTrue(all(item["resultType"] == "profile" for item in results)) results = self.yt_auth.search(query, filter="podcasts") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'podcast' for item in results)) + self.assertTrue(all(item["resultType"] == "podcast" for item in results)) results = self.yt_auth.search(query, filter="episodes") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'episode' for item in results)) + self.assertTrue(all(item["resultType"] == "episode" for item in results)) def test_search_uploads(self): self.assertRaises( Exception, self.yt.search, - config['queries']['uploads_songs'], + config["queries"]["uploads_songs"], filter="songs", scope="uploads", limit=40, ) - results = self.yt_auth.search(config['queries']['uploads_songs'], - scope="uploads", - limit=40) + results = self.yt_auth.search(config["queries"]["uploads_songs"], scope="uploads", limit=40) self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_oauth.search(config['queries']['library_any'], scope="library") + results = self.yt_oauth.search(config["queries"]["library_any"], scope="library") self.assertGreater(len(results), 5) - results = self.yt_alt_oauth.search(config['queries']['library_songs'], - filter="songs", - scope="library", - limit=40) + results = self.yt_alt_oauth.search( + config["queries"]["library_songs"], filter="songs", scope="library", limit=40 + ) self.assertGreater(len(results), 10) - results = self.yt_auth.search(config['queries']['library_albums'], - filter="albums", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_albums"], filter="albums", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search(config['queries']['library_artists'], - filter="artists", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_artists"], filter="artists", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search(config['queries']['library_playlists'], - filter="playlists", - scope="library") + results = self.yt_auth.search( + config["queries"]["library_playlists"], filter="playlists", scope="library" + ) self.assertGreaterEqual(len(results), 1) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="community_playlists", - scope="library", - limit=40) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="featured_playlists", - scope="library", - limit=40) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="community_playlists", scope="library", limit=40 + ) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="featured_playlists", scope="library", limit=40 + ) def test_get_artist(self): results = self.yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") @@ -243,10 +228,7 @@ def test_get_artist(self): # test correctness of related artists related = results["related"]["results"] self.assertEqual( - len([ - x for x in related - if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"} - ]), + len([x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"}]), len(related), ) @@ -255,14 +237,12 @@ def test_get_artist(self): def test_get_artist_albums(self): artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") - results = self.yt.get_artist_albums(artist["albums"]["browseId"], - artist["albums"]["params"]) + results = self.yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) self.assertGreater(len(results), 0) def test_get_artist_singles(self): artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt.get_artist_albums(artist["singles"]["browseId"], - artist["singles"]["params"]) + results = self.yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) self.assertGreater(len(results), 0) def test_get_user(self): @@ -271,8 +251,7 @@ def test_get_user(self): def test_get_user_playlists(self): results = self.yt.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") - results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", - results["playlists"]["params"]) + results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) self.assertGreater(len(results), 100) def test_get_album_browse_id(self): @@ -280,9 +259,8 @@ def test_get_album_browse_id(self): browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") self.assertEqual(browse_id, sample_album) with self.subTest(): - escaped_browse_id = self.yt.get_album_browse_id( - "OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") - self.assertEqual(escaped_browse_id, 'MPREb_scJdtUCpPE2') + escaped_browse_id = self.yt.get_album_browse_id("OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") + self.assertEqual(escaped_browse_id, "MPREb_scJdtUCpPE2") def test_get_album(self): results = self.yt_auth.get_album(sample_album) @@ -380,12 +358,10 @@ def test_get_watch_playlist(self): self.assertGreater(len(playlist["tracks"]), 45) playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist["tracks"]), 25) - playlist = self.yt.get_watch_playlist(playlistId=config['albums']['album_browse_id'], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('albums', 'album_track_length')) - playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('playlists', 'own_length')) + playlist = self.yt.get_watch_playlist(playlistId=config["albums"]["album_browse_id"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("albums", "album_track_length")) + playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("playlists", "own_length")) ################ # LIBRARY @@ -473,13 +449,11 @@ def test_rate_song(self): def test_edit_song_library_status(self): album = self.yt_brand.get_album(sample_album) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["add"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"]) album = self.yt_brand.get_album(sample_album) self.assertTrue(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["remove"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["remove"]) album = self.yt_brand.get_album(sample_album) self.assertFalse(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) @@ -487,8 +461,7 @@ def test_edit_song_library_status(self): def test_rate_playlist(self): response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") self.assertIn("actions", response) - response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", - "INDIFFERENT") + response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "INDIFFERENT") self.assertIn("actions", response) def test_subscribe_artists(self): @@ -501,26 +474,20 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", - limit=300, - suggestions_limit=7) - self.assertGreater(len(playlist['duration']), 5) + playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7) + self.assertGreater(len(playlist["duration"]), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) self.yt.get_playlist("RDATgXd-") self.assertGreaterEqual(len(playlist["tracks"]), 100) - playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", - limit=None, - related=True) + playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True) self.assertGreater(len(playlist["tracks"]), 200) self.assertEqual(len(playlist["related"]), 0) def test_get_playlist_owned(self): - playlist = self.yt_brand.get_playlist(config["playlists"]["own"], - related=True, - suggestions_limit=21) + playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) self.assertLess(len(playlist["tracks"]), 100) self.assertEqual(len(playlist["suggestions"]), 21) self.assertEqual(len(playlist["related"]), 10) @@ -624,8 +591,7 @@ def test_get_library_upload_album(self): self.assertGreater(len(album["tracks"]), 0) def test_get_library_upload_artist(self): - tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], - 100) + tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) self.assertGreater(len(tracks), 0) diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 2b2e4a3d..4f3ce510 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -9,7 +9,7 @@ # package is not installed pass -__copyright__ = 'Copyright 2023 sigma67' -__license__ = 'MIT' -__title__ = 'ytmusicapi' +__copyright__ = "Copyright 2023 sigma67" +__license__ = "MIT" +__title__ = "ytmusicapi" __all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 6f55218f..34dda063 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -50,7 +50,8 @@ def setup_browser(filepath=None, headers_raw=None): missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) if missing_headers: raise Exception( - "The following entries are missing in your headers: " + ", ".join(missing_headers) + "The following entries are missing in your headers: " + + ", ".join(missing_headers) + ". Please try a different request (such as /browse) and make sure you are logged in." ) diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py index daef4b31..f84e63fe 100644 --- a/ytmusicapi/auth/oauth/__init__.py +++ b/ytmusicapi/auth/oauth/__init__.py @@ -2,4 +2,4 @@ from .credentials import OAuthCredentials from .refreshing import RefreshingToken -__all__ = ['OAuthCredentials', 'RefreshingToken', 'OAuthToken'] +__all__ = ["OAuthCredentials", "RefreshingToken", "OAuthToken"] diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index c39d824c..c92c4362 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -8,7 +8,8 @@ class Credentials: - """ Base class representation of YouTubeMusicAPI OAuth Credentials """ + """Base class representation of YouTubeMusicAPI OAuth Credentials""" + client_id: str client_secret: str @@ -23,7 +24,8 @@ def refresh_token(self, refresh_token: str) -> BaseTokenDict: class Token: - """ Base class representation of the YouTubeMusicAPI OAuth token. """ + """Base class representation of the YouTubeMusicAPI OAuth token.""" + access_token: str refresh_token: str expires_in: int @@ -34,38 +36,40 @@ class Token: token_type: Bearer def __repr__(self) -> str: - """ Readable version. """ - return f'{self.__class__.__name__}: {self.as_dict()}' + """Readable version.""" + return f"{self.__class__.__name__}: {self.as_dict()}" def as_dict(self) -> RefreshableTokenDict: - """ Returns dictionary containing underlying token values. """ + """Returns dictionary containing underlying token values.""" return { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - 'scope': self.scope, - 'expires_at': self.expires_at, - 'expires_in': self.expires_in, - 'token_type': self.token_type + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "scope": self.scope, + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "token_type": self.token_type, } def as_json(self) -> str: return json.dumps(self.as_dict()) def as_auth(self) -> str: - """ Returns Authorization header ready str of token_type and access_token. """ - return f'{self.token_type} {self.access_token}' + """Returns Authorization header ready str of token_type and access_token.""" + return f"{self.token_type} {self.access_token}" class OAuthToken(Token): - """ Wrapper for an OAuth token implementing expiration methods. """ - - def __init__(self, - access_token: str, - refresh_token: str, - scope: str, - token_type: str, - expires_at: Optional[int] = None, - expires_in: Optional[int] = None): + """Wrapper for an OAuth token implementing expiration methods.""" + + def __init__( + self, + access_token: str, + refresh_token: str, + scope: str, + token_type: str, + expires_at: Optional[int] = None, + expires_in: Optional[int] = None, + ): """ :param access_token: active oauth key @@ -102,8 +106,8 @@ def update(self, fresh_access: BaseTokenDict): expires_at attribute set using current epoch, avoid expiration desync by passing only recently requested tokens dicts or updating values to compensate. """ - self._access_token = fresh_access['access_token'] - self._expires_at = int(time.time() + fresh_access['expires_in']) + self._access_token = fresh_access["access_token"] + self._expires_at = int(time.time() + fresh_access["expires_in"]) @property def access_token(self) -> str: diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index bdd3a48a..bdf12f76 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -23,11 +23,13 @@ class OAuthCredentials(Credentials): Class for handling OAuth credential retrieval and refreshing. """ - def __init__(self, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - session: Optional[requests.Session] = None, - proxies: Optional[Dict] = None): + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None, + ): """ :param client_id: Optional. Set the GoogleAPI client_id used for auth flows. Requires client_secret also be provided if set. @@ -38,7 +40,7 @@ def __init__(self, # id, secret should be None, None or str, str if not isinstance(client_id, type(client_secret)): raise KeyError( - 'OAuthCredential init failure. Provide both client_id and client_secret or neither.' + "OAuthCredential init failure. Provide both client_id and client_secret or neither." ) # bind instance to OAuth client for auth flows @@ -50,34 +52,34 @@ def __init__(self, self._session.proxies.update(proxies) def get_code(self) -> AuthCodeDict: - """ Method for obtaining a new user auth code. First step of token creation. """ + """Method for obtaining a new user auth code. First step of token creation.""" code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) return code_response.json() def _send_request(self, url, data): - """ Method for sending post requests with required client_id and User-Agent modifications """ + """Method for sending post requests with required client_id and User-Agent modifications""" data.update({"client_id": self.client_id}) response = self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) if response.status_code == 401: data = response.json() - issue = data.get('error') - if issue == 'unauthorized_client': - raise UnauthorizedOAuthClient( - 'Token refresh error. Most likely client/token mismatch.') + issue = data.get("error") + if issue == "unauthorized_client": + raise UnauthorizedOAuthClient("Token refresh error. Most likely client/token mismatch.") - elif issue == 'invalid_client': + elif issue == "invalid_client": raise BadOAuthClient( - 'OAuth client failure. Most likely client_id and client_secret mismatch or ' - 'YouTubeData API is not enabled.') + "OAuth client failure. Most likely client_id and client_secret mismatch or " + "YouTubeData API is not enabled." + ) else: raise Exception( - f'OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}' + f"OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}" ) return response def token_from_code(self, device_code: str) -> RefreshableTokenDict: - """ Method for verifying user auth code and conversion into a FullTokenDict. """ + """Method for verifying user auth code and conversion into a FullTokenDict.""" response = self._send_request( OAUTH_TOKEN_URL, data={ @@ -88,9 +90,7 @@ def token_from_code(self, device_code: str) -> RefreshableTokenDict: ) return response.json() - def prompt_for_token(self, - open_browser: bool = False, - to_file: Optional[str] = None) -> RefreshingToken: + def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None) -> RefreshingToken: """ Method for CLI token creation via user inputs. diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py index 57d9aaee..b22e802c 100644 --- a/ytmusicapi/auth/oauth/models.py +++ b/ytmusicapi/auth/oauth/models.py @@ -2,12 +2,12 @@ from typing import Literal, TypedDict, Union -DefaultScope = Union[str, Literal['https://www.googleapis.com/auth/youtube']] -Bearer = Union[str, Literal['Bearer']] +DefaultScope = Union[str, Literal["https://www.googleapis.com/auth/youtube"]] +Bearer = Union[str, Literal["Bearer"]] class BaseTokenDict(TypedDict): - """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ + """Limited token. Does not provide a refresh token. Commonly obtained via a token refresh.""" access_token: str #: str to be used in Authorization header expires_in: int #: seconds until expiration from request timestamp @@ -16,14 +16,14 @@ class BaseTokenDict(TypedDict): class RefreshableTokenDict(BaseTokenDict): - """ Entire token. Including refresh. Obtained through token setup. """ + """Entire token. Including refresh. Obtained through token setup.""" expires_at: int #: UNIX epoch timestamp in seconds refresh_token: str #: str used to obtain new access token upon expiration class AuthCodeDict(TypedDict): - """ Keys for the json object obtained via code response during auth flow. """ + """Keys for the json object obtained via code response during auth flow.""" device_code: str #: code obtained via user confirmation and oauth consent user_code: str #: alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 9e54eca1..2d48e8a5 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -32,10 +32,7 @@ def from_file(cls, file_path: str, credentials: Credentials, sync=True): return cls(OAuthToken(**file_pack), credentials, file_path if sync else None) - def __init__(self, - token: OAuthToken, - credentials: Credentials, - local_cache: Optional[str] = None): + def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Optional[str] = None): """ :param token: Underlying Token being maintained. :param credentials: OAuth client being used for refreshing. @@ -56,7 +53,7 @@ def local_cache(self) -> str | None: @local_cache.setter def local_cache(self, path: str): - """ Update attribute and dump token to new path. """ + """Update attribute and dump token to new path.""" self._local_cache = path self.store_token() @@ -78,7 +75,7 @@ def store_token(self, path: Optional[str] = None) -> None: file_path = path if path else self.local_cache if file_path: - with open(file_path, encoding="utf8", mode='w') as file: + with open(file_path, encoding="utf8", mode="w") as file: json.dump(self.token.as_dict(), file, indent=True) @property diff --git a/ytmusicapi/constants.py b/ytmusicapi/constants.py index 86d8eeb0..a497680e 100644 --- a/ytmusicapi/constants.py +++ b/ytmusicapi/constants.py @@ -1,20 +1,136 @@ -YTM_DOMAIN = 'https://music.youtube.com' -YTM_BASE_API = YTM_DOMAIN + '/youtubei/v1/' -YTM_PARAMS = '?alt=json' +YTM_DOMAIN = "https://music.youtube.com" +YTM_BASE_API = YTM_DOMAIN + "/youtubei/v1/" +YTM_PARAMS = "?alt=json" YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" -USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0' +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" SUPPORTED_LANGUAGES = { - 'ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'tr', 'ur', 'zh_CN', - 'zh_TW' + "ar", + "de", + "en", + "es", + "fr", + "hi", + "it", + "ja", + "ko", + "nl", + "pt", + "ru", + "tr", + "ur", + "zh_CN", + "zh_TW", } SUPPORTED_LOCATIONS = { - 'AE', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BD', 'BE', 'BG', 'BH', 'BO', 'BR', 'BY', 'CA', 'CH', 'CL', - 'CO', 'CR', 'CY', 'CZ', 'DE', 'DK', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ES', 'FI', 'FR', 'GB', 'GE', - 'GH', 'GR', 'GT', 'HK', 'HN', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', 'IS', 'IT', 'JM', 'JO', - 'JP', 'KE', 'KH', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LI', 'LK', 'LT', 'LU', 'LV', 'LY', 'MA', 'ME', - 'MK', 'MT', 'MX', 'MY', 'NG', 'NI', 'NL', 'NO', 'NP', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', - 'PL', 'PR', 'PT', 'PY', 'QA', 'RO', 'RS', 'RU', 'SA', 'SE', 'SG', 'SI', 'SK', 'SN', 'SV', 'TH', - 'TN', 'TR', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'VE', 'VN', 'YE', 'ZA', 'ZW' + "AE", + "AR", + "AT", + "AU", + "AZ", + "BA", + "BD", + "BE", + "BG", + "BH", + "BO", + "BR", + "BY", + "CA", + "CH", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DE", + "DK", + "DO", + "DZ", + "EC", + "EE", + "EG", + "ES", + "FI", + "FR", + "GB", + "GE", + "GH", + "GR", + "GT", + "HK", + "HN", + "HR", + "HU", + "ID", + "IE", + "IL", + "IN", + "IQ", + "IS", + "IT", + "JM", + "JO", + "JP", + "KE", + "KH", + "KR", + "KW", + "KZ", + "LA", + "LB", + "LI", + "LK", + "LT", + "LU", + "LV", + "LY", + "MA", + "ME", + "MK", + "MT", + "MX", + "MY", + "NG", + "NI", + "NL", + "NO", + "NP", + "NZ", + "OM", + "PA", + "PE", + "PG", + "PH", + "PK", + "PL", + "PR", + "PT", + "PY", + "QA", + "RO", + "RS", + "RU", + "SA", + "SE", + "SG", + "SI", + "SK", + "SN", + "SV", + "TH", + "TN", + "TR", + "TW", + "TZ", + "UA", + "UG", + "US", + "UY", + "VE", + "VN", + "YE", + "ZA", + "ZW", } OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT" diff --git a/ytmusicapi/continuations.py b/ytmusicapi/continuations.py index f0793941..20497983 100644 --- a/ytmusicapi/continuations.py +++ b/ytmusicapi/continuations.py @@ -1,20 +1,19 @@ from ytmusicapi.navigation import nav -def get_continuations(results, - continuation_type, - limit, - request_func, - parse_func, - ctoken_path="", - reloadable=False): +def get_continuations( + results, continuation_type, limit, request_func, parse_func, ctoken_path="", reloadable=False +): items = [] - while 'continuations' in results and (limit is None or len(items) < limit): - additionalParams = get_reloadable_continuation_params(results) if reloadable \ + while "continuations" in results and (limit is None or len(items) < limit): + additionalParams = ( + get_reloadable_continuation_params(results) + if reloadable else get_continuation_params(results, ctoken_path) + ) response = request_func(additionalParams) - if 'continuationContents' in response: - results = response['continuationContents'][continuation_type] + if "continuationContents" in response: + results = response["continuationContents"][continuation_type] else: break contents = get_continuation_contents(results, parse_func) @@ -25,42 +24,38 @@ def get_continuations(results, return items -def get_validated_continuations(results, - continuation_type, - limit, - per_page, - request_func, - parse_func, - ctoken_path=""): +def get_validated_continuations( + results, continuation_type, limit, per_page, request_func, parse_func, ctoken_path="" +): items = [] - while 'continuations' in results and len(items) < limit: + while "continuations" in results and len(items) < limit: additionalParams = get_continuation_params(results, ctoken_path) wrapped_parse_func = lambda raw_response: get_parsed_continuation_items( - raw_response, parse_func, continuation_type) + raw_response, parse_func, continuation_type + ) validate_func = lambda parsed: validate_response(parsed, per_page, limit, len(items)) - response = resend_request_until_parsed_response_is_valid(request_func, additionalParams, - wrapped_parse_func, validate_func, - 3) - results = response['results'] - items.extend(response['parsed']) + response = resend_request_until_parsed_response_is_valid( + request_func, additionalParams, wrapped_parse_func, validate_func, 3 + ) + results = response["results"] + items.extend(response["parsed"]) return items def get_parsed_continuation_items(response, parse_func, continuation_type): - results = response['continuationContents'][continuation_type] - return {'results': results, 'parsed': get_continuation_contents(results, parse_func)} + results = response["continuationContents"][continuation_type] + return {"results": results, "parsed": get_continuation_contents(results, parse_func)} -def get_continuation_params(results, ctoken_path=''): - ctoken = nav(results, - ['continuations', 0, 'next' + ctoken_path + 'ContinuationData', 'continuation']) +def get_continuation_params(results, ctoken_path=""): + ctoken = nav(results, ["continuations", 0, "next" + ctoken_path + "ContinuationData", "continuation"]) return get_continuation_string(ctoken) def get_reloadable_continuation_params(results): - ctoken = nav(results, ['continuations', 0, 'reloadContinuationData', 'continuation']) + ctoken = nav(results, ["continuations", 0, "reloadContinuationData", "continuation"]) return get_continuation_string(ctoken) @@ -69,22 +64,23 @@ def get_continuation_string(ctoken): def get_continuation_contents(continuation, parse_func): - for term in ['contents', 'items']: + for term in ["contents", "items"]: if term in continuation: return parse_func(continuation[term]) return [] -def resend_request_until_parsed_response_is_valid(request_func, request_additional_params, - parse_func, validate_func, max_retries): +def resend_request_until_parsed_response_is_valid( + request_func, request_additional_params, parse_func, validate_func, max_retries +): response = request_func(request_additional_params) parsed_object = parse_func(response) retry_counter = 0 while not validate_func(parsed_object) and retry_counter < max_retries: response = request_func(request_additional_params) attempt = parse_func(response) - if len(attempt['parsed']) > len(parsed_object['parsed']): + if len(attempt["parsed"]) > len(parsed_object["parsed"]): parsed_object = attempt retry_counter += 1 @@ -96,4 +92,4 @@ def validate_response(response, per_page, limit, current_count): expected_items_count = min(per_page, remaining_items_count) # response is invalid, if it has less items then minimal expected count - return len(response['parsed']) >= expected_items_count + return len(response["parsed"]) >= expected_items_count diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 743c86ac..40eec562 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -16,36 +16,36 @@ def initialize_headers(): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } def initialize_context(): return { - 'context': { - 'client': { - 'clientName': 'WEB_REMIX', - 'clientVersion': '1.' + time.strftime("%Y%m%d", time.gmtime()) + '.01.00' + "context": { + "client": { + "clientName": "WEB_REMIX", + "clientVersion": "1." + time.strftime("%Y%m%d", time.gmtime()) + ".01.00", }, - 'user': {} + "user": {}, } } def get_visitor_id(request_func): response = request_func(YTM_DOMAIN) - matches = re.findall(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', response.text) + matches = re.findall(r"ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;", response.text) visitor_id = "" if len(matches) > 0: ytcfg = json.loads(matches[0]) - visitor_id = ytcfg.get('VISITOR_DATA') - return {'X-Goog-Visitor-Id': visitor_id} + visitor_id = ytcfg.get("VISITOR_DATA") + return {"X-Goog-Visitor-Id": visitor_id} def sapisid_from_cookie(raw_cookie): cookie = SimpleCookie() - cookie.load(raw_cookie.replace("\"", "")) - return cookie['__Secure-3PAPISID'].value + cookie.load(raw_cookie.replace('"', "")) + return cookie["__Secure-3PAPISID"].value # SAPISID Hash reverse engineered by @@ -53,24 +53,22 @@ def sapisid_from_cookie(raw_cookie): def get_authorization(auth): sha_1 = sha1() unix_timestamp = str(int(time.time())) - sha_1.update((unix_timestamp + ' ' + auth).encode('utf-8')) + sha_1.update((unix_timestamp + " " + auth).encode("utf-8")) return "SAPISIDHASH " + unix_timestamp + "_" + sha_1.hexdigest() def to_int(string): string = unicodedata.normalize("NFKD", string) - number_string = re.sub(r'\D', '', string) + number_string = re.sub(r"\D", "", string) try: int_value = locale.atoi(number_string) except ValueError: - number_string = number_string.replace(',', '') + number_string = number_string.replace(",", "") int_value = int(number_string) return int_value def sum_total_duration(item): - if 'tracks' not in item: + if "tracks" not in item: return 0 - return sum([ - track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks'] - ]) + return sum([track["duration_seconds"] if "duration_seconds" in track else 0 for track in item["tracks"]]) diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index a0c6d4cb..69627f04 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -3,36 +3,37 @@ def prepare_like_endpoint(rating): - if rating == 'LIKE': - return 'like/like' - elif rating == 'DISLIKE': - return 'like/dislike' - elif rating == 'INDIFFERENT': - return 'like/removelike' + if rating == "LIKE": + return "like/like" + elif rating == "DISLIKE": + return "like/dislike" + elif rating == "INDIFFERENT": + return "like/removelike" else: return None def validate_order_parameter(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order and order not in orders: raise Exception( "Invalid order provided. Please use one of the following orders or leave out the parameter: " - + ', '.join(orders)) + + ", ".join(orders) + ) def prepare_order_params(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order is not None: # determine order_params via `.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[1].itemSectionRenderer.header.itemSectionTabbedHeaderRenderer.endItems[1].dropdownRenderer.entries[].dropdownItemRenderer.onSelectCommand.browseEndpoint.params` of `/youtubei/v1/browse` response - order_params = ['ggMGKgQIARAA', 'ggMGKgQIARAB', 'ggMGKgQIABAB'] + order_params = ["ggMGKgQIARAA", "ggMGKgQIARAB", "ggMGKgQIABAB"] return order_params[orders.index(order)] def html_to_txt(html_text): tags = re.findall("<[^>]+>", html_text) for tag in tags: - html_text = html_text.replace(tag, '') + html_text = html_text.replace(tag, "") return html_text diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c8532562..2c99c823 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -11,7 +11,6 @@ class BrowsingMixin: - def get_home(self, limit=3) -> List[Dict]: """ Get the home page. @@ -98,23 +97,24 @@ def get_home(self, limit=3) -> List[Dict]: ] """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_home"} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] home.extend(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) + 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: parse_mixed_content(contents) home.extend( - get_continuations(section_list, 'sectionListContinuation', limit - len(home), - request_func, parse_func)) + get_continuations( + section_list, "sectionListContinuation", limit - len(home), request_func, parse_func + ) + ) return home @@ -212,36 +212,39 @@ def get_artist(self, channelId: str) -> Dict: """ if channelId.startswith("MPLA"): channelId = channelId[4:] - body = {'browseId': channelId} - endpoint = 'browse' + body = {"browseId": channelId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - artist = {'description': None, 'views': None} - header = response['header']['musicImmersiveHeaderRenderer'] - artist['name'] = nav(header, TITLE_TEXT) + artist = {"description": None, "views": None} + header = response["header"]["musicImmersiveHeaderRenderer"] + artist["name"] = nav(header, TITLE_TEXT) descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True) if descriptionShelf: - artist['description'] = nav(descriptionShelf, DESCRIPTION) - artist['views'] = None if 'subheader' not in descriptionShelf else descriptionShelf[ - 'subheader']['runs'][0]['text'] - subscription_button = header['subscriptionButton']['subscribeButtonRenderer'] - artist['channelId'] = subscription_button['channelId'] - artist['shuffleId'] = nav(header, - ['playButton', 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - artist['radioId'] = nav(header, ['startRadioButton', 'buttonRenderer'] - + NAVIGATION_WATCH_PLAYLIST_ID, True) - artist['subscribers'] = nav(subscription_button, - ['subscriberCountText', 'runs', 0, 'text'], True) - artist['subscribed'] = subscription_button['subscribed'] - artist['thumbnails'] = nav(header, THUMBNAILS, True) - artist['songs'] = {'browseId': None} - if 'musicShelfRenderer' in results[0]: # API sometimes does not return songs + artist["description"] = nav(descriptionShelf, DESCRIPTION) + artist["views"] = ( + None + if "subheader" not in descriptionShelf + else descriptionShelf["subheader"]["runs"][0]["text"] + ) + subscription_button = header["subscriptionButton"]["subscribeButtonRenderer"] + artist["channelId"] = subscription_button["channelId"] + artist["shuffleId"] = nav( + header, ["playButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["radioId"] = nav( + header, ["startRadioButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["subscribers"] = nav(subscription_button, ["subscriberCountText", "runs", 0, "text"], True) + artist["subscribed"] = subscription_button["subscribed"] + artist["thumbnails"] = nav(header, THUMBNAILS, True) + artist["songs"] = {"browseId": None} + if "musicShelfRenderer" in results[0]: # API sometimes does not return songs musicShelf = nav(results[0], MUSIC_SHELF) - if 'navigationEndpoint' in nav(musicShelf, TITLE): - artist['songs']['browseId'] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) - artist['songs']['results'] = parse_playlist_items(musicShelf['contents']) + if "navigationEndpoint" in nav(musicShelf, TITLE): + artist["songs"]["browseId"] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) + artist["songs"]["results"] = parse_playlist_items(musicShelf["contents"]) artist.update(self.parser.parse_artist_contents(results)) return artist @@ -257,7 +260,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: """ body = {"browseId": channelId, "params": params} - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) results = nav(results, GRID_ITEMS, True) or nav(results, CAROUSEL_CONTENTS) @@ -313,10 +316,10 @@ def get_user(self, channelId: str) -> Dict: } } """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": channelId} response = self._send_request(endpoint, body) - user = {'name': nav(response, ['header', 'musicVisualHeaderRenderer'] + TITLE_TEXT)} + user = {"name": nav(response, ["header", "musicVisualHeaderRenderer"] + TITLE_TEXT)} results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) user.update(self.parser.parse_artist_contents(results)) return user @@ -331,8 +334,8 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: :return: List of user playlists in the format of :py:func:`get_library_playlists` """ - endpoint = 'browse' - body = {"browseId": channelId, 'params': params} + endpoint = "browse" + body = {"browseId": channelId, "params": params} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS) user_playlists = parse_content_list(results, parse_playlist) @@ -415,19 +418,19 @@ def get_album(self, browseId: str) -> Dict: "duration_seconds": 4657 } """ - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_playlist_items(results['contents']) + album["tracks"] = parse_playlist_items(results["contents"]) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST + [1] + CAROUSEL, True) if results is not None: - album['other_versions'] = parse_content_list(results['contents'], parse_album) - album['duration_seconds'] = sum_total_duration(album) - for i, track in enumerate(album['tracks']): - album['tracks'][i]['album'] = album['title'] - album['tracks'][i]['artists'] = album['tracks'][i]['artists'] or album['artists'] + album["other_versions"] = parse_content_list(results["contents"], parse_album) + album["duration_seconds"] = sum_total_duration(album) + for i, track in enumerate(album["tracks"]): + album["tracks"][i]["album"] = album["title"] + album["tracks"][i]["artists"] = album["tracks"][i]["artists"] or album["artists"] return album @@ -602,22 +605,16 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: } """ - endpoint = 'player' + endpoint = "player" if not signatureTimestamp: signatureTimestamp = get_datestamp() - 1 params = { - "playbackContext": { - "contentPlaybackContext": { - "signatureTimestamp": signatureTimestamp - } - }, - "video_id": videoId + "playbackContext": {"contentPlaybackContext": {"signatureTimestamp": signatureTimestamp}}, + "video_id": videoId, } response = self._send_request(endpoint, params) - keys = [ - 'videoDetails', 'playabilityStatus', 'streamingData', 'microformat', 'playbackTracking' - ] + keys = ["videoDetails", "playabilityStatus", "streamingData", "microformat", "playbackTracking"] for k in list(response.keys()): if k not in keys: del response[k] @@ -700,8 +697,8 @@ def get_song_related(self, browseId: str): if not browseId: raise Exception("Invalid browseId provided.") - response = self._send_request('browse', {'browseId': browseId}) - sections = nav(response, ['contents'] + SECTION_LIST) + response = self._send_request("browse", {"browseId": browseId}) + sections = nav(response, ["contents"] + SECTION_LIST) return parse_mixed_content(sections) def get_lyrics(self, browseId: str) -> Dict: @@ -723,12 +720,13 @@ def get_lyrics(self, browseId: str) -> Dict: if not browseId: raise Exception("Invalid browseId provided. This song might not have lyrics.") - response = self._send_request('browse', {'browseId': browseId}) - lyrics['lyrics'] = nav(response, - ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, - True) - lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF - + ['footer'] + RUN_TEXT, True) + response = self._send_request("browse", {"browseId": browseId}) + lyrics["lyrics"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, True + ) + lyrics["source"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + ["footer"] + RUN_TEXT, True + ) return lyrics @@ -782,7 +780,7 @@ def get_tasteprofile(self) -> Dict: """ - response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) + response = self._send_request("browse", {"browseId": "FEmusic_tastebuilder"}) profiles = nav(response, TASTE_PROFILE_ITEMS) taste_profiles = {} @@ -791,7 +789,7 @@ def get_tasteprofile(self) -> Dict: artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] taste_profiles[artist] = { "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], - "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] + "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"], } return taste_profiles @@ -809,9 +807,8 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No if taste_profile is None: taste_profile = self.get_tasteprofile() formData = { - "impressionValues": - [taste_profile[profile]["impressionValue"] for profile in taste_profile], - "selectedValues": [] + "impressionValues": [taste_profile[profile]["impressionValue"] for profile in taste_profile], + "selectedValues": [], } for artist in artists: @@ -819,5 +816,5 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No raise Exception("The artist, {}, was not present in taste!".format(artist)) formData["selectedValues"].append(taste_profile[artist]["selectionValue"]) - body = {'browseId': "FEmusic_home", "formData": formData} - self._send_request('browse', body) + body = {"browseId": "FEmusic_home", "formData": formData} + self._send_request("browse", body) diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index 0c983b1a..c56266c2 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -4,7 +4,6 @@ class ExploreMixin: - def get_mood_categories(self) -> Dict: """ Fetch "Moods & Genres" categories from YouTube Music. @@ -51,15 +50,14 @@ def get_mood_categories(self) -> Dict: """ sections = {} - response = self._send_request('browse', {'browseId': 'FEmusic_moods_and_genres'}) + response = self._send_request("browse", {"browseId": "FEmusic_moods_and_genres"}) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): - title = nav(section, GRID + ['header', 'gridHeaderRenderer'] + TITLE_TEXT) + title = nav(section, GRID + ["header", "gridHeaderRenderer"] + TITLE_TEXT) sections[title] = [] for category in nav(section, GRID_ITEMS): - sections[title].append({ - "title": nav(category, CATEGORY_TITLE), - "params": nav(category, CATEGORY_PARAMS) - }) + sections[title].append( + {"title": nav(category, CATEGORY_TITLE), "params": nav(category, CATEGORY_PARAMS)} + ) return sections @@ -72,25 +70,24 @@ def get_mood_playlists(self, params: str) -> List[Dict]: """ playlists = [] - response = self._send_request('browse', { - 'browseId': 'FEmusic_moods_and_genres_category', - 'params': params - }) + response = self._send_request( + "browse", {"browseId": "FEmusic_moods_and_genres_category", "params": params} + ) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): path = [] - if 'gridRenderer' in section: + if "gridRenderer" in section: path = GRID_ITEMS - elif 'musicCarouselShelfRenderer' in section: + elif "musicCarouselShelfRenderer" in section: path = CAROUSEL_CONTENTS - elif 'musicImmersiveCarouselShelfRenderer' in section: - path = ['musicImmersiveCarouselShelfRenderer', 'contents'] + elif "musicImmersiveCarouselShelfRenderer" in section: + path = ["musicImmersiveCarouselShelfRenderer", "contents"] if len(path): results = nav(section, path) playlists += parse_content_list(results, parse_playlist) return playlists - def get_charts(self, country: str = 'ZZ') -> Dict: + def get_charts(self, country: str = "ZZ") -> Dict: """ Get latest charts data from YouTube Music: Top songs, top videos, top artists and top trending videos. Global charts have no Trending section, US charts have an extra Genres section with some Genre charts. @@ -190,58 +187,69 @@ def get_charts(self, country: str = 'ZZ') -> Dict: } """ - body = {'browseId': 'FEmusic_charts'} + body = {"browseId": "FEmusic_charts"} if country: - body['formData'] = {'selectedValues': [country]} + body["formData"] = {"selectedValues": [country]} - response = self._send_request('browse', body) + response = self._send_request("browse", body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - charts = {'countries': {}} + charts = {"countries": {}} menu = nav( - results[0], MUSIC_SHELF + [ - 'subheaders', 0, 'musicSideAlignedItemRenderer', 'startItems', 0, - 'musicSortFilterButtonRenderer' - ]) - charts['countries']['selected'] = nav(menu, TITLE) - charts['countries']['options'] = list( - filter(None, [ - nav(m, ['payload', 'musicFormBooleanChoice', 'opaqueToken'], True) - for m in nav(response, FRAMEWORK_MUTATIONS) - ])) - charts_categories = ['videos', 'artists'] - - has_genres = country == 'US' - has_trending = country != 'ZZ' + results[0], + MUSIC_SHELF + + [ + "subheaders", + 0, + "musicSideAlignedItemRenderer", + "startItems", + 0, + "musicSortFilterButtonRenderer", + ], + ) + charts["countries"]["selected"] = nav(menu, TITLE) + charts["countries"]["options"] = list( + filter( + None, + [ + nav(m, ["payload", "musicFormBooleanChoice", "opaqueToken"], True) + for m in nav(response, FRAMEWORK_MUTATIONS) + ], + ) + ) + charts_categories = ["videos", "artists"] + + has_genres = country == "US" + has_trending = country != "ZZ" # use result length to determine if songs category is present # could also be done via an is_premium attribute on YTMusic instance has_songs = (len(results) - 1) > (len(charts_categories) + has_genres + has_trending) if has_songs: - charts_categories.insert(0, 'songs') + charts_categories.insert(0, "songs") if has_genres: - charts_categories.append('genres') + charts_categories.append("genres") if has_trending: - charts_categories.append('trending') + charts_categories.append("trending") parse_chart = lambda i, parse_func, key: parse_content_list( - nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key) + nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key + ) for i, c in enumerate(charts_categories): charts[c] = { - 'playlist': nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, - True) + "playlist": nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, True) } if has_songs: - charts['songs'].update({'items': parse_chart(0, parse_chart_song, MRLIR)}) + charts["songs"].update({"items": parse_chart(0, parse_chart_song, MRLIR)}) - charts['videos']['items'] = parse_chart(1, parse_video, MTRIR) - charts['artists']['items'] = parse_chart(2, parse_chart_artist, MRLIR) + charts["videos"]["items"] = parse_chart(1, parse_video, MTRIR) + charts["artists"]["items"] = parse_chart(2, parse_chart_artist, MRLIR) if has_genres: - charts['genres'] = parse_chart(3, parse_playlist, MTRIR) + charts["genres"] = parse_chart(3, parse_playlist, MTRIR) if has_trending: - charts['trending']['items'] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) + charts["trending"]["items"] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) return charts diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index f452ce72..3633960e 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -9,7 +9,6 @@ class LibraryMixin: - def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. @@ -27,28 +26,26 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_playlists'} - endpoint = 'browse' + body = {"browseId": "FEmusic_liked_playlists"} + endpoint = "browse" response = self._send_request(endpoint, body) results = get_library_contents(response, GRID) - playlists = parse_content_list(results['items'][1:], parse_playlist) + playlists = parse_content_list(results["items"][1:], parse_playlist) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_content_list(contents, parse_playlist) remaining_limit = None if limit is None else (limit - len(playlists)) playlists.extend( - get_continuations(results, 'gridContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return playlists - def get_library_songs(self, - limit: int = 25, - validate_responses: bool = False, - order: str = None) -> List[Dict]: + def get_library_songs( + self, limit: int = 25, validate_responses: bool = False, order: str = None + ) -> List[Dict]: """ Gets the songs in the user's library (liked videos are not included). To get liked songs and videos, use :py:func:`get_liked_songs` @@ -60,11 +57,11 @@ def get_library_songs(self, :return: List of songs. Same format as :py:func:`get_playlist` """ self._check_auth() - body = {'browseId': 'FEmusic_liked_videos'} + body = {"browseId": "FEmusic_liked_videos"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" per_page = 25 request_func = lambda additionalParams: self._send_request(endpoint, body) @@ -75,32 +72,45 @@ def get_library_songs(self, if validate_responses: validate_func = lambda parsed: validate_response(parsed, per_page, limit, 0) - response = resend_request_until_parsed_response_is_valid(request_func, None, - parse_func, validate_func, 3) + response = resend_request_until_parsed_response_is_valid( + request_func, None, parse_func, validate_func, 3 + ) else: response = parse_func(request_func(None)) - results = response['results'] - songs = response['parsed'] + results = response["results"] + songs = response["parsed"] if songs is None: return [] - if 'continuations' in results: + if "continuations" in results: request_continuations_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + endpoint, body, additionalParams + ) parse_continuations_func = lambda contents: parse_playlist_items(contents) if validate_responses: songs.extend( - get_validated_continuations(results, 'musicShelfContinuation', - limit - len(songs), per_page, - request_continuations_func, - parse_continuations_func)) + get_validated_continuations( + results, + "musicShelfContinuation", + limit - len(songs), + per_page, + request_continuations_func, + parse_continuations_func, + ) + ) else: remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, - request_continuations_func, parse_continuations_func)) + get_continuations( + results, + "musicShelfContinuation", + remaining_limit, + request_continuations_func, + parse_continuations_func, + ) + ) return songs @@ -128,16 +138,16 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_albums'} + body = {"browseId": "FEmusic_liked_albums"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -157,15 +167,15 @@ def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_track_artists'} + body = {"browseId": "FEmusic_library_corpus_track_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -176,15 +186,15 @@ def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[ :return: List of artists. Same format as :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_artists'} + body = {"browseId": "FEmusic_library_corpus_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_liked_songs(self, limit: int = 100) -> Dict: """ @@ -193,7 +203,7 @@ def get_liked_songs(self, limit: int = 100) -> Dict: :param limit: How many items to return. Default: 100 :return: List of playlistItem dictionaries. See :py:func:`get_playlist` """ - return self.get_playlist('LM', limit) + return self.get_playlist("LM", limit) def get_history(self) -> List[Dict]: """ @@ -204,20 +214,20 @@ def get_history(self) -> List[Dict]: The additional property ``feedbackToken`` can be used to remove items with :py:func:`remove_history_items` """ self._check_auth() - body = {'browseId': 'FEmusic_history'} - endpoint = 'browse' + body = {"browseId": "FEmusic_history"} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) songs = [] for content in results: - data = nav(content, MUSIC_SHELF + ['contents'], True) + data = nav(content, MUSIC_SHELF + ["contents"], True) if not data: - error = nav(content, ['musicNotifierShelfRenderer'] + TITLE, True) + error = nav(content, ["musicNotifierShelfRenderer"] + TITLE, True) raise Exception(error) menu_entries = [[-1] + MENU_SERVICE + FEEDBACK_TOKEN] songlist = parse_playlist_items(data, menu_entries) for song in songlist: - song['played'] = nav(content['musicShelfRenderer'], TITLE_TEXT) + song["played"] = nav(content["musicShelfRenderer"], TITLE_TEXT) songs.extend(songlist) return songs @@ -231,7 +241,7 @@ def add_history_item(self, song): :return: Full response. response.status_code is 204 if successful """ url = song["playbackTracking"]["videostatsPlaybackUrl"]["baseUrl"] - CPNA = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + CPNA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" cpn = "".join((CPNA[randint(0, 256) & 63] for _ in range(0, 16))) params = {"ver": 2, "c": "WEB_REMIX", "cpn": cpn} return self._send_get_request(url, params) @@ -244,13 +254,13 @@ def remove_history_items(self, feedbackTokens: List[str]) -> Dict: # pragma: no :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" response = self._send_request(endpoint, body) return response - def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a song ("thumbs up"/"thumbs down" interactions on YouTube Music) @@ -262,7 +272,7 @@ def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'videoId': videoId}} + body = {"target": {"videoId": videoId}} endpoint = prepare_like_endpoint(rating) if endpoint is None: return @@ -278,11 +288,11 @@ def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" return endpoint if not endpoint else self._send_request(endpoint, body) - def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_playlist(self, playlistId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a playlist/album ("Add to library"/"Remove from library" interactions on YouTube Music) You can also dislike a playlist/album, which has an effect on your recommendations @@ -295,7 +305,7 @@ def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'playlistId': playlistId}} + body = {"target": {"playlistId": playlistId}} endpoint = prepare_like_endpoint(rating) return endpoint if not endpoint else self._send_request(endpoint, body) @@ -307,8 +317,8 @@ def subscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/subscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/subscribe" return self._send_request(endpoint, body) def unsubscribe_artists(self, channelIds: List[str]) -> Dict: @@ -319,6 +329,6 @@ def unsubscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/unsubscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/unsubscribe" return self._send_request(endpoint, body) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index bc34b82a..a7bcc0c4 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -10,12 +10,9 @@ class PlaylistsMixin: - - def get_playlist(self, - playlistId: str, - limit: int = 100, - related: bool = False, - suggestions_limit: int = 0) -> Dict: + def get_playlist( + self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0 + ) -> Dict: """ Returns a list of playlist items @@ -105,57 +102,55 @@ def get_playlist(self, needed for moving/removing playlist items """ browseId = "VL" + playlistId if not playlistId.startswith("VL") else playlistId - body = {'browseId': browseId} - endpoint = 'browse' + 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'] + 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' + header = response["header"]["musicDetailHeaderRenderer"] + playlist["privacy"] = "PUBLIC" else: - header = response['header']['musicEditablePlaylistDetailHeaderRenderer'] - playlist['privacy'] = header['editHeader']['musicPlaylistEditHeaderRenderer'][ - 'privacy'] - header = header['header']['musicDetailHeaderRenderer'] + 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["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) + 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["year"] = nav(header, SUBTITLE3) - playlist['views'] = None - playlist['duration'] = None - if 'runs' in header['secondSubtitle']: - second_subtitle_runs = header['secondSubtitle']['runs'] + 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']) + 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(" ") + 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']) + song_count = len(results["contents"]) - playlist['trackCount'] = song_count + playlist["trackCount"] = song_count - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams - ) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) # suggestions and related are missing e.g. on liked songs - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - playlist['related'] = [] - if 'continuations' in section_list: + section_list = nav(response, SINGLE_COLUMN_TAB + ["sectionListRenderer"]) + playlist["related"] = [] + if "continuations" in section_list: additionalParams = get_continuation_params(section_list) if own_playlist and (suggestions_limit > 0 or related): parse_func = lambda results: parse_playlist_items(results) @@ -163,44 +158,52 @@ def get_playlist(self, continuation = nav(suggested, SECTION_LIST_CONTINUATION) additionalParams = get_continuation_params(continuation) suggestions_shelf = nav(continuation, CONTENT + MUSIC_SHELF) - playlist['suggestions'] = get_continuation_contents(suggestions_shelf, parse_func) + playlist["suggestions"] = get_continuation_contents(suggestions_shelf, parse_func) parse_func = lambda results: parse_playlist_items(results) - playlist['suggestions'].extend( - get_continuations(suggestions_shelf, - 'musicShelfContinuation', - suggestions_limit - len(playlist['suggestions']), - request_func, - parse_func, - reloadable=True)) + playlist["suggestions"].extend( + get_continuations( + suggestions_shelf, + "musicShelfContinuation", + suggestions_limit - len(playlist["suggestions"]), + request_func, + parse_func, + reloadable=True, + ) + ) if related: response = request_func(additionalParams) continuation = nav(response, SECTION_LIST_CONTINUATION, True) if continuation: parse_func = lambda results: parse_content_list(results, parse_playlist) - playlist['related'] = get_continuation_contents( - nav(continuation, CONTENT + CAROUSEL), parse_func) + playlist["related"] = get_continuation_contents( + nav(continuation, CONTENT + CAROUSEL), parse_func + ) - playlist['tracks'] = [] - if 'contents' in results: - playlist['tracks'] = parse_playlist_items(results['contents']) + playlist["tracks"] = [] + if "contents" in results: + playlist["tracks"] = parse_playlist_items(results["contents"]) parse_func = lambda contents: parse_playlist_items(contents) - if 'continuations' in results: - playlist['tracks'].extend( - get_continuations(results, 'musicPlaylistShelfContinuation', limit, - request_func, parse_func)) - - playlist['duration_seconds'] = sum_total_duration(playlist) + if "continuations" in results: + playlist["tracks"].extend( + get_continuations( + results, "musicPlaylistShelfContinuation", limit, request_func, parse_func + ) + ) + + playlist["duration_seconds"] = sum_total_duration(playlist) return playlist - def create_playlist(self, - title: str, - description: str, - privacy_status: str = "PRIVATE", - video_ids: List = None, - source_playlist: str = None) -> Union[str, Dict]: + def create_playlist( + self, + title: str, + description: str, + privacy_status: str = "PRIVATE", + video_ids: List = None, + source_playlist: str = None, + ) -> Union[str, Dict]: """ Creates a new empty playlist and returns its id. @@ -213,28 +216,30 @@ def create_playlist(self, """ self._check_auth() body = { - 'title': title, - 'description': html_to_txt(description), # YT does not allow HTML tags - 'privacyStatus': privacy_status + "title": title, + "description": html_to_txt(description), # YT does not allow HTML tags + "privacyStatus": privacy_status, } if video_ids is not None: - body['videoIds'] = video_ids + body["videoIds"] = video_ids if source_playlist is not None: - body['sourcePlaylistId'] = source_playlist + body["sourcePlaylistId"] = source_playlist - endpoint = 'playlist/create' + endpoint = "playlist/create" response = self._send_request(endpoint, body) - return response['playlistId'] if 'playlistId' in response else response - - def edit_playlist(self, - playlistId: str, - title: str = None, - description: str = None, - privacyStatus: str = None, - moveItem: Tuple[str, str] = None, - addPlaylistId: str = None, - addToTop: Optional[bool] = None) -> Union[str, Dict]: + return response["playlistId"] if "playlistId" in response else response + + def edit_playlist( + self, + playlistId: str, + title: str = None, + description: str = None, + privacyStatus: str = None, + moveItem: Tuple[str, str] = None, + addPlaylistId: str = None, + addToTop: Optional[bool] = None, + ) -> Union[str, Dict]: """ Edit title, description or privacyStatus of a playlist. You may also move an item within a playlist or append another playlist to this playlist. @@ -250,43 +255,39 @@ def edit_playlist(self, :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} + body = {"playlistId": validate_playlist_id(playlistId)} actions = [] if title: - actions.append({'action': 'ACTION_SET_PLAYLIST_NAME', 'playlistName': title}) + actions.append({"action": "ACTION_SET_PLAYLIST_NAME", "playlistName": title}) if description: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_DESCRIPTION', - 'playlistDescription': description - }) + actions.append({"action": "ACTION_SET_PLAYLIST_DESCRIPTION", "playlistDescription": description}) if privacyStatus: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_PRIVACY', - 'playlistPrivacy': privacyStatus - }) + actions.append({"action": "ACTION_SET_PLAYLIST_PRIVACY", "playlistPrivacy": privacyStatus}) if moveItem: - actions.append({ - 'action': 'ACTION_MOVE_VIDEO_BEFORE', - 'setVideoId': moveItem[0], - 'movedSetVideoIdSuccessor': moveItem[1] - }) + actions.append( + { + "action": "ACTION_MOVE_VIDEO_BEFORE", + "setVideoId": moveItem[0], + "movedSetVideoIdSuccessor": moveItem[1], + } + ) if addPlaylistId: - actions.append({'action': 'ACTION_ADD_PLAYLIST', 'addedFullListId': addPlaylistId}) + actions.append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": addPlaylistId}) if addToTop: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': 'true'}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": "true"}) if addToTop is not None: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': str(addToTop)}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": str(addToTop)}) - body['actions'] = actions - endpoint = 'browse/edit_playlist' + body["actions"] = actions + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response def delete_playlist(self, playlistId: str) -> Union[str, Dict]: """ @@ -296,16 +297,18 @@ def delete_playlist(self, playlistId: str) -> Union[str, Dict]: :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} - endpoint = 'playlist/delete' + body = {"playlistId": validate_playlist_id(playlistId)} + endpoint = "playlist/delete" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response - - def add_playlist_items(self, - playlistId: str, - videoIds: List[str] = None, - source_playlist: str = None, - duplicates: bool = False) -> Union[str, Dict]: + return response["status"] if "status" in response else response + + def add_playlist_items( + self, + playlistId: str, + videoIds: List[str] = None, + source_playlist: str = None, + duplicates: bool = False, + ) -> Union[str, Dict]: """ Add songs to an existing playlist @@ -316,32 +319,28 @@ def add_playlist_items(self, :return: Status String and a dict containing the new setVideoId for each videoId or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} if not videoIds and not source_playlist: - raise Exception( - "You must provide either videoIds or a source_playlist to add to the playlist") + raise Exception("You must provide either videoIds or a source_playlist to add to the playlist") if videoIds: for videoId in videoIds: - action = {'action': 'ACTION_ADD_VIDEO', 'addedVideoId': videoId} + action = {"action": "ACTION_ADD_VIDEO", "addedVideoId": videoId} if duplicates: - action['dedupeOption'] = 'DEDUPE_OPTION_SKIP' - body['actions'].append(action) + action["dedupeOption"] = "DEDUPE_OPTION_SKIP" + body["actions"].append(action) if source_playlist: - body['actions'].append({ - 'action': 'ACTION_ADD_PLAYLIST', - 'addedFullListId': source_playlist - }) + body["actions"].append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": source_playlist}) # add an empty ACTION_ADD_VIDEO because otherwise # YTM doesn't return the dict that maps videoIds to their new setVideoIds if not videoIds: - body['actions'].append({'action': 'ACTION_ADD_VIDEO', 'addedVideoId': None}) + body["actions"].append({"action": "ACTION_ADD_VIDEO", "addedVideoId": None}) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - if 'status' in response and 'SUCCEEDED' in response['status']: + if "status" in response and "SUCCEEDED" in response["status"]: result_dict = [ result_data.get("playlistEditVideoAddedResultData") for result_data in response.get("playlistEditResults", []) @@ -360,19 +359,20 @@ def remove_playlist_items(self, playlistId: str, videos: List[Dict]) -> Union[st :return: Status String or full response """ self._check_auth() - videos = list(filter(lambda x: 'videoId' in x and 'setVideoId' in x, videos)) + videos = list(filter(lambda x: "videoId" in x and "setVideoId" in x, videos)) if len(videos) == 0: - raise Exception( - "Cannot remove songs, because setVideoId is missing. Do you own this playlist?") + raise Exception("Cannot remove songs, because setVideoId is missing. Do you own this playlist?") - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} for video in videos: - body['actions'].append({ - 'setVideoId': video['setVideoId'], - 'removedVideoId': video['videoId'], - 'action': 'ACTION_REMOVE_VIDEO' - }) + body["actions"].append( + { + "setVideoId": video["setVideoId"], + "removedVideoId": video["videoId"], + "action": "ACTION_REMOVE_VIDEO", + } + ) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 349eb115..783a48b3 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -5,13 +5,14 @@ class SearchMixin: - - def search(self, - query: str, - filter: str = None, - scope: str = None, - limit: int = 20, - ignore_spelling: bool = False) -> List[Dict]: + 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. @@ -131,79 +132,94 @@ def search(self, """ - body = {'query': query} - endpoint = 'search' + body = {"query": query} + endpoint = "search" search_results = [] filters = [ - 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos', 'profiles', 'podcasts', 'episodes' + "albums", + "artists", + "playlists", + "community_playlists", + "featured_playlists", + "songs", + "videos", + "profiles", + "podcasts", + "episodes", ] 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)) + + ", ".join(filters) + ) - scopes = ['library', 'uploads'] + 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)) + + ", ".join(scopes) + ) if scope == scopes[1] and filter: raise Exception( "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " - "uploads. ") + "uploads. " + ) if scope == scopes[0] and filter in filters[3:5]: - raise Exception(f"{filter} cannot be set when searching library. " - f"Please use one of the following filters or leave out the parameter: " - + ', '.join(filters[0:3] + filters[5:])) + raise Exception( + f"{filter} cannot be set when searching library. " + f"Please use one of the following filters or leave out the parameter: " + + ", ".join(filters[0:3] + filters[5:]) + ) params = get_search_params(filter, scope, ignore_spelling) if params: - body['params'] = params + body["params"] = params response = self._send_request(endpoint, body) # no results - if 'contents' not in response: + if "contents" not in response: return search_results - if 'tabbedSearchResultsRenderer' in response['contents']: + 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'] + results = response["contents"]["tabbedSearchResultsRenderer"]["tabs"][tab_index]["tabRenderer"][ + "content" + ] else: - results = response['contents'] + results = response["contents"] results = nav(results, SECTION_LIST) # no results - if len(results) == 1 and 'itemSectionRenderer' in results: + if len(results) == 1 and "itemSectionRenderer" in results: return search_results # set filter for parser - if filter and 'playlists' in filter: - filter = 'playlists' + if filter and "playlists" in filter: + filter = "playlists" elif scope == scopes[1]: filter = scopes[1] for res in results: - if 'musicCardShelfRenderer' in res: - top_result = parse_top_result(res['musicCardShelfRenderer'], - self.parser.get_search_result_types()) + if "musicCardShelfRenderer" in res: + top_result = parse_top_result( + res["musicCardShelfRenderer"], self.parser.get_search_result_types() + ) search_results.append(top_result) - if results := nav(res, ['musicCardShelfRenderer', 'contents'], True): + if results := nav(res, ["musicCardShelfRenderer", "contents"], True): category = None # category "more from youtube" is missing sometimes - if 'messageRenderer' in results[0]: - category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT) + if "messageRenderer" in results[0]: + category = nav(results.pop(0), ["messageRenderer"] + TEXT_RUN_TEXT) type = None else: continue - elif 'musicShelfRenderer' in res: - results = res['musicShelfRenderer']['contents'] + elif "musicShelfRenderer" in res: + results = res["musicShelfRenderer"]["contents"] type_filter = filter category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) if not type_filter and scope == scopes[0]: @@ -215,8 +231,7 @@ def search(self, continue search_result_types = self.parser.get_search_result_types() - search_results.extend( - parse_search_results(results, search_result_types, type, category)) + search_results.extend(parse_search_results(results, search_result_types, type, category)) if filter: # if filter is set, there are continuations @@ -227,14 +242,18 @@ def parse_func(contents): return parse_search_results(contents, search_result_types, type, category) search_results.extend( - get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', - limit - len(search_results), request_func, parse_func)) + get_continuations( + res["musicShelfRenderer"], + "musicShelfContinuation", + limit - len(search_results), + request_func, + parse_func, + ) + ) return search_results - def get_search_suggestions(self, - query: str, - detailed_runs=False) -> Union[List[str], List[Dict]]: + def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List[str], List[Dict]]: """ Get Search Suggestions @@ -301,8 +320,8 @@ def get_search_suggestions(self, ] """ - body = {'input': query} - endpoint = 'music/get_search_suggestions' + body = {"input": query} + endpoint = "music/get_search_suggestions" response = self._send_request(endpoint, body) search_suggestions = parse_search_suggestions(response, detailed_runs) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index e912f5e9..1f87842d 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -21,7 +21,6 @@ class UploadsMixin: - def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[Dict]: """ Returns a list of uploaded songs @@ -46,7 +45,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D } """ self._check_auth() - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_library_privately_owned_tracks"} validate_order_parameter(order) if order is not None: @@ -56,15 +55,16 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D if results is None: return [] pop_songs_random_mix(results) - songs = parse_uploaded_items(results['contents']) + songs = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_uploaded_items)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_uploaded_items + ) + ) return songs @@ -77,15 +77,15 @@ def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[ :return: List of albums as returned by :py:func:`get_library_albums` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_releases'} + body = {"browseId": "FEmusic_library_privately_owned_releases"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -96,15 +96,15 @@ def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List :return: List of artists as returned by :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_artists'} + body = {"browseId": "FEmusic_library_privately_owned_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict]: """ @@ -134,23 +134,24 @@ def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict ] """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - if len(results['contents']) > 1: - results['contents'].pop(0) + if len(results["contents"]) > 1: + results["contents"].pop(0) - items = parse_uploaded_items(results['contents']) + items = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_uploaded_items(contents) remaining_limit = None if limit is None else (limit - len(items)) items.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_func + ) + ) return items @@ -187,13 +188,13 @@ def get_library_upload_album(self, browseId: str) -> Dict: }, """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_uploaded_items(results['contents']) - album['duration_seconds'] = sum_total_duration(album) + album["tracks"] = parse_uploaded_items(results["contents"]) + album["duration_seconds"] = sum_total_duration(album) return album def upload_song(self, filepath: str) -> Union[str, requests.Response]: @@ -213,27 +214,29 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: if os.path.splitext(filepath)[1][1:] not in supported_filetypes: raise Exception( "The provided file type is not supported by YouTube Music. Supported file types are " - + ', '.join(supported_filetypes)) + + ", ".join(supported_filetypes) + ) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers[ - 'x-goog-authuser'] + upload_url = ( + "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers["x-goog-authuser"] + ) filesize = os.path.getsize(filepath) - body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') - headers.pop('content-encoding', None) - headers['content-type'] = 'application/x-www-form-urlencoded;charset=utf-8' - headers['X-Goog-Upload-Command'] = 'start' - headers['X-Goog-Upload-Header-Content-Length'] = str(filesize) - headers['X-Goog-Upload-Protocol'] = 'resumable' + body = ("filename=" + ntpath.basename(filepath)).encode("utf-8") + headers.pop("content-encoding", None) + headers["content-type"] = "application/x-www-form-urlencoded;charset=utf-8" + headers["X-Goog-Upload-Command"] = "start" + headers["X-Goog-Upload-Header-Content-Length"] = str(filesize) + headers["X-Goog-Upload-Protocol"] = "resumable" response = requests.post(upload_url, data=body, headers=headers, proxies=self.proxies) - headers['X-Goog-Upload-Command'] = 'upload, finalize' - headers['X-Goog-Upload-Offset'] = '0' - upload_url = response.headers['X-Goog-Upload-URL'] - with open(filepath, 'rb') as file: + headers["X-Goog-Upload-Command"] = "upload, finalize" + headers["X-Goog-Upload-Offset"] = "0" + upload_url = response.headers["X-Goog-Upload-URL"] + with open(filepath, "rb") as file: response = requests.post(upload_url, data=file, headers=headers, proxies=self.proxies) if response.status_code == 200: - return 'STATUS_SUCCEEDED' + return "STATUS_SUCCEEDED" else: return response @@ -246,14 +249,14 @@ def delete_upload_entity(self, entityId: str) -> Union[str, Dict]: # pragma: no :return: Status String or error """ self._check_auth() - endpoint = 'music/delete_privately_owned_entity' - if 'FEmusic_library_privately_owned_release_detail' in entityId: - entityId = entityId.replace('FEmusic_library_privately_owned_release_detail', '') + endpoint = "music/delete_privately_owned_entity" + if "FEmusic_library_privately_owned_release_detail" in entityId: + entityId = entityId.replace("FEmusic_library_privately_owned_release_detail", "") body = {"entityId": entityId} response = self._send_request(endpoint, body) - if 'error' not in response: - return 'STATUS_SUCCEEDED' + if "error" not in response: + return "STATUS_SUCCEEDED" else: - return response['error'] + return response["error"] diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 6fe477d9..4e5fc37a 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -6,12 +6,14 @@ class WatchMixin: - def get_watch_playlist(self, - videoId: str = None, - playlistId: str = None, - limit=25, - radio: bool = False, - shuffle: bool = False) -> Dict[str, Union[List[Dict]]]: + def get_watch_playlist( + self, + videoId: str = None, + playlistId: str = None, + limit=25, + radio: bool = False, + shuffle: bool = False, + ) -> 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. @@ -101,59 +103,71 @@ def get_watch_playlist(self, """ body = { - 'enablePersistentPlaylistPanel': True, - 'isAudioOnly': True, - 'tunerSettingValue': 'AUTOMIX_SETTING_NORMAL' + "enablePersistentPlaylistPanel": True, + "isAudioOnly": True, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", } 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 + body["videoId"] = videoId if not playlistId: playlistId = "RDAMVM" + videoId if not (radio or shuffle): - body['watchEndpointMusicSupportedConfigs'] = { - 'watchEndpointMusicConfig': { - 'hasPersistentPlaylistPanel': True, - 'musicVideoType': "MUSIC_VIDEO_TYPE_ATV", + 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') + body["playlistId"] = validate_playlist_id(playlistId) + is_playlist = body["playlistId"].startswith("PL") or body["playlistId"].startswith("OLA") if shuffle and playlistId is not None: - body['params'] = "wAEB8gECKAE%3D" + body["params"] = "wAEB8gECKAE%3D" if radio: - body['params'] = "wAEB" - endpoint = 'next' + body["params"] = "wAEB" + endpoint = "next" response = self._send_request(endpoint, body) - watchNextRenderer = nav(response, [ - 'contents', 'singleColumnMusicWatchNextResultsRenderer', 'tabbedRenderer', - 'watchNextTabbedResultsRenderer' - ]) + 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']) + 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']) + 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) + 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')) + 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) + return dict(tracks=tracks, playlistId=playlist, lyrics=lyrics_browse_id, related=related_browse_id) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 3357f4d8..6b2bfd8c 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,72 +1,70 @@ # commonly used navigation paths -CONTENT = ['contents', 0] -RUN_TEXT = ['runs', 0, 'text'] -TAB_CONTENT = ['tabs', 0, 'tabRenderer', 'content'] -TAB_1_CONTENT = ['tabs', 1, 'tabRenderer', 'content'] -SINGLE_COLUMN = ['contents', 'singleColumnBrowseResultsRenderer'] +CONTENT = ["contents", 0] +RUN_TEXT = ["runs", 0, "text"] +TAB_CONTENT = ["tabs", 0, "tabRenderer", "content"] +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 -ITEM_SECTION = ['itemSectionRenderer'] + CONTENT -MUSIC_SHELF = ['musicShelfRenderer'] -GRID = ['gridRenderer'] -GRID_ITEMS = GRID + ['items'] -MENU = ['menu', 'menuRenderer'] -MENU_ITEMS = MENU + ['items'] -MENU_LIKE_STATUS = MENU + ['topLevelButtons', 0, 'likeButtonRenderer', 'likeStatus'] -MENU_SERVICE = ['menuServiceItemRenderer', 'serviceEndpoint'] -TOGGLE_MENU = 'toggleMenuServiceItemRenderer' -PLAY_BUTTON = [ - 'overlay', 'musicItemThumbnailOverlayRenderer', 'content', 'musicPlayButtonRenderer' -] -NAVIGATION_BROWSE = ['navigationEndpoint', 'browseEndpoint'] -NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ['browseId'] -PAGE_TYPE = [ - 'browseEndpointContextSupportedConfigs', 'browseEndpointContextMusicConfig', 'pageType' -] -WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] -NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID -QUEUE_VIDEO_ID = ['queueAddEndpoint','queueTarget','videoId'] -NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] -NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] +SECTION_LIST = ["sectionListRenderer", "contents"] +SECTION_LIST_ITEM = ["sectionListRenderer"] + CONTENT +ITEM_SECTION = ["itemSectionRenderer"] + CONTENT +MUSIC_SHELF = ["musicShelfRenderer"] +GRID = ["gridRenderer"] +GRID_ITEMS = GRID + ["items"] +MENU = ["menu", "menuRenderer"] +MENU_ITEMS = MENU + ["items"] +MENU_LIKE_STATUS = MENU + ["topLevelButtons", 0, "likeButtonRenderer", "likeStatus"] +MENU_SERVICE = ["menuServiceItemRenderer", "serviceEndpoint"] +TOGGLE_MENU = "toggleMenuServiceItemRenderer" +PLAY_BUTTON = ["overlay", "musicItemThumbnailOverlayRenderer", "content", "musicPlayButtonRenderer"] +NAVIGATION_BROWSE = ["navigationEndpoint", "browseEndpoint"] +NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ["browseId"] +PAGE_TYPE = ["browseEndpointContextSupportedConfigs", "browseEndpointContextMusicConfig", "pageType"] +WATCH_VIDEO_ID = ["watchEndpoint", "videoId"] +NAVIGATION_VIDEO_ID = ["navigationEndpoint"] + WATCH_VIDEO_ID +QUEUE_VIDEO_ID = ["queueAddEndpoint", "queueTarget", "videoId"] +NAVIGATION_PLAYLIST_ID = ["navigationEndpoint", "watchEndpoint", "playlistId"] +NAVIGATION_WATCH_PLAYLIST_ID = ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"] NAVIGATION_VIDEO_TYPE = [ - 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', - 'musicVideoType' + "watchEndpoint", + "watchEndpointMusicSupportedConfigs", + "watchEndpointMusicConfig", + "musicVideoType", ] -TITLE = ['title', 'runs', 0] -TITLE_TEXT = ['title'] + RUN_TEXT -TEXT_RUNS = ['text', 'runs'] +TITLE = ["title", "runs", 0] +TITLE_TEXT = ["title"] + RUN_TEXT +TEXT_RUNS = ["text", "runs"] TEXT_RUN = TEXT_RUNS + [0] -TEXT_RUN_TEXT = TEXT_RUN + ['text'] -SUBTITLE = ['subtitle'] + RUN_TEXT -SUBTITLE_RUNS = ['subtitle', 'runs'] -SUBTITLE2 = SUBTITLE_RUNS + [2, 'text'] -SUBTITLE3 = SUBTITLE_RUNS + [4, 'text'] -THUMBNAIL = ['thumbnail', 'thumbnails'] -THUMBNAILS = ['thumbnail', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_RENDERER = ['thumbnailRenderer', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_CROPPED = ['thumbnail', 'croppedSquareThumbnailRenderer'] + THUMBNAIL -FEEDBACK_TOKEN = ['feedbackEndpoint', 'feedbackToken'] -BADGE_PATH = [0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label'] -BADGE_LABEL = ['badges'] + BADGE_PATH -SUBTITLE_BADGE_LABEL = ['subtitleBadges'] + BADGE_PATH -CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT -CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] -MRLIR = 'musicResponsiveListItemRenderer' -MTRIR = 'musicTwoRowItemRenderer' +TEXT_RUN_TEXT = TEXT_RUN + ["text"] +SUBTITLE = ["subtitle"] + RUN_TEXT +SUBTITLE_RUNS = ["subtitle", "runs"] +SUBTITLE2 = SUBTITLE_RUNS + [2, "text"] +SUBTITLE3 = SUBTITLE_RUNS + [4, "text"] +THUMBNAIL = ["thumbnail", "thumbnails"] +THUMBNAILS = ["thumbnail", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_RENDERER = ["thumbnailRenderer", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_CROPPED = ["thumbnail", "croppedSquareThumbnailRenderer"] + THUMBNAIL +FEEDBACK_TOKEN = ["feedbackEndpoint", "feedbackToken"] +BADGE_PATH = [0, "musicInlineBadgeRenderer", "accessibilityData", "accessibilityData", "label"] +BADGE_LABEL = ["badges"] + BADGE_PATH +SUBTITLE_BADGE_LABEL = ["subtitleBadges"] + BADGE_PATH +CATEGORY_TITLE = ["musicNavigationButtonRenderer", "buttonText"] + RUN_TEXT +CATEGORY_PARAMS = ["musicNavigationButtonRenderer", "clickCommand", "browseEndpoint", "params"] +MRLIR = "musicResponsiveListItemRenderer" +MTRIR = "musicTwoRowItemRenderer" TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] -SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] -MENU_PLAYLIST_ID = MENU_ITEMS + [0, 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID -HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] -DESCRIPTION_SHELF = ['musicDescriptionShelfRenderer'] -DESCRIPTION = ['description'] + RUN_TEXT -CAROUSEL = ['musicCarouselShelfRenderer'] -IMMERSIVE_CAROUSEL = ['musicImmersiveCarouselShelfRenderer'] -CAROUSEL_CONTENTS = CAROUSEL + ['contents'] -CAROUSEL_TITLE = ['header', 'musicCarouselShelfBasicHeaderRenderer'] + TITLE -CARD_SHELF_TITLE = ['header', 'musicCardShelfHeaderBasicRenderer'] + TITLE_TEXT -FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] +SECTION_LIST_CONTINUATION = ["continuationContents", "sectionListContinuation"] +MENU_PLAYLIST_ID = MENU_ITEMS + [0, "menuNavigationItemRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID +HEADER_DETAIL = ["header", "musicDetailHeaderRenderer"] +DESCRIPTION_SHELF = ["musicDescriptionShelfRenderer"] +DESCRIPTION = ["description"] + RUN_TEXT +CAROUSEL = ["musicCarouselShelfRenderer"] +IMMERSIVE_CAROUSEL = ["musicImmersiveCarouselShelfRenderer"] +CAROUSEL_CONTENTS = CAROUSEL + ["contents"] +CAROUSEL_TITLE = ["header", "musicCarouselShelfBasicHeaderRenderer"] + TITLE +CARD_SHELF_TITLE = ["header", "musicCardShelfHeaderBasicRenderer"] + TITLE_TEXT +FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/_utils.py b/ytmusicapi/parsers/_utils.py index 1200cd50..ccb2efff 100644 --- a/ytmusicapi/parsers/_utils.py +++ b/ytmusicapi/parsers/_utils.py @@ -4,19 +4,19 @@ def parse_menu_playlists(data, result): - watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), 'menuNavigationItemRenderer') - for item in [_x['menuNavigationItemRenderer'] for _x in watch_menu]: - icon = nav(item, ['icon', 'iconType']) - if icon == 'MUSIC_SHUFFLE': - watch_key = 'shuffleId' - elif icon == 'MIX': - watch_key = 'radioId' + watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), "menuNavigationItemRenderer") + for item in [_x["menuNavigationItemRenderer"] for _x in watch_menu]: + icon = nav(item, ["icon", "iconType"]) + if icon == "MUSIC_SHUFFLE": + watch_key = "shuffleId" + elif icon == "MIX": + watch_key = "radioId" else: continue - watch_id = nav(item, ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"], True) if not watch_id: - watch_id = nav(item, ['navigationEndpoint', 'watchEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchEndpoint", "playlistId"], True) if watch_id: result[watch_key] = watch_id @@ -25,39 +25,43 @@ def get_item_text(item, index, run_index=0, none_if_absent=False): column = get_flex_column_item(item, index) if not column: return None - if none_if_absent and len(column['text']['runs']) < run_index + 1: + if none_if_absent and len(column["text"]["runs"]) < run_index + 1: return None - return column['text']['runs'][run_index]['text'] + return column["text"]["runs"][run_index]["text"] def get_flex_column_item(item, index): - if len(item['flexColumns']) <= index or \ - 'text' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] or \ - 'runs' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer']['text']: + if ( + len(item["flexColumns"]) <= index + or "text" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] + or "runs" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"]["text"] + ): return None - return item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] + return item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] def get_fixed_column_item(item, index): - if 'text' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] or \ - 'runs' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer']['text']: + if ( + "text" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] + or "runs" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"]["text"] + ): return None - return item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] + return item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] def get_browse_id(item, index): - if 'navigationEndpoint' not in item['text']['runs'][index]: + if "navigationEndpoint" not in item["text"]["runs"][index]: return None else: - return nav(item['text']['runs'][index], NAVIGATION_BROWSE_ID) + return nav(item["text"]["runs"][index], NAVIGATION_BROWSE_ID) def get_dot_separator_index(runs): index = len(runs) try: - index = runs.index({'text': ' • '}) + index = runs.index({"text": " • "}) except ValueError: len(runs) return index @@ -74,7 +78,7 @@ def parse_duration(duration): def i18n(method): @wraps(method) def _impl(self, *method_args, **method_kwargs): - method.__globals__['_'] = self.lang.gettext + method.__globals__["_"] = self.lang.gettext return method(self, *method_args, **method_kwargs) return _impl diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index a2a4fc01..2d7e6852 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -7,32 +7,30 @@ def parse_album_header(response): header = nav(response, HEADER_DETAIL) album = { - 'title': nav(header, TITLE_TEXT), - 'type': nav(header, SUBTITLE), - 'thumbnails': nav(header, THUMBNAIL_CROPPED) + "title": nav(header, TITLE_TEXT), + "type": nav(header, SUBTITLE), + "thumbnails": nav(header, THUMBNAIL_CROPPED), } if "description" in header: album["description"] = header["description"]["runs"][0]["text"] - album_info = parse_song_runs(header['subtitle']['runs'][2:]) + album_info = parse_song_runs(header["subtitle"]["runs"][2:]) album.update(album_info) - if len(header['secondSubtitle']['runs']) > 1: - album['trackCount'] = to_int(header['secondSubtitle']['runs'][0]['text']) - album['duration'] = header['secondSubtitle']['runs'][2]['text'] + if len(header["secondSubtitle"]["runs"]) > 1: + album["trackCount"] = to_int(header["secondSubtitle"]["runs"][0]["text"]) + album["duration"] = header["secondSubtitle"]["runs"][2]["text"] else: - album['duration'] = header['secondSubtitle']['runs'][0]['text'] + album["duration"] = header["secondSubtitle"]["runs"][0]["text"] # add to library/uploaded menu = nav(header, MENU) - toplevel = menu['topLevelButtons'] - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - if not album['audioPlaylistId']: - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, - True) - service = nav(toplevel, [1, 'buttonRenderer', 'defaultServiceEndpoint'], True) + toplevel = menu["topLevelButtons"] + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True) + if not album["audioPlaylistId"]: + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_PLAYLIST_ID, True) + service = nav(toplevel, [1, "buttonRenderer", "defaultServiceEndpoint"], True) if service: - album['likeStatus'] = parse_like_status(service) + album["likeStatus"] = parse_like_status(service) return album diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 44e99094..2348395d 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -7,15 +7,15 @@ def parse_mixed_content(rows): for row in rows: if DESCRIPTION_SHELF[0] in row: results = nav(row, DESCRIPTION_SHELF) - title = nav(results, ['header'] + RUN_TEXT) + title = nav(results, ["header"] + RUN_TEXT) contents = nav(results, DESCRIPTION) else: results = next(iter(row.values())) - if 'contents' not in results: + if "contents" not in results: continue - title = nav(results, CAROUSEL_TITLE + ['text']) + title = nav(results, CAROUSEL_TITLE + ["text"]) contents = [] - for result in results['contents']: + for result in results["contents"]: data = nav(result, [MTRIR], True) content = None if data: @@ -37,7 +37,7 @@ def parse_mixed_content(rows): contents.append(content) - items.append({'title': title, 'contents': contents}) + items.append({"title": title, "contents": contents}) return items @@ -51,51 +51,50 @@ def parse_content_list(results, parse_func, key=MTRIR): def parse_album(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE2, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER), - 'isExplicit': nav(result, SUBTITLE_BADGE_LABEL, True) is not None + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE2, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), + "isExplicit": nav(result, SUBTITLE_BADGE_LABEL, True) is not None, } def parse_single(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } def parse_song(result): song = { - 'title': nav(result, TITLE_TEXT), - 'videoId': nav(result, NAVIGATION_VIDEO_ID), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "videoId": nav(result, NAVIGATION_VIDEO_ID), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } song.update(parse_song_runs(nav(result, SUBTITLE_RUNS))) return song def parse_song_flat(data): - columns = [get_flex_column_item(data, i) for i in range(0, len(data['flexColumns']))] + columns = [get_flex_column_item(data, i) for i in range(0, len(data["flexColumns"]))] song = { - 'title': nav(columns[0], TEXT_RUN_TEXT), - 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'artists': parse_song_artists(data, 1), - 'thumbnails': nav(data, THUMBNAILS), - 'isExplicit': nav(data, BADGE_LABEL, True) is not None + "title": nav(columns[0], TEXT_RUN_TEXT), + "videoId": nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "artists": parse_song_artists(data, 1), + "thumbnails": nav(data, THUMBNAILS), + "isExplicit": nav(data, BADGE_LABEL, True) is not None, } - if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav( - columns[2], TEXT_RUN): - song['album'] = { - 'name': nav(columns[2], TEXT_RUN_TEXT), - 'id': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) + if len(columns) > 2 and columns[2] is not None and "navigationEndpoint" in nav(columns[2], TEXT_RUN): + song["album"] = { + "name": nav(columns[2], TEXT_RUN_TEXT), + "id": nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID), } else: - song['views'] = nav(columns[1], ['text', 'runs', -1, 'text']).split(' ')[0] + song["views"] = nav(columns[1], ["text", "runs", -1, "text"]).split(" ")[0] return song @@ -105,30 +104,31 @@ def parse_video(result): artists_len = get_dot_separator_index(runs) videoId = nav(result, NAVIGATION_VIDEO_ID, True) if not videoId: - videoId = next(id for entry in nav(result, MENU_ITEMS) - if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True)) + videoId = next( + id for entry in nav(result, MENU_ITEMS) if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True) + ) return { - 'title': nav(result, TITLE_TEXT), - 'videoId': videoId, - 'artists': parse_song_artists_runs(runs[:artists_len]), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER, True), - 'views': runs[-1]['text'].split(' ')[0] + "title": nav(result, TITLE_TEXT), + "videoId": videoId, + "artists": parse_song_artists_runs(runs[:artists_len]), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), + "views": runs[-1]["text"].split(" ")[0], } def parse_playlist(data): playlist = { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], - 'thumbnails': nav(data, THUMBNAIL_RENDERER) + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], + "thumbnails": nav(data, THUMBNAIL_RENDERER), } - subtitle = data['subtitle'] - if 'runs' in subtitle: - playlist['description'] = "".join([run['text'] for run in subtitle['runs']]) - if len(subtitle['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): - playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] - playlist['author'] = parse_song_artists_runs(subtitle['runs'][:1]) + subtitle = data["subtitle"] + if "runs" in subtitle: + playlist["description"] = "".join([run["text"] for run in subtitle["runs"]]) + if len(subtitle["runs"]) == 3 and re.search(r"\d+ ", nav(data, SUBTITLE2)): + playlist["count"] = nav(data, SUBTITLE2).split(" ")[0] + playlist["author"] = parse_song_artists_runs(subtitle["runs"][:1]) return playlist @@ -136,18 +136,18 @@ def parse_playlist(data): def parse_related_artist(data): subscribers = nav(data, SUBTITLE, True) if subscribers: - subscribers = subscribers.split(' ')[0] + subscribers = subscribers.split(" ")[0] return { - 'title': nav(data, TITLE_TEXT), - 'browseId': nav(data, TITLE + NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAIL_RENDERER), } def parse_watch_playlist(data): return { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, NAVIGATION_WATCH_PLAYLIST_ID), + "thumbnails": nav(data, THUMBNAIL_RENDERER), } diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 6f1a371d..510b5a7a 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -1,6 +1,6 @@ from ytmusicapi.parsers.browsing import * -TRENDS = {'ARROW_DROP_UP': 'up', 'ARROW_DROP_DOWN': 'down', 'ARROW_CHART_NEUTRAL': 'neutral'} +TRENDS = {"ARROW_DROP_UP": "up", "ARROW_DROP_DOWN": "down", "ARROW_CHART_NEUTRAL": "neutral"} def parse_chart_song(data): @@ -12,13 +12,13 @@ def parse_chart_song(data): def parse_chart_artist(data): subscribers = get_flex_column_item(data, 1) if subscribers: - subscribers = nav(subscribers, TEXT_RUN_TEXT).split(' ')[0] + subscribers = nav(subscribers, TEXT_RUN_TEXT).split(" ")[0] parsed = { - 'title': nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), - 'browseId': nav(data, NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAILS), + "title": nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), + "browseId": nav(data, NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAILS), } parsed.update(parse_ranking(data)) return parsed @@ -29,22 +29,21 @@ def parse_chart_trending(data): artists = parse_song_artists(data, 1) index = get_dot_separator_index(artists) # last item is views for some reason - views = None if index == len(artists) else artists.pop()['name'].split(' ')[0] + views = None if index == len(artists) else artists.pop()["name"].split(" ")[0] return { - 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'playlistId': nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), - 'artists': artists, - 'thumbnails': nav(data, THUMBNAILS), - 'views': views + "title": nav(flex_0, TEXT_RUN_TEXT), + "videoId": nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "playlistId": nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), + "artists": artists, + "thumbnails": nav(data, THUMBNAILS), + "views": views, } def parse_ranking(data): return { - 'rank': - nav(data, ['customIndexColumn', 'musicCustomIndexColumnRenderer'] + TEXT_RUN_TEXT), - 'trend': - TRENDS[nav(data, - ['customIndexColumn', 'musicCustomIndexColumnRenderer', 'icon', 'iconType'])] + "rank": nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer"] + TEXT_RUN_TEXT), + "trend": TRENDS[ + nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer", "icon", "iconType"]) + ], } diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index 809c5e31..289a4571 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -13,39 +13,44 @@ class Parser: - def __init__(self, language): self.lang = language @i18n def get_search_result_types(self): - return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile'), _('podcast'), _('episode')] + return [ + _("artist"), + _("playlist"), + _("song"), + _("video"), + _("station"), + _("profile"), + _("podcast"), + _("episode"), + ] @i18n def parse_artist_contents(self, results: List) -> Dict: - categories = ['albums', 'singles', 'videos', 'playlists', 'related'] - categories_local = [_('albums'), _('singles'), _('videos'), _('playlists'), _('related')] - categories_parser = [ - parse_album, parse_single, parse_video, parse_playlist, parse_related_artist - ] + categories = ["albums", "singles", "videos", "playlists", "related"] + categories_local = [_("albums"), _("singles"), _("videos"), _("playlists"), _("related")] + categories_parser = [parse_album, parse_single, parse_video, parse_playlist, parse_related_artist] artist = {} for i, category in enumerate(categories): data = [ - r['musicCarouselShelfRenderer'] for r in results - if 'musicCarouselShelfRenderer' in r - and nav(r, CAROUSEL + CAROUSEL_TITLE)['text'].lower() == categories_local[i] + r["musicCarouselShelfRenderer"] + for r in results + if "musicCarouselShelfRenderer" in r + and nav(r, CAROUSEL + CAROUSEL_TITLE)["text"].lower() == categories_local[i] ] if len(data) > 0: - artist[category] = {'browseId': None, 'results': []} - if 'navigationEndpoint' in nav(data[0], CAROUSEL_TITLE): - artist[category]['browseId'] = nav(data[0], - CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) - if category in ['albums', 'singles', 'playlists']: - artist[category]['params'] = nav( - data[0], - CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] - - artist[category]['results'] = parse_content_list(data[0]['contents'], - categories_parser[i]) + artist[category] = {"browseId": None, "results": []} + if "navigationEndpoint" in nav(data[0], CAROUSEL_TITLE): + artist[category]["browseId"] = nav(data[0], CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) + if category in ["albums", "singles", "playlists"]: + artist[category]["params"] = nav(data[0], CAROUSEL_TITLE)["navigationEndpoint"][ + "browseEndpoint" + ]["params"] + + artist[category]["results"] = parse_content_list(data[0]["contents"], categories_parser[i]) return artist diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 2dc23cc5..7cc64640 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -10,16 +10,16 @@ def parse_artists(results, uploaded=False): for result in results: data = result[MRLIR] artist = {} - artist['browseId'] = nav(data, NAVIGATION_BROWSE_ID) - artist['artist'] = get_item_text(data, 0) + artist["browseId"] = nav(data, NAVIGATION_BROWSE_ID) + artist["artist"] = get_item_text(data, 0) parse_menu_playlists(data, artist) if uploaded: - artist['songs'] = get_item_text(data, 1).split(' ')[0] + artist["songs"] = get_item_text(data, 1).split(" ")[0] else: subtitle = get_item_text(data, 1) if subtitle: - artist['subscribers'] = subtitle.split(' ')[0] - artist['thumbnails'] = nav(data, THUMBNAILS, True) + artist["subscribers"] = subtitle.split(" ")[0] + artist["thumbnails"] = nav(data, THUMBNAILS, True) artists.append(artist) return artists @@ -29,14 +29,14 @@ def parse_library_albums(response, request_func, limit): results = get_library_contents(response, GRID) if results is None: return [] - albums = parse_albums(results['items']) + albums = parse_albums(results["items"]) - if 'continuations' in results: + if "continuations" in results: 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)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return albums @@ -46,14 +46,14 @@ def parse_albums(results): for result in results: data = result[MTRIR] album = {} - album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) - album['playlistId'] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) - album['title'] = nav(data, TITLE_TEXT) - album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) + album["browseId"] = nav(data, TITLE + NAVIGATION_BROWSE_ID) + album["playlistId"] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) + album["title"] = nav(data, TITLE_TEXT) + album["thumbnails"] = nav(data, THUMBNAIL_RENDERER) - if 'runs' in data['subtitle']: - album['type'] = nav(data, SUBTITLE) - album.update(parse_song_runs(data['subtitle']['runs'][2:])) + if "runs" in data["subtitle"]: + album["type"] = nav(data, SUBTITLE) + album.update(parse_song_runs(data["subtitle"]["runs"][2:])) albums.append(album) @@ -64,14 +64,14 @@ def parse_library_artists(response, request_func, limit): results = get_library_contents(response, MUSIC_SHELF) if results is None: return [] - artists = parse_artists(results['contents']) + artists = parse_artists(results["contents"]) - if 'continuations' in results: + if "continuations" in results: parse_func = lambda contents: parse_artists(contents) remaining_limit = None if limit is None else (limit - len(artists)) artists.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "musicShelfContinuation", remaining_limit, request_func, parse_func) + ) return artists @@ -79,17 +79,14 @@ def parse_library_artists(response, request_func, limit): def pop_songs_random_mix(results) -> None: """remove the random mix that conditionally appears at the start of library songs""" if results: - if len(results['contents']) >= 2: - results['contents'].pop(0) + if len(results["contents"]) >= 2: + results["contents"].pop(0) def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) pop_songs_random_mix(results) - return { - 'results': results, - 'parsed': parse_playlist_items(results['contents']) if results else results - } + return {"results": results, "parsed": parse_playlist_items(results["contents"]) if results else results} def get_library_contents(response, renderer): @@ -104,10 +101,9 @@ def get_library_contents(response, renderer): section = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) contents = None if section is None: # empty library - contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, - True) + contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, True) else: - results = find_object_by_key(section, 'itemSectionRenderer') + results = find_object_by_key(section, "itemSectionRenderer") if results is None: contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) else: diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 4b1e3917..a626f963 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -17,13 +17,17 @@ def parse_playlist_items(results, menu_entries: List[List] = None): library_status = None # if the item has a menu, find its setVideoId - if 'menu' in data: + if "menu" in data: for item in nav(data, MENU_ITEMS): - if 'menuServiceItemRenderer' in item: + 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 "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) @@ -31,15 +35,14 @@ def parse_playlist_items(results, menu_entries: List[List] = None): # 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 "playNavigationEndpoint" in nav(data, PLAY_BUTTON): + videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"] - if 'menu' in data: + if "menu" in data: like = nav(data, MENU_LIKE_STATUS, True) title = get_item_text(data, 0) - if title == 'Song deleted': + if title == "Song deleted": continue artists = parse_song_artists(data, 1) @@ -47,46 +50,49 @@ def parse_playlist_items(results, menu_entries: List[List] = None): album = parse_song_album(data, 2) 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'] + 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'] + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] thumbnails = None - if 'thumbnail' in data: + if "thumbnail" in data: thumbnails = nav(data, THUMBNAILS) isAvailable = True - if 'musicItemRendererDisplayPolicy' in data: - isAvailable = data[ - 'musicItemRendererDisplayPolicy'] != 'MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT' + 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, 'menuNavigationItemRenderer', 'navigationEndpoint'] - + NAVIGATION_VIDEO_TYPE, True) + data, + MENU_ITEMS + [0, "menuNavigationItemRenderer", "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 + "videoId": videoId, + "title": title, + "artists": artists, + "album": album, + "likeStatus": like, + "inLibrary": library_status, + "thumbnails": thumbnails, + "isAvailable": isAvailable, + "isExplicit": isExplicit, + "videoType": videoType, } if duration: - song['duration'] = duration - song['duration_seconds'] = parse_duration(duration) + song["duration"] = duration + song["duration_seconds"] = parse_duration(duration) if setVideoId: - song['setVideoId'] = setVideoId + song["setVideoId"] = setVideoId if feedback_tokens: - song['feedbackTokens'] = feedback_tokens + song["feedbackTokens"] = feedback_tokens if menu_entries: for menu_entry in menu_entries: diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 4b77be33..32488bae 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -5,11 +5,11 @@ def get_search_result_type(result_type_local, result_types_local): if not result_type_local: return None - result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile', 'podcast', 'episode'] + result_types = ["artist", "playlist", "song", "video", "station", "profile", "podcast", "episode"] result_type_local = result_type_local.lower() # default to album since it's labeled with multiple values ('Single', 'EP', etc.) if result_type_local not in result_types_local: - result_type = 'album' + result_type = "album" else: result_type = result_types[result_types_local.index(result_type_local)] @@ -18,218 +18,214 @@ def get_search_result_type(result_type_local, result_types_local): def parse_top_result(data, search_result_types): result_type = get_search_result_type(nav(data, SUBTITLE), search_result_types) - search_result = {'category': nav(data, CARD_SHELF_TITLE), 'resultType': result_type} - if result_type == 'artist': + search_result = {"category": nav(data, CARD_SHELF_TITLE), "resultType": result_type} + if result_type == "artist": subscribers = nav(data, SUBTITLE2, True) if subscribers: - search_result['subscribers'] = subscribers.split(' ')[0] + search_result["subscribers"] = subscribers.split(" ")[0] - artist_info = parse_song_runs(nav(data, ['title', 'runs'])) + artist_info = parse_song_runs(nav(data, ["title", "runs"])) search_result.update(artist_info) - if result_type in ['song', 'video']: - on_tap = data.get('onTap') + if result_type in ["song", "video"]: + on_tap = data.get("onTap") if on_tap: - search_result['videoId'] = nav(on_tap, WATCH_VIDEO_ID) - search_result['videoType'] = nav(on_tap, NAVIGATION_VIDEO_TYPE) + search_result["videoId"] = nav(on_tap, WATCH_VIDEO_ID) + search_result["videoType"] = nav(on_tap, NAVIGATION_VIDEO_TYPE) - if result_type in ['song', 'video', 'album']: - search_result['title'] = nav(data, TITLE_TEXT) - runs = nav(data, ['subtitle', 'runs'])[2:] + if result_type in ["song", "video", "album"]: + search_result["title"] = nav(data, TITLE_TEXT) + runs = nav(data, ["subtitle", "runs"])[2:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['album']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["album"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_result(data, search_result_types, result_type, category): default_offset = (not result_type or result_type == "album") * 2 - search_result = {'category': category} - video_type = nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + search_result = {"category": category} + video_type = nav(data, PLAY_BUTTON + ["playNavigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True) if not result_type and video_type: - result_type = 'song' if video_type == 'MUSIC_VIDEO_TYPE_ATV' else 'video' + result_type = "song" if video_type == "MUSIC_VIDEO_TYPE_ATV" else "video" - result_type = get_search_result_type(get_item_text(data, 1), - search_result_types) if not result_type else result_type - search_result['resultType'] = result_type + result_type = ( + get_search_result_type(get_item_text(data, 1), search_result_types) + if not result_type + else result_type + ) + search_result["resultType"] = result_type - if result_type != 'artist': - search_result['title'] = get_item_text(data, 0) + if result_type != "artist": + search_result["title"] = get_item_text(data, 0) - if result_type == 'artist': - search_result['artist'] = get_item_text(data, 0) + if result_type == "artist": + search_result["artist"] = get_item_text(data, 0) parse_menu_playlists(data, search_result) - elif result_type == 'album': - search_result['type'] = get_item_text(data, 1) + elif result_type == "album": + search_result["type"] = get_item_text(data, 1) - elif result_type == 'playlist': - flex_item = get_flex_column_item(data, 1)['text']['runs'] + elif result_type == "playlist": + flex_item = get_flex_column_item(data, 1)["text"]["runs"] has_author = len(flex_item) == default_offset + 3 - search_result['itemCount'] = get_item_text(data, 1, - default_offset + has_author * 2).split(' ')[0] - search_result['author'] = None if not has_author else get_item_text( - data, 1, default_offset) + search_result["itemCount"] = get_item_text(data, 1, default_offset + has_author * 2).split(" ")[0] + search_result["author"] = None if not has_author else get_item_text(data, 1, default_offset) - elif result_type == 'station': - search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) - search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) + elif result_type == "station": + search_result["videoId"] = nav(data, NAVIGATION_VIDEO_ID) + search_result["playlistId"] = nav(data, NAVIGATION_PLAYLIST_ID) - elif result_type == 'profile': - search_result['name'] = get_item_text(data, 1, 2, True) + elif result_type == "profile": + search_result["name"] = get_item_text(data, 1, 2, True) - elif result_type == 'song': - search_result['album'] = None - if 'menu' in data: + elif result_type == "song": + search_result["album"] = None + if "menu" in data: toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) if toggle_menu: - search_result['inLibrary'] = parse_song_library_status(toggle_menu) - search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) + search_result["inLibrary"] = parse_song_library_status(toggle_menu) + search_result["feedbackTokens"] = parse_song_menu_tokens(toggle_menu) - elif result_type == 'upload': + elif result_type == "upload": browse_id = nav(data, NAVIGATION_BROWSE_ID, True) if not browse_id: # song result - flex_items = [ - nav(get_flex_column_item(data, i), ['text', 'runs'], True) for i in range(2) - ] + flex_items = [nav(get_flex_column_item(data, i), ["text", "runs"], True) for i in range(2)] if flex_items[0]: - search_result['videoId'] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) - search_result['playlistId'] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) + search_result["videoId"] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) + search_result["playlistId"] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) if flex_items[1]: search_result.update(parse_song_runs(flex_items[1])) - search_result['resultType'] = 'song' + search_result["resultType"] = "song" else: # artist or album result - search_result['browseId'] = browse_id - if 'artist' in search_result['browseId']: - search_result['resultType'] = 'artist' + search_result["browseId"] = browse_id + if "artist" in search_result["browseId"]: + search_result["resultType"] = "artist" else: flex_item2 = get_flex_column_item(data, 1) - runs = [ - run['text'] for i, run in enumerate(flex_item2['text']['runs']) if i % 2 == 0 - ] + runs = [run["text"] for i, run in enumerate(flex_item2["text"]["runs"]) if i % 2 == 0] if len(runs) > 1: - search_result['artist'] = runs[1] + search_result["artist"] = runs[1] if len(runs) > 2: # date may be missing - search_result['releaseDate'] = runs[2] - search_result['resultType'] = 'album' - - if result_type in ['song', 'video']: - search_result['videoId'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) - search_result['videoType'] = video_type - - if result_type in ['song', 'video', 'album']: - search_result['duration'] = None - search_result['year'] = None + search_result["releaseDate"] = runs[2] + search_result["resultType"] = "album" + + if result_type in ["song", "video"]: + search_result["videoId"] = nav( + data, PLAY_BUTTON + ["playNavigationEndpoint", "watchEndpoint", "videoId"], True + ) + search_result["videoType"] = video_type + + if result_type in ["song", "video", "album"]: + search_result["duration"] = None + search_result["year"] = None flex_item = get_flex_column_item(data, 1) - runs = flex_item['text']['runs'][default_offset:] + runs = flex_item["text"]["runs"][default_offset:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['artist', 'album', 'playlist', 'profile']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["artist", "album", "playlist", "profile"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - if result_type in ['song', 'album']: - search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None + if result_type in ["song", "album"]: + search_result["isExplicit"] = nav(data, BADGE_LABEL, True) is not None - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_results(results, search_result_types, resultType=None, category=None): return [ - parse_search_result(result[MRLIR], search_result_types, resultType, category) - for result in results + parse_search_result(result[MRLIR], search_result_types, resultType, category) for result in results ] def get_search_params(filter, scope, ignore_spelling): - filtered_param1 = 'EgWKAQ' + filtered_param1 = "EgWKAQ" params = None if filter is None and scope is None and not ignore_spelling: return params - if scope == 'uploads': - params = 'agIYAw%3D%3D' + if scope == "uploads": + params = "agIYAw%3D%3D" - if scope == 'library': + if scope == "library": if filter: param1 = filtered_param1 param2 = _get_param2(filter) - param3 = 'AWoKEAUQCRADEAoYBA%3D%3D' + param3 = "AWoKEAUQCRADEAoYBA%3D%3D" else: - params = 'agIYBA%3D%3D' + params = "agIYBA%3D%3D" if scope is None and filter: - if filter == 'playlists': - params = 'Eg-KAQwIABAAGAAgACgB' + if filter == "playlists": + params = "Eg-KAQwIABAAGAAgACgB" if not ignore_spelling: - params += 'MABqChAEEAMQCRAFEAo%3D' + params += "MABqChAEEAMQCRAFEAo%3D" else: - params += 'MABCAggBagoQBBADEAkQBRAK' + params += "MABCAggBagoQBBADEAkQBRAK" - elif 'playlists' in filter: - param1 = 'EgeKAQQoA' - if filter == 'featured_playlists': - param2 = 'Dg' + elif "playlists" in filter: + param1 = "EgeKAQQoA" + if filter == "featured_playlists": + param2 = "Dg" else: # community_playlists - param2 = 'EA' + param2 = "EA" if not ignore_spelling: - param3 = 'BagwQDhAKEAMQBBAJEAU%3D' + param3 = "BagwQDhAKEAMQBBAJEAU%3D" else: - param3 = 'BQgIIAWoMEA4QChADEAQQCRAF' + param3 = "BQgIIAWoMEA4QChADEAQQCRAF" else: param1 = filtered_param1 param2 = _get_param2(filter) if not ignore_spelling: - param3 = 'AWoMEA4QChADEAQQCRAF' + param3 = "AWoMEA4QChADEAQQCRAF" else: - param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' + param3 = "AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D" if not scope and not filter and ignore_spelling: - params = 'EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D' + params = "EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D" return params if params else param1 + param2 + param3 def _get_param2(filter): filter_params = { - 'songs': 'II', - 'videos': 'IQ', - 'albums': 'IY', - 'artists': 'Ig', - 'playlists': 'Io', - 'profiles': 'JY', - 'podcasts': 'JQ', - 'episodes': 'JI' + "songs": "II", + "videos": "IQ", + "albums": "IY", + "artists": "Ig", + "playlists": "Io", + "profiles": "JY", + "podcasts": "JQ", + "episodes": "JI", } return filter_params[filter] def parse_search_suggestions(results, detailed_runs): - if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( - 'contents', []): + if not results.get("contents", [{}])[0].get("searchSuggestionsSectionRenderer", {}).get("contents", []): return [] - raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] + raw_suggestions = results["contents"][0]["searchSuggestionsSectionRenderer"]["contents"] suggestions = [] for raw_suggestion in raw_suggestions: - suggestion_content = raw_suggestion['searchSuggestionRenderer'] + suggestion_content = raw_suggestion["searchSuggestionRenderer"] - text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] - runs = suggestion_content['suggestion']['runs'] + text = suggestion_content["navigationEndpoint"]["searchEndpoint"]["query"] + runs = suggestion_content["suggestion"]["runs"] if detailed_runs: - suggestions.append({'text': text, 'runs': runs}) + suggestions.append({"text": text, "runs": runs}) else: suggestions.append(text) diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index 7c792469..9347b51a 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -8,64 +8,57 @@ def parse_song_artists(data, index): if not flex_item: return None else: - runs = flex_item['text']['runs'] + runs = flex_item["text"]["runs"] return parse_song_artists_runs(runs) def parse_song_artists_runs(runs): artists = [] for j in range(int(len(runs) / 2) + 1): - artists.append({ - 'name': runs[j * 2]['text'], - 'id': nav(runs[j * 2], NAVIGATION_BROWSE_ID, True) - }) + artists.append({"name": runs[j * 2]["text"], "id": nav(runs[j * 2], NAVIGATION_BROWSE_ID, True)}) return artists def parse_song_runs(runs): - parsed = {'artists': []} + parsed = {"artists": []} for i, run in enumerate(runs): if i % 2: # uneven items are always separators continue - text = run['text'] - if 'navigationEndpoint' in run: # artist or album - item = {'name': text, 'id': nav(run, NAVIGATION_BROWSE_ID, True)} + text = run["text"] + if "navigationEndpoint" in run: # artist or album + item = {"name": text, "id": nav(run, NAVIGATION_BROWSE_ID, True)} - if item['id'] and (item['id'].startswith('MPRE') - or "release_detail" in item['id']): # album - parsed['album'] = item + if item["id"] and (item["id"].startswith("MPRE") or "release_detail" in item["id"]): # album + parsed["album"] = item else: # artist - parsed['artists'].append(item) + parsed["artists"].append(item) else: # note: YT uses non-breaking space \xa0 to separate number and magnitude if re.match(r"^\d([^ ])* [^ ]*$", text) and i > 0: - parsed['views'] = text.split(' ')[0] + parsed["views"] = text.split(" ")[0] elif re.match(r"^(\d+:)*\d+:\d+$", text): - parsed['duration'] = text - parsed['duration_seconds'] = parse_duration(text) + parsed["duration"] = text + parsed["duration_seconds"] = parse_duration(text) elif re.match(r"^\d{4}$", text): - parsed['year'] = text + parsed["year"] = text else: # artist without id - parsed['artists'].append({'name': text, 'id': None}) + parsed["artists"].append({"name": text, "id": None}) return parsed def parse_song_album(data, index): flex_item = get_flex_column_item(data, index) - return None if not flex_item else { - 'name': get_item_text(data, index), - 'id': get_browse_id(flex_item, 0) - } + return None if not flex_item else {"name": get_item_text(data, index), "id": get_browse_id(flex_item, 0)} def parse_song_library_status(item) -> bool: """Returns True if song is in the library""" - library_status = nav(item, [TOGGLE_MENU, 'defaultIcon', 'iconType'], True) + library_status = nav(item, [TOGGLE_MENU, "defaultIcon", "iconType"], True) return library_status == "LIBRARY_SAVED" @@ -73,16 +66,16 @@ def parse_song_library_status(item) -> bool: def parse_song_menu_tokens(item): toggle_menu = item[TOGGLE_MENU] - library_add_token = nav(toggle_menu, ['defaultServiceEndpoint'] + FEEDBACK_TOKEN, True) - library_remove_token = nav(toggle_menu, ['toggledServiceEndpoint'] + FEEDBACK_TOKEN, True) + library_add_token = nav(toggle_menu, ["defaultServiceEndpoint"] + FEEDBACK_TOKEN, True) + library_remove_token = nav(toggle_menu, ["toggledServiceEndpoint"] + FEEDBACK_TOKEN, True) in_library = parse_song_library_status(item) if in_library: library_add_token, library_remove_token = library_remove_token, library_add_token - return {'add': library_add_token, 'remove': library_remove_token} + return {"add": library_add_token, "remove": library_remove_token} def parse_like_status(service): - status = ['LIKE', 'INDIFFERENT'] - return status[status.index(service['likeEndpoint']['status']) - 1] + status = ["LIKE", "INDIFFERENT"] + return status[status.index(service["likeEndpoint"]["status"]) - 1] diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index 76032070..91023d7b 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -6,29 +6,30 @@ def parse_uploaded_items(results): songs = [] for result in results: data = result[MRLIR] - if 'menu' not in data: + if "menu" not in data: continue - entityId = nav(data, MENU_ITEMS)[-1]['menuNavigationItemRenderer']['navigationEndpoint'][ - 'confirmDialogEndpoint']['content']['confirmDialogRenderer']['confirmButton'][ - 'buttonRenderer']['command']['musicDeletePrivatelyOwnedEntityCommand']['entityId'] + entityId = nav(data, MENU_ITEMS)[-1]["menuNavigationItemRenderer"]["navigationEndpoint"][ + "confirmDialogEndpoint" + ]["content"]["confirmDialogRenderer"]["confirmButton"]["buttonRenderer"]["command"][ + "musicDeletePrivatelyOwnedEntityCommand" + ]["entityId"] - videoId = nav(data, MENU_ITEMS + [0] - + MENU_SERVICE)['queueAddEndpoint']['queueTarget']['videoId'] + videoId = nav(data, MENU_ITEMS + [0] + MENU_SERVICE)["queueAddEndpoint"]["queueTarget"]["videoId"] title = get_item_text(data, 0) like = nav(data, MENU_LIKE_STATUS) - thumbnails = nav(data, THUMBNAILS) if 'thumbnail' in data else None - duration = get_fixed_column_item(data, 0)['text']['runs'][0]['text'] + thumbnails = nav(data, THUMBNAILS) if "thumbnail" in data else None + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] song = { - 'entityId': entityId, - 'videoId': videoId, - 'title': title, - 'duration': duration, - 'duration_seconds': parse_duration(duration), - 'artists': parse_song_artists(data, 1), - 'album': parse_song_album(data, 2), - 'likeStatus': like, - 'thumbnails': thumbnails + "entityId": entityId, + "videoId": videoId, + "title": title, + "duration": duration, + "duration_seconds": parse_duration(duration), + "artists": parse_song_artists(data, 1), + "album": parse_song_album(data, 2), + "likeStatus": like, + "thumbnails": thumbnails, } songs.append(song) diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index d1a3cf5f..8f28dd6f 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -4,22 +4,22 @@ def parse_watch_playlist(results): tracks = [] - PPVWR = 'playlistPanelVideoWrapperRenderer' - PPVR = 'playlistPanelVideoRenderer' + PPVWR = "playlistPanelVideoWrapperRenderer" + PPVR = "playlistPanelVideoRenderer" for result in results: counterpart = None if PPVWR in result: - counterpart = result[PPVWR]['counterpart'][0]['counterpartRenderer'][PPVR] - result = result[PPVWR]['primaryRenderer'] + counterpart = result[PPVWR]["counterpart"][0]["counterpartRenderer"][PPVR] + result = result[PPVWR]["primaryRenderer"] if PPVR not in result: continue data = result[PPVR] - if 'unplayableText' in data: + if "unplayableText" in data: continue track = parse_watch_track(data) if counterpart: - track['counterpart'] = parse_watch_track(counterpart) + track["counterpart"] = parse_watch_track(counterpart) tracks.append(track) return tracks @@ -30,31 +30,30 @@ def parse_watch_track(data): for item in nav(data, MENU_ITEMS): if TOGGLE_MENU in item: library_status = parse_song_library_status(item) - service = item[TOGGLE_MENU]['defaultServiceEndpoint'] - if 'feedbackEndpoint' in service: + service = item[TOGGLE_MENU]["defaultServiceEndpoint"] + if "feedbackEndpoint" in service: feedback_tokens = parse_song_menu_tokens(item) - if 'likeEndpoint' in service: + if "likeEndpoint" in service: like_status = parse_like_status(service) - song_info = parse_song_runs(data['longBylineText']['runs']) + song_info = parse_song_runs(data["longBylineText"]["runs"]) track = { - 'videoId': data['videoId'], - 'title': nav(data, TITLE_TEXT), - 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), - 'thumbnail': nav(data, THUMBNAIL), - 'feedbackTokens': feedback_tokens, - 'likeStatus': like_status, - 'inLibrary': library_status, - 'videoType': nav(data, ['navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + "videoId": data["videoId"], + "title": nav(data, TITLE_TEXT), + "length": nav(data, ["lengthText", "runs", 0, "text"], True), + "thumbnail": nav(data, THUMBNAIL), + "feedbackTokens": feedback_tokens, + "likeStatus": like_status, + "inLibrary": library_status, + "videoType": nav(data, ["navigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True), } track.update(song_info) return track def get_tab_browse_id(watchNextRenderer, tab_id): - if 'unselectable' not in watchNextRenderer['tabs'][tab_id]['tabRenderer']: - return watchNextRenderer['tabs'][tab_id]['tabRenderer']['endpoint']['browseEndpoint'][ - 'browseId'] + if "unselectable" not in watchNextRenderer["tabs"][tab_id]["tabRenderer"]: + return watchNextRenderer["tabs"][tab_id]["tabRenderer"]["endpoint"]["browseEndpoint"]["browseId"] else: return None diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index f826ec41..67da6de9 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -22,12 +22,14 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: return setup_browser(filepath, headers_raw) -def setup_oauth(filepath: str = None, - session: requests.Session = None, - proxies: dict = None, - open_browser: bool = False, - client_id: str = None, - client_secret: str = None) -> Dict: +def setup_oauth( + filepath: str = None, + session: requests.Session = None, + proxies: dict = None, + open_browser: bool = False, + client_id: str = None, + client_secret: str = None, +) -> Dict: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() @@ -55,11 +57,8 @@ def setup_oauth(filepath: str = None, def parse_args(args): - parser = argparse.ArgumentParser(description='Setup ytmusicapi.') - parser.add_argument("setup_type", - type=str, - choices=["oauth", "browser"], - help="choose a setup type.") + parser = argparse.ArgumentParser(description="Setup ytmusicapi.") + parser.add_argument("setup_type", type=str, choices=["oauth", "browser"], help="choose a setup type.") parser.add_argument("--file", type=Path, help="optional path to output file.") return parser.parse_args(args) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 8bcbbec0..870f4a24 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -23,22 +23,25 @@ from .auth.types import AuthType -class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, - UploadsMixin): +class YTMusic( + BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin +): """ Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. Permits both authenticated and non-authenticated requests. Authentication header data must be provided on initialization. """ - def __init__(self, - auth: Optional[str | Dict] = None, - user: str = None, - requests_session=True, - proxies: Dict = None, - language: str = 'en', - location: str = '', - oauth_credentials: Optional[OAuthCredentials] = None): + def __init__( + self, + auth: Optional[str | Dict] = None, + user: str = None, + requests_session=True, + proxies: Dict = None, + language: str = "en", + location: str = "", + oauth_credentials: Optional[OAuthCredentials] = None, + ): """ Create a new instance to interact with YouTube Music. @@ -102,9 +105,11 @@ def __init__(self, # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 - self.cookies = {'SOCS': 'CAI'} + self.cookies = {"SOCS": "CAI"} if self.auth is not None: - self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() + self.oauth_credentials = ( + oauth_credentials if oauth_credentials is not None else OAuthCredentials() + ) auth_filepath = None if isinstance(self.auth, str): if os.path.isfile(auth): @@ -129,31 +134,32 @@ def __init__(self, if location: if location not in SUPPORTED_LOCATIONS: raise Exception("Location not supported. Check the FAQ for supported locations.") - self.context['context']['client']['gl'] = location + self.context["context"]["client"]["gl"] = location if language not in SUPPORTED_LANGUAGES: - raise Exception("Language not supported. Supported languages are " - + (', '.join(SUPPORTED_LANGUAGES)) + ".") - self.context['context']['client']['hl'] = language + raise Exception( + "Language not supported. Supported languages are " + (", ".join(SUPPORTED_LANGUAGES)) + "." + ) + self.context["context"]["client"]["hl"] = language self.language = language try: locale.setlocale(locale.LC_ALL, self.language) except locale.Error: with suppress(locale.Error): - locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + locale.setlocale(locale.LC_ALL, "en_US.UTF-8") - locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' - self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) + locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + "locales" + self.lang = gettext.translation("base", localedir=locale_dir, languages=[language]) self.parser = Parser(self.lang) if user: - self.context['context']['user']['onBehalfOfUser'] = user + self.context["context"]["user"]["onBehalfOfUser"] = user auth_headers = self._input_dict.get("authorization") if auth_headers: if "SAPISIDHASH" in auth_headers: self.auth_type = AuthType.BROWSER - elif auth_headers.startswith('Bearer'): + elif auth_headers.startswith("Bearer"): self.auth_type = AuthType.OAUTH_CUSTOM_FULL # sapsid, origin, and params all set once during init @@ -161,9 +167,9 @@ def __init__(self, if self.auth_type == AuthType.BROWSER: self.params += YTM_PARAMS_KEY try: - cookie = self.base_headers.get('cookie') + cookie = self.base_headers.get("cookie") self.sapisid = sapisid_from_cookie(cookie) - self.origin = self.base_headers.get('origin', self.base_headers.get('x-origin')) + self.origin = self.base_headers.get("origin", self.base_headers.get("x-origin")) except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") @@ -179,7 +185,7 @@ def base_headers(self): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } return self._base_headers @@ -192,11 +198,11 @@ def headers(self): # keys updated each use, custom oauth implementations left untouched if self.auth_type == AuthType.BROWSER: - self._headers["authorization"] = get_authorization(self.sapisid + ' ' + self.origin) + self._headers["authorization"] = get_authorization(self.sapisid + " " + self.origin) elif self.auth_type in AuthType.oauth_types(): - self._headers['authorization'] = self._token.as_auth() - self._headers['X-Goog-Request-Time'] = str(int(time.time())) + self._headers["authorization"] = self._token.as_auth() + self._headers["X-Goog-Request-Time"] = str(int(time.time())) return self._headers @@ -204,19 +210,20 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - body.update(self.context) # only required for post requests (?) - if 'X-Goog-Visitor-Id' not in self.headers: + if "X-Goog-Visitor-Id" not in self.headers: self._headers.update(get_visitor_id(self._send_get_request)) - response = self._session.post(YTM_BASE_API + endpoint + self.params + additionalParams, - json=body, - headers=self.headers, - proxies=self.proxies, - cookies=self.cookies) + response = self._session.post( + YTM_BASE_API + endpoint + self.params + additionalParams, + json=body, + headers=self.headers, + proxies=self.proxies, + cookies=self.cookies, + ) response_text = json.loads(response.text) if response.status_code >= 400: - message = "Server returned HTTP " + str( - response.status_code) + ": " + response.reason + ".\n" - error = response_text.get('error', {}).get('message') + message = "Server returned HTTP " + str(response.status_code) + ": " + response.reason + ".\n" + error = response_text.get("error", {}).get("message") raise Exception(message + error) return response_text @@ -227,7 +234,8 @@ def _send_get_request(self, url: str, params: Dict = None): # handle first-use x-goog-visitor-id fetching headers=self.headers if self._headers else self.base_headers, proxies=self.proxies, - cookies=self.cookies) + cookies=self.cookies, + ) return response def _check_auth(self):