1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279
|
import gettext
import json
import locale
import time
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from functools import cached_property, partial
from pathlib import Path
from typing import Optional, Union
import requests
from requests import Response
from requests.structures import CaseInsensitiveDict
from ytmusicapi.helpers import (
SUPPORTED_LANGUAGES,
SUPPORTED_LOCATIONS,
YTM_BASE_API,
YTM_PARAMS,
YTM_PARAMS_KEY,
get_authorization,
get_visitor_id,
initialize_context,
initialize_headers,
sapisid_from_cookie,
)
from ytmusicapi.mixins.browsing import BrowsingMixin
from ytmusicapi.mixins.explore import ExploreMixin
from ytmusicapi.mixins.library import LibraryMixin
from ytmusicapi.mixins.playlists import PlaylistsMixin
from ytmusicapi.mixins.podcasts import PodcastsMixin
from ytmusicapi.mixins.search import SearchMixin
from ytmusicapi.mixins.uploads import UploadsMixin
from ytmusicapi.mixins.watch import WatchMixin
from ytmusicapi.parsers.i18n import Parser
from .auth.auth_parse import determine_auth_type, parse_auth_str
from .auth.oauth import OAuthCredentials, RefreshingToken
from .auth.oauth.token import Token
from .auth.types import AuthType
from .exceptions import YTMusicServerError, YTMusicUserError
class YTMusicBase:
def __init__(
self,
auth: Optional[Union[str, dict]] = None,
user: Optional[str] = None,
requests_session: Optional[requests.Session] = None,
proxies: Optional[dict[str, str]] = None,
language: str = "en",
location: str = "",
oauth_credentials: Optional[OAuthCredentials] = None,
):
"""
Create a new instance to interact with YouTube Music.
:param auth: Optional. Provide a string, path to file, or oauth token dict.
Authentication credentials are needed to manage your library.
See :py:func:`setup` for how to fill in the correct credentials.
Default: A default header is used without authentication.
:param user: Optional. Specify a user ID string to use in requests.
This is needed if you want to send requests on behalf of a brand account.
Otherwise the default account is used. You can retrieve the user ID
by going to https://myaccount.google.com/brandaccounts and selecting your brand account.
The user ID will be in the URL: https://myaccount.google.com/b/user_id/
:param requests_session: A Requests session object or None to create one.
Default sessions have a request timeout of 30s, which produces a requests.exceptions.ReadTimeout.
The timeout can be changed by passing your own Session object::
s = requests.Session()
s.request = functools.partial(s.request, timeout=3)
ytm = YTMusic(requests_session=s)
:param proxies: Optional. Proxy configuration in requests_ format_.
.. _requests: https://requests.readthedocs.io/
.. _format: https://requests.readthedocs.io/en/master/user/advanced/#proxies
:param language: Optional. Can be used to change the language of returned data.
English will be used by default. Available languages can be checked in
the ytmusicapi/locales directory.
:param location: Optional. Can be used to change the location of the user.
No location will be set by default. This means it is determined by the server.
Available languages can be checked in the FAQ.
:param oauth_credentials: Optional. Used to specify a different oauth client to be
used for authentication flow.
"""
#: request session for connection pooling
self._session = self._prepare_session(requests_session)
self.proxies: Optional[dict[str, str]] = proxies #: params for session modification
# 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._auth_headers: CaseInsensitiveDict = CaseInsensitiveDict()
self.auth_type = AuthType.UNAUTHORIZED
if auth is not None:
self._auth_headers, auth_path = parse_auth_str(auth)
self.auth_type = determine_auth_type(self._auth_headers)
self._token: Token
if self.auth_type == AuthType.OAUTH_CUSTOM_CLIENT:
if oauth_credentials is None:
raise YTMusicUserError(
"oauth JSON provided via auth argument, but oauth_credentials not provided."
"Please provide oauth_credentials as specified in the OAuth setup documentation."
)
#: OAuth credential handler
self._token = RefreshingToken(
credentials=oauth_credentials, _local_cache=auth_path, **self._auth_headers
)
# prepare context
self.context = initialize_context()
if location:
if location not in SUPPORTED_LOCATIONS:
raise YTMusicUserError("Location not supported. Check the FAQ for supported locations.")
self.context["context"]["client"]["gl"] = location
if language not in SUPPORTED_LANGUAGES:
raise YTMusicUserError(
"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_dir = Path(__file__).parent.resolve() / "locales"
self.lang = gettext.translation("base", localedir=locale_dir, languages=[language])
self.parser = Parser(self.lang)
if user:
self.context["context"]["user"]["onBehalfOfUser"] = user
# sapsid, origin, and params all set once during init
self.params = YTM_PARAMS
if self.auth_type == AuthType.BROWSER:
self.params += YTM_PARAMS_KEY
try:
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"))
except KeyError:
raise YTMusicUserError("Your cookie is missing the required value __Secure-3PAPISID")
@cached_property
def base_headers(self) -> CaseInsensitiveDict:
headers = (
self._auth_headers
if self.auth_type == AuthType.BROWSER or self.auth_type == AuthType.OAUTH_CUSTOM_FULL
else initialize_headers()
)
if "X-Goog-Visitor-Id" not in headers:
headers.update(get_visitor_id(partial(self._send_get_request, use_base_headers=True)))
return headers
@property
def headers(self) -> CaseInsensitiveDict:
headers = self.base_headers
# keys updated each use, custom oauth implementations left untouched
if self.auth_type == AuthType.BROWSER:
headers["authorization"] = get_authorization(self.sapisid + " " + self.origin)
# Do not set custom headers when using OAUTH_CUSTOM_FULL
# Full headers are provided by the downstream client in this scenario.
elif self.auth_type == AuthType.OAUTH_CUSTOM_CLIENT:
headers["authorization"] = self._token.as_auth()
headers["X-Goog-Request-Time"] = str(int(time.time()))
return headers
@contextmanager
def as_mobile(self) -> Iterator[None]:
"""
Not thread-safe!
----------------
Temporarily changes the `context` to enable different results
from the API, meant for the Android mobile-app.
All calls inside the `with`-statement with emulate mobile behavior.
This context-manager has no `enter_result`, as it operates in-place
and only temporarily alters the underlying `YTMusic`-object.
Example::
with yt.as_mobile():
yt._send_request(...) # results as mobile-app
yt._send_request(...) # back to normal, like web-app
"""
# change the context to emulate a mobile-app (Android)
copied_context_client = self.context["context"]["client"].copy()
self.context["context"]["client"].update({"clientName": "ANDROID_MUSIC", "clientVersion": "7.21.50"})
# this will not catch errors
try:
yield None
finally:
# safely restore the old context
self.context["context"]["client"] = copied_context_client
def _prepare_session(self, requests_session: Optional[requests.Session]) -> requests.Session:
"""Prepare requests session or use user-provided requests_session"""
if isinstance(requests_session, requests.Session):
return requests_session
self._session = requests.Session()
self._session.request = partial(self._session.request, timeout=30) # type: ignore[method-assign]
return self._session
def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -> dict:
body.update(self.context)
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")
raise YTMusicServerError(message + error)
return response_text
def _send_get_request(
self, url: str, params: Optional[dict] = None, use_base_headers: bool = False
) -> Response:
response = self._session.get(
url,
params=params,
# handle first-use x-goog-visitor-id fetching
headers=initialize_headers() if use_base_headers else self.headers,
proxies=self.proxies,
cookies=self.cookies,
)
return response
def _check_auth(self):
if self.auth_type == AuthType.UNAUTHORIZED:
raise YTMusicUserError("Please provide authentication before using this function")
def __enter__(self):
return self
def __exit__(self, execType=None, execValue=None, trackback=None):
pass
class YTMusic(
YTMusicBase,
BrowsingMixin,
SearchMixin,
WatchMixin,
ExploreMixin,
LibraryMixin,
PlaylistsMixin,
PodcastsMixin,
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.
"""
|