yutipy 2.2.6__py3-none-any.whl → 2.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of yutipy might be problematic. Click here for more details.
- yutipy/base_clients.py +582 -0
- yutipy/deezer.py +7 -21
- yutipy/itunes.py +10 -20
- yutipy/kkbox.py +22 -222
- yutipy/lastfm.py +1 -1
- yutipy/musicyt.py +3 -5
- yutipy/spotify.py +46 -636
- {yutipy-2.2.6.dist-info → yutipy-2.2.7.dist-info}/METADATA +4 -4
- yutipy-2.2.7.dist-info/RECORD +23 -0
- {yutipy-2.2.6.dist-info → yutipy-2.2.7.dist-info}/WHEEL +1 -1
- yutipy-2.2.6.dist-info/RECORD +0 -22
- {yutipy-2.2.6.dist-info → yutipy-2.2.7.dist-info}/entry_points.txt +0 -0
- {yutipy-2.2.6.dist-info → yutipy-2.2.7.dist-info}/licenses/LICENSE +0 -0
- {yutipy-2.2.6.dist-info → yutipy-2.2.7.dist-info}/top_level.txt +0 -0
yutipy/spotify.py
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
__all__ = ["Spotify", "SpotifyException", "SpotifyAuthException"]
|
|
2
2
|
|
|
3
|
-
import base64
|
|
4
3
|
import os
|
|
5
|
-
import secrets
|
|
6
4
|
import webbrowser
|
|
7
5
|
from pprint import pprint
|
|
8
|
-
from time import time
|
|
9
6
|
from typing import Optional, Union
|
|
10
|
-
from urllib.parse import urlencode
|
|
11
7
|
|
|
12
8
|
import requests
|
|
13
9
|
from dotenv import load_dotenv
|
|
14
10
|
|
|
11
|
+
from yutipy.base_clients import BaseAuthClient, BaseClient
|
|
15
12
|
from yutipy.exceptions import (
|
|
16
|
-
AuthenticationException,
|
|
17
13
|
InvalidValueException,
|
|
18
14
|
SpotifyAuthException,
|
|
19
15
|
SpotifyException,
|
|
@@ -34,7 +30,7 @@ SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
|
34
30
|
SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI")
|
|
35
31
|
|
|
36
32
|
|
|
37
|
-
class Spotify:
|
|
33
|
+
class Spotify(BaseClient):
|
|
38
34
|
"""
|
|
39
35
|
A class to interact with the Spotify API. It uses "Client Credentials" grant type (or flow).
|
|
40
36
|
|
|
@@ -55,9 +51,8 @@ class Spotify:
|
|
|
55
51
|
client_secret : str, optional
|
|
56
52
|
The Client secret for the Spotify API. Defaults to ``SPOTIFY_CLIENT_SECRET`` from environment variable or the ``.env`` file.
|
|
57
53
|
defer_load : bool, optional
|
|
58
|
-
Whether to defer loading the access token during initialization
|
|
54
|
+
Whether to defer loading the access token during initialization, by default ``False``
|
|
59
55
|
"""
|
|
60
|
-
|
|
61
56
|
self.client_id = client_id or SPOTIFY_CLIENT_ID
|
|
62
57
|
self.client_secret = client_secret or SPOTIFY_CLIENT_SECRET
|
|
63
58
|
|
|
@@ -71,224 +66,15 @@ class Spotify:
|
|
|
71
66
|
"Client Secret was not found. Set it in environment variable or directly pass it when creating object."
|
|
72
67
|
)
|
|
73
68
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
self.__access_token = None
|
|
81
|
-
self.__token_expires_in = None
|
|
82
|
-
self.__token_requested_at = None
|
|
83
|
-
self.__session = requests.Session()
|
|
84
|
-
self.__translation_session = requests.Session()
|
|
85
|
-
|
|
86
|
-
if not defer_load:
|
|
87
|
-
# Attempt to load access token during initialization if not deferred
|
|
88
|
-
token_info = None
|
|
89
|
-
try:
|
|
90
|
-
token_info = self.load_access_token()
|
|
91
|
-
except NotImplementedError:
|
|
92
|
-
logger.warning(
|
|
93
|
-
"`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
|
|
94
|
-
)
|
|
95
|
-
finally:
|
|
96
|
-
if not token_info:
|
|
97
|
-
token_info = self.__get_access_token()
|
|
98
|
-
self.__access_token = token_info.get("access_token")
|
|
99
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
100
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
101
|
-
|
|
102
|
-
try:
|
|
103
|
-
self.save_access_token(token_info)
|
|
104
|
-
except NotImplementedError:
|
|
105
|
-
logger.warning(
|
|
106
|
-
"`save_access_token` is not implemented, falling back to in-memory storage. Access token will not be saved."
|
|
107
|
-
)
|
|
108
|
-
else:
|
|
109
|
-
logger.warning(
|
|
110
|
-
"`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
def __enter__(self):
|
|
114
|
-
"""Enters the runtime context related to this object."""
|
|
115
|
-
return self
|
|
116
|
-
|
|
117
|
-
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
118
|
-
"""Exits the runtime context related to this object."""
|
|
119
|
-
self.close_session()
|
|
120
|
-
|
|
121
|
-
def close_session(self) -> None:
|
|
122
|
-
"""Closes the current session(s)."""
|
|
123
|
-
if not self.is_session_closed:
|
|
124
|
-
self.__session.close()
|
|
125
|
-
self.__translation_session.close()
|
|
126
|
-
self._is_session_closed = True
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def is_session_closed(self) -> bool:
|
|
130
|
-
"""Checks if the session is closed."""
|
|
131
|
-
return self._is_session_closed
|
|
132
|
-
|
|
133
|
-
def load_token_after_init(self):
|
|
134
|
-
"""
|
|
135
|
-
Explicitly load the access token after initialization.
|
|
136
|
-
This is useful when ``defer_load`` is set to ``True`` during initialization.
|
|
137
|
-
"""
|
|
138
|
-
token_info = None
|
|
139
|
-
try:
|
|
140
|
-
token_info = self.load_access_token()
|
|
141
|
-
except NotImplementedError:
|
|
142
|
-
logger.warning(
|
|
143
|
-
"`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
|
|
144
|
-
)
|
|
145
|
-
finally:
|
|
146
|
-
if not token_info:
|
|
147
|
-
token_info = self.__get_access_token()
|
|
148
|
-
self.__access_token = token_info.get("access_token")
|
|
149
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
150
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
151
|
-
|
|
152
|
-
try:
|
|
153
|
-
self.save_access_token(token_info)
|
|
154
|
-
except NotImplementedError:
|
|
155
|
-
logger.warning(
|
|
156
|
-
"`save_access_token` is not implemented, falling back to in-memory storage. Access token will not be saved."
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
def __authorization_header(self) -> dict:
|
|
160
|
-
"""
|
|
161
|
-
Generates the authorization header for Spotify API requests.
|
|
162
|
-
|
|
163
|
-
Returns
|
|
164
|
-
-------
|
|
165
|
-
dict
|
|
166
|
-
A dictionary containing the Bearer token for authentication.
|
|
167
|
-
"""
|
|
168
|
-
return {"Authorization": f"Bearer {self.__access_token}"}
|
|
169
|
-
|
|
170
|
-
def __get_access_token(self) -> dict:
|
|
171
|
-
"""
|
|
172
|
-
Gets the Spotify API access token information.
|
|
173
|
-
|
|
174
|
-
Returns
|
|
175
|
-
-------
|
|
176
|
-
dict
|
|
177
|
-
The Spotify API access token, with additional information such as expires in, etc.
|
|
178
|
-
"""
|
|
179
|
-
auth_string = f"{self.client_id}:{self.client_secret}"
|
|
180
|
-
auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
181
|
-
|
|
182
|
-
url = "https://accounts.spotify.com/api/token"
|
|
183
|
-
headers = {
|
|
184
|
-
"Authorization": f"Basic {auth_base64}",
|
|
185
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
186
|
-
}
|
|
187
|
-
data = {"grant_type": "client_credentials"}
|
|
188
|
-
|
|
189
|
-
try:
|
|
190
|
-
logger.info(
|
|
191
|
-
"Authenticating with Spotify API using Client Credentials grant type."
|
|
192
|
-
)
|
|
193
|
-
response = self.__session.post(
|
|
194
|
-
url=url, headers=headers, data=data, timeout=30
|
|
195
|
-
)
|
|
196
|
-
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
197
|
-
response.raise_for_status()
|
|
198
|
-
except requests.RequestException as e:
|
|
199
|
-
raise requests.RequestException(
|
|
200
|
-
f"Network error during Spotify authentication: {e}"
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
if response.status_code == 200:
|
|
204
|
-
response_json = response.json()
|
|
205
|
-
response_json["requested_at"] = time()
|
|
206
|
-
return response_json
|
|
207
|
-
else:
|
|
208
|
-
raise AuthenticationException(
|
|
209
|
-
f"Invalid response received: {response.json()}"
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
def __refresh_access_token(self):
|
|
213
|
-
"""Refreshes the token if it has expired."""
|
|
214
|
-
if not self.__access_token:
|
|
215
|
-
raise SpotifyAuthException("No access token was found.")
|
|
216
|
-
|
|
217
|
-
try:
|
|
218
|
-
if time() - self.__token_requested_at >= self.__token_expires_in:
|
|
219
|
-
token_info = self.__get_access_token()
|
|
220
|
-
|
|
221
|
-
try:
|
|
222
|
-
self.save_access_token(token_info)
|
|
223
|
-
except NotImplementedError as e:
|
|
224
|
-
logger.warning(e)
|
|
225
|
-
|
|
226
|
-
self.__access_token = token_info.get("access_token")
|
|
227
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
228
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
229
|
-
|
|
230
|
-
logger.info("The access token is still valid, no need to refresh.")
|
|
231
|
-
except (AuthenticationException, requests.RequestException) as e:
|
|
232
|
-
logger.warning(
|
|
233
|
-
f"Failed to refresh the access toke due to following error: {e}"
|
|
234
|
-
)
|
|
235
|
-
except TypeError:
|
|
236
|
-
logger.debug(
|
|
237
|
-
f"token requested at: {self.__token_requested_at} | token expires in: {self.__token_expires_in}"
|
|
238
|
-
)
|
|
239
|
-
logger.info(
|
|
240
|
-
"Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
def save_access_token(self, token_info: dict) -> None:
|
|
244
|
-
"""
|
|
245
|
-
Saves the access token and related information.
|
|
246
|
-
|
|
247
|
-
This method must be overridden in a subclass to persist the access token and other
|
|
248
|
-
related information (e.g., expiration time). If not implemented,
|
|
249
|
-
the access token will not be saved, and it will be requested each time the
|
|
250
|
-
application restarts.
|
|
251
|
-
|
|
252
|
-
Parameters
|
|
253
|
-
----------
|
|
254
|
-
token_info : dict
|
|
255
|
-
A dictionary containing the access token and related information, such as
|
|
256
|
-
refresh token, expiration time, etc.
|
|
257
|
-
|
|
258
|
-
Raises
|
|
259
|
-
------
|
|
260
|
-
NotImplementedError
|
|
261
|
-
If the method is not overridden in a subclass.
|
|
262
|
-
"""
|
|
263
|
-
raise NotImplementedError(
|
|
264
|
-
"The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
|
|
265
|
-
"If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
|
|
69
|
+
super().__init__(
|
|
70
|
+
service_name="Spotify",
|
|
71
|
+
access_token_url="https://accounts.spotify.com/api/token",
|
|
72
|
+
client_id=self.client_id,
|
|
73
|
+
client_secret=self.client_secret,
|
|
74
|
+
defer_load=defer_load,
|
|
266
75
|
)
|
|
267
76
|
|
|
268
|
-
|
|
269
|
-
"""
|
|
270
|
-
Loads the access token and related information.
|
|
271
|
-
|
|
272
|
-
This method must be overridden in a subclass to retrieve the access token and other
|
|
273
|
-
related information (e.g., expiration time) from persistent storage.
|
|
274
|
-
If not implemented, the access token will not be loaded, and it will be requested
|
|
275
|
-
each time the application restarts.
|
|
276
|
-
|
|
277
|
-
Returns
|
|
278
|
-
-------
|
|
279
|
-
dict | None
|
|
280
|
-
A dictionary containing the access token and related information, such as
|
|
281
|
-
refresh token, expiration time, etc., or None if no token is found.
|
|
282
|
-
|
|
283
|
-
Raises
|
|
284
|
-
------
|
|
285
|
-
NotImplementedError
|
|
286
|
-
If the method is not overridden in a subclass.
|
|
287
|
-
"""
|
|
288
|
-
raise NotImplementedError(
|
|
289
|
-
"The `load_access_token` method must be overridden in a subclass to load access token and related information. "
|
|
290
|
-
"If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
|
|
291
|
-
)
|
|
77
|
+
self.__api_url = "https://api.spotify.com/v1"
|
|
292
78
|
|
|
293
79
|
def search(
|
|
294
80
|
self,
|
|
@@ -334,7 +120,7 @@ class Spotify:
|
|
|
334
120
|
if music_info:
|
|
335
121
|
return music_info
|
|
336
122
|
|
|
337
|
-
self.
|
|
123
|
+
self._refresh_access_token()
|
|
338
124
|
|
|
339
125
|
query_url = f"{self.__api_url}/search{query}"
|
|
340
126
|
|
|
@@ -344,17 +130,14 @@ class Spotify:
|
|
|
344
130
|
logger.debug(f"Query URL: {query_url}")
|
|
345
131
|
|
|
346
132
|
try:
|
|
347
|
-
response = self.
|
|
348
|
-
query_url, headers=self.
|
|
133
|
+
response = self._session.get(
|
|
134
|
+
query_url, headers=self._authorization_header(), timeout=30
|
|
349
135
|
)
|
|
350
136
|
response.raise_for_status()
|
|
351
137
|
except requests.RequestException as e:
|
|
352
|
-
logger.warning(f"
|
|
138
|
+
logger.warning(f"Failed to search for music: {response.json()}")
|
|
353
139
|
return None
|
|
354
140
|
|
|
355
|
-
if response.status_code != 200:
|
|
356
|
-
raise SpotifyException(f"Failed to search for music: {response.json()}")
|
|
357
|
-
|
|
358
141
|
artist_ids = artist_ids if artist_ids else self._get_artists_ids(artist)
|
|
359
142
|
music_info = self._find_music_info(
|
|
360
143
|
artist, song, response.json(), artist_ids
|
|
@@ -400,8 +183,7 @@ class Spotify:
|
|
|
400
183
|
)
|
|
401
184
|
|
|
402
185
|
self._normalize_non_english = normalize_non_english
|
|
403
|
-
|
|
404
|
-
self.__refresh_access_token()
|
|
186
|
+
self._refresh_access_token()
|
|
405
187
|
|
|
406
188
|
if isrc:
|
|
407
189
|
query = f"?q={artist} {song} isrc:{isrc}&type=track&limit={limit}"
|
|
@@ -412,18 +194,15 @@ class Spotify:
|
|
|
412
194
|
|
|
413
195
|
query_url = f"{self.__api_url}/search{query}"
|
|
414
196
|
try:
|
|
415
|
-
response = self.
|
|
416
|
-
query_url, headers=self.
|
|
197
|
+
response = self._session.get(
|
|
198
|
+
query_url, headers=self._authorization_header(), timeout=30
|
|
417
199
|
)
|
|
418
200
|
response.raise_for_status()
|
|
419
201
|
except requests.RequestException as e:
|
|
420
|
-
logger.warning(
|
|
421
|
-
return None
|
|
422
|
-
|
|
423
|
-
if response.status_code != 200:
|
|
424
|
-
raise SpotifyException(
|
|
202
|
+
raise logger.warning(
|
|
425
203
|
f"Failed to search music with ISRC/UPC: {response.json()}"
|
|
426
204
|
)
|
|
205
|
+
return None
|
|
427
206
|
|
|
428
207
|
artist_ids = self._get_artists_ids(artist)
|
|
429
208
|
return self._find_music_info(artist, song, response.json(), artist_ids)
|
|
@@ -446,8 +225,8 @@ class Spotify:
|
|
|
446
225
|
for name in separate_artists(artist):
|
|
447
226
|
query_url = f"{self.__api_url}/search?q={name}&type=artist&limit=5"
|
|
448
227
|
try:
|
|
449
|
-
response = self.
|
|
450
|
-
query_url, headers=self.
|
|
228
|
+
response = self._session.get(
|
|
229
|
+
query_url, headers=self._authorization_header(), timeout=30
|
|
451
230
|
)
|
|
452
231
|
response.raise_for_status()
|
|
453
232
|
except requests.RequestException as e:
|
|
@@ -531,7 +310,7 @@ class Spotify:
|
|
|
531
310
|
track["name"],
|
|
532
311
|
song,
|
|
533
312
|
use_translation=self._normalize_non_english,
|
|
534
|
-
translation_session=self.
|
|
313
|
+
translation_session=self._translation_session,
|
|
535
314
|
):
|
|
536
315
|
return None
|
|
537
316
|
|
|
@@ -543,7 +322,7 @@ class Spotify:
|
|
|
543
322
|
x["name"],
|
|
544
323
|
artist,
|
|
545
324
|
use_translation=self._normalize_non_english,
|
|
546
|
-
translation_session=self.
|
|
325
|
+
translation_session=self._translation_session,
|
|
547
326
|
)
|
|
548
327
|
or x["id"] in artist_ids
|
|
549
328
|
]
|
|
@@ -594,7 +373,7 @@ class Spotify:
|
|
|
594
373
|
album["name"],
|
|
595
374
|
song,
|
|
596
375
|
use_translation=self._normalize_non_english,
|
|
597
|
-
translation_session=self.
|
|
376
|
+
translation_session=self._translation_session,
|
|
598
377
|
):
|
|
599
378
|
return None
|
|
600
379
|
|
|
@@ -606,7 +385,7 @@ class Spotify:
|
|
|
606
385
|
x["name"],
|
|
607
386
|
artist,
|
|
608
387
|
use_translation=self._normalize_non_english,
|
|
609
|
-
translation_session=self.
|
|
388
|
+
translation_session=self._translation_session,
|
|
610
389
|
)
|
|
611
390
|
or x["id"] in artist_ids
|
|
612
391
|
]
|
|
@@ -637,7 +416,7 @@ class Spotify:
|
|
|
637
416
|
return None
|
|
638
417
|
|
|
639
418
|
|
|
640
|
-
class SpotifyAuth:
|
|
419
|
+
class SpotifyAuth(BaseAuthClient):
|
|
641
420
|
"""
|
|
642
421
|
A class to interact with the Spotify API. It uses "Authorization Code" grant type (or flow).
|
|
643
422
|
|
|
@@ -673,6 +452,7 @@ class SpotifyAuth:
|
|
|
673
452
|
self.client_id = client_id or os.getenv("SPOTIFY_CLIENT_ID")
|
|
674
453
|
self.client_secret = client_secret or os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
675
454
|
self.redirect_uri = redirect_uri or os.getenv("SPOTIFY_REDIRECT_URI")
|
|
455
|
+
self.scopes = scopes
|
|
676
456
|
|
|
677
457
|
if not self.client_id:
|
|
678
458
|
raise SpotifyAuthException(
|
|
@@ -689,366 +469,25 @@ class SpotifyAuth:
|
|
|
689
469
|
"No redirect URI was provided! Set it in environment variable or directly pass it when creating object."
|
|
690
470
|
)
|
|
691
471
|
|
|
692
|
-
self.scope = scopes
|
|
693
|
-
self.defer_load = defer_load
|
|
694
|
-
|
|
695
|
-
self._is_session_closed = False
|
|
696
|
-
|
|
697
|
-
self.__api_url = "https://api.spotify.com/v1/me"
|
|
698
|
-
self.__access_token = None
|
|
699
|
-
self.__refresh_token = None
|
|
700
|
-
self.__token_expires_in = None
|
|
701
|
-
self.__token_requested_at = None
|
|
702
|
-
self.__session = requests.Session()
|
|
703
|
-
|
|
704
472
|
if not scopes:
|
|
705
473
|
logger.warning(
|
|
706
474
|
"No scopes were provided. Authorization will only grant access to publicly available information."
|
|
707
475
|
)
|
|
708
|
-
self.scope = None
|
|
709
|
-
else:
|
|
710
|
-
self.scope = " ".join(scopes)
|
|
711
|
-
|
|
712
|
-
if not defer_load:
|
|
713
|
-
# Attempt to load access token during initialization if not deferred
|
|
714
|
-
try:
|
|
715
|
-
token_info = self.load_access_token()
|
|
716
|
-
if token_info:
|
|
717
|
-
self.__access_token = token_info.get("access_token")
|
|
718
|
-
self.__refresh_token = token_info.get("refresh_token")
|
|
719
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
720
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
721
|
-
else:
|
|
722
|
-
logger.warning(
|
|
723
|
-
"No access token found during initialization. You must authenticate to obtain a new token."
|
|
724
|
-
)
|
|
725
|
-
except NotImplementedError:
|
|
726
|
-
logger.warning(
|
|
727
|
-
"`load_access_token` is not implemented. Falling back to in-memory storage."
|
|
728
|
-
)
|
|
729
|
-
else:
|
|
730
|
-
logger.warning(
|
|
731
|
-
"`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
|
|
732
|
-
)
|
|
733
|
-
|
|
734
|
-
def __enter__(self):
|
|
735
|
-
"""Enters the runtime context related to this object."""
|
|
736
|
-
return self
|
|
737
|
-
|
|
738
|
-
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
739
|
-
"""Exits the runtime context related to this object."""
|
|
740
|
-
self.close_session()
|
|
741
|
-
|
|
742
|
-
def close_session(self) -> None:
|
|
743
|
-
"""Closes the current session(s)."""
|
|
744
|
-
if not self.is_session_closed:
|
|
745
|
-
self.__session.close()
|
|
746
|
-
self._is_session_closed = True
|
|
747
|
-
|
|
748
|
-
@property
|
|
749
|
-
def is_session_closed(self) -> bool:
|
|
750
|
-
"""Checks if the session is closed."""
|
|
751
|
-
return self._is_session_closed
|
|
752
|
-
|
|
753
|
-
def load_token_after_init(self):
|
|
754
|
-
"""
|
|
755
|
-
Explicitly load the access token after initialization.
|
|
756
|
-
This is useful when ``defer_load`` is set to ``True`` during initialization.
|
|
757
|
-
"""
|
|
758
|
-
try:
|
|
759
|
-
token_info = self.load_access_token()
|
|
760
|
-
if token_info:
|
|
761
|
-
self.__access_token = token_info.get("access_token")
|
|
762
|
-
self.__refresh_token = token_info.get("refresh_token")
|
|
763
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
764
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
765
|
-
else:
|
|
766
|
-
logger.warning(
|
|
767
|
-
"No access token found. You must authenticate to obtain a new token."
|
|
768
|
-
)
|
|
769
|
-
except NotImplementedError:
|
|
770
|
-
logger.warning(
|
|
771
|
-
"`load_access_token` is not implemented. Falling back to in-memory storage."
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
def __authorization_header(self) -> dict:
|
|
775
|
-
"""
|
|
776
|
-
Generates the authorization header for Spotify API requests.
|
|
777
|
-
|
|
778
|
-
Returns
|
|
779
|
-
-------
|
|
780
|
-
dict
|
|
781
|
-
A dictionary containing the Bearer token for authentication.
|
|
782
|
-
"""
|
|
783
|
-
return {"Authorization": f"Bearer {self.__access_token}"}
|
|
784
|
-
|
|
785
|
-
def __get_access_token(
|
|
786
|
-
self,
|
|
787
|
-
authorization_code: str = None,
|
|
788
|
-
refresh_token: str = None,
|
|
789
|
-
) -> dict:
|
|
790
|
-
"""
|
|
791
|
-
Gets the Spotify API access token information.
|
|
792
|
-
|
|
793
|
-
If ``authorization_code`` provided, it will try to get a new access token from Spotify.
|
|
794
|
-
Otherwise, if `refresh_token` is provided, it will refresh the access token using it
|
|
795
|
-
and return new access token information.
|
|
796
|
-
|
|
797
|
-
Returns
|
|
798
|
-
-------
|
|
799
|
-
dict
|
|
800
|
-
The Spotify API access token, with additional information such as expires in, refresh token, etc.
|
|
801
|
-
"""
|
|
802
|
-
auth_string = f"{self.client_id}:{self.client_secret}"
|
|
803
|
-
auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
804
|
-
|
|
805
|
-
url = "https://accounts.spotify.com/api/token"
|
|
806
|
-
headers = {
|
|
807
|
-
"Authorization": f"Basic {auth_base64}",
|
|
808
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if authorization_code:
|
|
812
|
-
data = {
|
|
813
|
-
"grant_type": "authorization_code",
|
|
814
|
-
"code": authorization_code,
|
|
815
|
-
"redirect_uri": self.redirect_uri,
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
if refresh_token:
|
|
819
|
-
data = {
|
|
820
|
-
"grant_type": "refresh_token",
|
|
821
|
-
"refresh_token": self.__refresh_token,
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
try:
|
|
825
|
-
logger.info(
|
|
826
|
-
"Authenticating with Spotify API using Authorization Code grant type."
|
|
827
|
-
)
|
|
828
|
-
response = self.__session.post(
|
|
829
|
-
url=url, headers=headers, data=data, timeout=30
|
|
830
|
-
)
|
|
831
|
-
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
832
|
-
response.raise_for_status()
|
|
833
|
-
except requests.RequestException as e:
|
|
834
|
-
raise requests.RequestException(
|
|
835
|
-
f"Network error during Spotify authentication: {e}"
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
if response.status_code == 200:
|
|
839
|
-
response_json = response.json()
|
|
840
|
-
response_json["requested_at"] = time()
|
|
841
|
-
return response_json
|
|
842
476
|
else:
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
token_info = self.__get_access_token(refresh_token=self.__refresh_token)
|
|
855
|
-
|
|
856
|
-
try:
|
|
857
|
-
self.save_access_token(token_info)
|
|
858
|
-
except NotImplementedError as e:
|
|
859
|
-
logger.warning(e)
|
|
860
|
-
|
|
861
|
-
self.__access_token = token_info.get("access_token")
|
|
862
|
-
self.__refresh_token = token_info.get("refresh_token")
|
|
863
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
864
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
865
|
-
|
|
866
|
-
logger.info("The access token is still valid, no need to refresh.")
|
|
867
|
-
except (AuthenticationException, requests.RequestException) as e:
|
|
868
|
-
logger.warning(f"Failed to refresh the access toke due to following error: {e}")
|
|
869
|
-
except TypeError:
|
|
870
|
-
logger.debug(
|
|
871
|
-
f"token requested at: {self.__token_requested_at} | token expires in: {self.__token_expires_in}"
|
|
872
|
-
)
|
|
873
|
-
logger.warning(
|
|
874
|
-
"Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
|
|
875
|
-
)
|
|
876
|
-
|
|
877
|
-
@staticmethod
|
|
878
|
-
def generate_state() -> str:
|
|
879
|
-
"""
|
|
880
|
-
Generates a random state string for use in OAuth 2.0 authorization.
|
|
881
|
-
|
|
882
|
-
This method creates a cryptographically secure, URL-safe string that can be used
|
|
883
|
-
to prevent cross-site request forgery (CSRF) attacks during the authorization process.
|
|
884
|
-
|
|
885
|
-
Returns
|
|
886
|
-
-------
|
|
887
|
-
str
|
|
888
|
-
A random URL-safe string to be used as the state parameter in OAuth 2.0.
|
|
889
|
-
"""
|
|
890
|
-
return secrets.token_urlsafe(16)
|
|
891
|
-
|
|
892
|
-
def get_authorization_url(self, state: str = None, show_dialog: bool = False):
|
|
893
|
-
"""
|
|
894
|
-
Constructs the Spotify authorization URL for user authentication.
|
|
895
|
-
|
|
896
|
-
This method generates a URL that can be used to redirect users to Spotify's
|
|
897
|
-
authorization page for user authentication.
|
|
898
|
-
|
|
899
|
-
Parameters
|
|
900
|
-
----------
|
|
901
|
-
state : str, optional
|
|
902
|
-
A random string to maintain state between the request and callback.
|
|
903
|
-
If not provided, no state parameter is included.
|
|
904
|
-
|
|
905
|
-
You may use :meth:`SpotifyAuth.generate_state` method to generate one.
|
|
906
|
-
show_dialog : bool, optional
|
|
907
|
-
Whether or not to force the user to approve the app again if they’ve already done so.
|
|
908
|
-
If ``False`` (default), a user who has already approved the application may be automatically
|
|
909
|
-
redirected to the URI specified by redirect_uri. If ``True``, the user will not be automatically
|
|
910
|
-
redirected and will have to approve the app again.
|
|
911
|
-
|
|
912
|
-
Returns
|
|
913
|
-
-------
|
|
914
|
-
str
|
|
915
|
-
The full authorization URL to redirect users for Spotify authentication.
|
|
916
|
-
"""
|
|
917
|
-
auth_endpoint = "https://accounts.spotify.com/authorize"
|
|
918
|
-
payload = {
|
|
919
|
-
"response_type": "code",
|
|
920
|
-
"client_id": self.client_id,
|
|
921
|
-
"redirect_uri": self.redirect_uri,
|
|
922
|
-
"show_dialog": show_dialog,
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
if self.scope:
|
|
926
|
-
payload["scope"] = self.scope
|
|
927
|
-
|
|
928
|
-
if state:
|
|
929
|
-
payload["state"] = state
|
|
930
|
-
|
|
931
|
-
return f"{auth_endpoint}?{urlencode(payload)}"
|
|
932
|
-
|
|
933
|
-
def save_access_token(self, token_info: dict) -> None:
|
|
934
|
-
"""
|
|
935
|
-
Saves the access token and related information.
|
|
936
|
-
|
|
937
|
-
This method must be overridden in a subclass to persist the access token and other
|
|
938
|
-
related information (e.g., refresh token, expiration time). If not implemented,
|
|
939
|
-
the access token will not be saved, and users will need to re-authenticate after
|
|
940
|
-
application restarts.
|
|
941
|
-
|
|
942
|
-
Parameters
|
|
943
|
-
----------
|
|
944
|
-
token_info : dict
|
|
945
|
-
A dictionary containing the access token and related information, such as
|
|
946
|
-
refresh token, expiration time, etc.
|
|
947
|
-
|
|
948
|
-
Raises
|
|
949
|
-
------
|
|
950
|
-
NotImplementedError
|
|
951
|
-
If the method is not overridden in a subclass.
|
|
952
|
-
"""
|
|
953
|
-
raise NotImplementedError(
|
|
954
|
-
"The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
|
|
955
|
-
"If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
|
|
477
|
+
self.scopes = " ".join(scopes)
|
|
478
|
+
|
|
479
|
+
super().__init__(
|
|
480
|
+
service_name="Spotify",
|
|
481
|
+
access_token_url="https://accounts.spotify.com/api/token",
|
|
482
|
+
user_auth_url="https://accounts.spotify.com/authorize",
|
|
483
|
+
client_id=self.client_id,
|
|
484
|
+
client_secret=self.client_secret,
|
|
485
|
+
redirect_uri=self.redirect_uri,
|
|
486
|
+
scopes=self.scopes,
|
|
487
|
+
defer_load=defer_load,
|
|
956
488
|
)
|
|
957
489
|
|
|
958
|
-
|
|
959
|
-
"""
|
|
960
|
-
Loads the access token and related information.
|
|
961
|
-
|
|
962
|
-
This method must be overridden in a subclass to retrieve the access token and other
|
|
963
|
-
related information (e.g., refresh token, expiration time) from persistent storage.
|
|
964
|
-
If not implemented, the access token will not be loaded, and users will need to
|
|
965
|
-
re-authenticate after application restarts.
|
|
966
|
-
|
|
967
|
-
Returns
|
|
968
|
-
-------
|
|
969
|
-
dict | None
|
|
970
|
-
A dictionary containing the access token and related information, such as
|
|
971
|
-
refresh token, expiration time, etc., or None if no token is found.
|
|
972
|
-
|
|
973
|
-
Raises
|
|
974
|
-
------
|
|
975
|
-
NotImplementedError
|
|
976
|
-
If the method is not overridden in a subclass.
|
|
977
|
-
"""
|
|
978
|
-
raise NotImplementedError(
|
|
979
|
-
"The `load_access_token` method must be overridden in a subclass to load access token and related information. "
|
|
980
|
-
"If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
|
|
981
|
-
)
|
|
982
|
-
|
|
983
|
-
def callback_handler(self, code, state, expected_state):
|
|
984
|
-
"""
|
|
985
|
-
Handles the callback phase of the OAuth 2.0 authorization process.
|
|
986
|
-
|
|
987
|
-
This method processes the authorization code and state returned by Spotify after the user
|
|
988
|
-
has granted permission. It validates the state to prevent CSRF attacks, exchanges the
|
|
989
|
-
authorization code for an access token, and saves the token for future use.
|
|
990
|
-
|
|
991
|
-
Parameters
|
|
992
|
-
----------
|
|
993
|
-
code : str
|
|
994
|
-
The authorization code returned by Spotify after user authorization.
|
|
995
|
-
state : str
|
|
996
|
-
The state parameter returned by Spotify to ensure the request's integrity.
|
|
997
|
-
expected_state : str
|
|
998
|
-
The original state parameter sent during the authorization request, used to validate the response.
|
|
999
|
-
|
|
1000
|
-
Raises
|
|
1001
|
-
------
|
|
1002
|
-
SpotifyAuthException
|
|
1003
|
-
If the returned state does not match the expected state.
|
|
1004
|
-
|
|
1005
|
-
Notes
|
|
1006
|
-
-----
|
|
1007
|
-
- This method can be used in a web application (e.g., Flask) in the `/callback` route to handle
|
|
1008
|
-
successful authorization.
|
|
1009
|
-
- Ensure that the ``save_access_token`` and ``load_access_token`` methods are implemented in a subclass
|
|
1010
|
-
if token persistence is required.
|
|
1011
|
-
|
|
1012
|
-
Example
|
|
1013
|
-
-------
|
|
1014
|
-
In a Flask application, you can use this method in the ``/callback`` route:
|
|
1015
|
-
|
|
1016
|
-
.. code-block:: python
|
|
1017
|
-
|
|
1018
|
-
@app.route('/callback')
|
|
1019
|
-
def callback():
|
|
1020
|
-
code = request.args.get('code')
|
|
1021
|
-
state = request.args.get('state')
|
|
1022
|
-
expected_state = session['state'] # Retrieve the state stored during authorization URL generation
|
|
1023
|
-
|
|
1024
|
-
try:
|
|
1025
|
-
spotify_auth.callback_handler(code, state, expected_state)
|
|
1026
|
-
return "Authorization successful!"
|
|
1027
|
-
except SpotifyAuthException as e:
|
|
1028
|
-
return f"Authorization failed: {e}", 400
|
|
1029
|
-
"""
|
|
1030
|
-
if state != expected_state:
|
|
1031
|
-
raise SpotifyAuthException("state does not match!")
|
|
1032
|
-
|
|
1033
|
-
token_info = None
|
|
1034
|
-
|
|
1035
|
-
try:
|
|
1036
|
-
token_info = self.load_access_token()
|
|
1037
|
-
except NotImplementedError as e:
|
|
1038
|
-
logger.warning(e)
|
|
1039
|
-
|
|
1040
|
-
if not token_info:
|
|
1041
|
-
token_info = self.__get_access_token(authorization_code=code)
|
|
1042
|
-
|
|
1043
|
-
self.__access_token = token_info.get("access_token")
|
|
1044
|
-
self.__refresh_token = token_info.get("refresh_token")
|
|
1045
|
-
self.__token_expires_in = token_info.get("expires_in")
|
|
1046
|
-
self.__token_requested_at = token_info.get("requested_at")
|
|
1047
|
-
|
|
1048
|
-
try:
|
|
1049
|
-
self.save_access_token(token_info)
|
|
1050
|
-
except NotImplementedError as e:
|
|
1051
|
-
logger.warning(e)
|
|
490
|
+
self.__api_url = "https://api.spotify.com/v1/me"
|
|
1052
491
|
|
|
1053
492
|
def get_user_profile(self) -> Optional[dict]:
|
|
1054
493
|
"""
|
|
@@ -1063,19 +502,11 @@ class SpotifyAuth:
|
|
|
1063
502
|
dict
|
|
1064
503
|
A dictionary containing the user's display name and profile images.
|
|
1065
504
|
"""
|
|
1066
|
-
try:
|
|
1067
|
-
self.__refresh_access_token()
|
|
1068
|
-
except SpotifyAuthException:
|
|
1069
|
-
logger.warning(
|
|
1070
|
-
"No access token was found. You may authenticate the user again."
|
|
1071
|
-
)
|
|
1072
|
-
return None
|
|
1073
|
-
|
|
1074
505
|
query_url = self.__api_url
|
|
1075
|
-
header = self.
|
|
506
|
+
header = self._authorization_header()
|
|
1076
507
|
|
|
1077
508
|
try:
|
|
1078
|
-
response = self.
|
|
509
|
+
response = self._session.get(query_url, headers=header, timeout=30)
|
|
1079
510
|
response.raise_for_status()
|
|
1080
511
|
except requests.RequestException as e:
|
|
1081
512
|
logger.warning(f"Failed to fetch user profile: {e}")
|
|
@@ -1089,7 +520,7 @@ class SpotifyAuth:
|
|
|
1089
520
|
return {
|
|
1090
521
|
"display_name": result.get("display_name"),
|
|
1091
522
|
"images": result.get("images", []),
|
|
1092
|
-
"url": result.get("external_urls", {}).get("spotify")
|
|
523
|
+
"url": result.get("external_urls", {}).get("spotify"),
|
|
1093
524
|
}
|
|
1094
525
|
|
|
1095
526
|
def get_currently_playing(self) -> Optional[UserPlaying]:
|
|
@@ -1113,19 +544,11 @@ class SpotifyAuth:
|
|
|
1113
544
|
- If the API response does not contain the expected data, the method will return `None`.
|
|
1114
545
|
|
|
1115
546
|
"""
|
|
1116
|
-
try:
|
|
1117
|
-
self.__refresh_access_token()
|
|
1118
|
-
except SpotifyAuthException:
|
|
1119
|
-
logger.warning(
|
|
1120
|
-
"No access token was found. You may authenticate the user again."
|
|
1121
|
-
)
|
|
1122
|
-
return None
|
|
1123
|
-
|
|
1124
547
|
query_url = f"{self.__api_url}/player/currently-playing"
|
|
1125
|
-
header = self.
|
|
548
|
+
header = self._authorization_header()
|
|
1126
549
|
|
|
1127
550
|
try:
|
|
1128
|
-
response = self.
|
|
551
|
+
response = self._session.get(query_url, headers=header, timeout=30)
|
|
1129
552
|
response.raise_for_status()
|
|
1130
553
|
except requests.RequestException as e:
|
|
1131
554
|
logger.warning(f"Error while getting Spotify user activity: {e}")
|
|
@@ -1134,14 +557,6 @@ class SpotifyAuth:
|
|
|
1134
557
|
if response.status_code == 204:
|
|
1135
558
|
logger.info("Requested user is currently not listening to any music.")
|
|
1136
559
|
return None
|
|
1137
|
-
if response.status_code != 200:
|
|
1138
|
-
try:
|
|
1139
|
-
logger.warning(f"Unexpected response: {response.json()}")
|
|
1140
|
-
except requests.exceptions.JSONDecodeError:
|
|
1141
|
-
logger.warning(
|
|
1142
|
-
f"Response Code: {response.status_code}, Reason: {response.reason}"
|
|
1143
|
-
)
|
|
1144
|
-
return None
|
|
1145
560
|
|
|
1146
561
|
response_json = response.json()
|
|
1147
562
|
result = response_json.get("item")
|
|
@@ -1194,7 +609,7 @@ if __name__ == "__main__":
|
|
|
1194
609
|
choice = input("\nEnter your choice (1 or 2): ")
|
|
1195
610
|
|
|
1196
611
|
if choice == "1":
|
|
1197
|
-
spotify = Spotify(
|
|
612
|
+
spotify = Spotify()
|
|
1198
613
|
|
|
1199
614
|
try:
|
|
1200
615
|
artist_name = input("Artist Name: ")
|
|
@@ -1208,12 +623,7 @@ if __name__ == "__main__":
|
|
|
1208
623
|
redirect_uri = input("Enter Redirect URI: ")
|
|
1209
624
|
scopes = ["user-read-email", "user-read-private"]
|
|
1210
625
|
|
|
1211
|
-
spotify_auth = SpotifyAuth(
|
|
1212
|
-
client_id=SPOTIFY_CLIENT_ID,
|
|
1213
|
-
client_secret=SPOTIFY_CLIENT_SECRET,
|
|
1214
|
-
redirect_uri=redirect_uri,
|
|
1215
|
-
scopes=scopes,
|
|
1216
|
-
)
|
|
626
|
+
spotify_auth = SpotifyAuth(scopes=scopes)
|
|
1217
627
|
|
|
1218
628
|
try:
|
|
1219
629
|
state = spotify_auth.generate_state()
|