File: ytmusic.py

package info (click to toggle)
python-ytmusicapi 1.10.2-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 3,412 kB
  • sloc: python: 4,324; sh: 14; makefile: 12
file content (279 lines) | stat: -rw-r--r-- 11,280 bytes parent folder | download
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.
    """