yutipy 2.2.5__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 +6 -4
- yutipy/musicyt.py +3 -5
- yutipy/spotify.py +46 -636
- {yutipy-2.2.5.dist-info → yutipy-2.2.7.dist-info}/METADATA +4 -4
- yutipy-2.2.7.dist-info/RECORD +23 -0
- {yutipy-2.2.5.dist-info → yutipy-2.2.7.dist-info}/WHEEL +1 -1
- yutipy-2.2.5.dist-info/RECORD +0 -22
- {yutipy-2.2.5.dist-info → yutipy-2.2.7.dist-info}/entry_points.txt +0 -0
- {yutipy-2.2.5.dist-info → yutipy-2.2.7.dist-info}/licenses/LICENSE +0 -0
- {yutipy-2.2.5.dist-info → yutipy-2.2.7.dist-info}/top_level.txt +0 -0
yutipy/base_clients.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
__all__ = ["BaseClient", "BaseAuthClient"]
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import secrets
|
|
5
|
+
from time import time
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from yutipy.exceptions import AuthenticationException, InvalidValueException
|
|
11
|
+
from yutipy.logger import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseClient:
|
|
15
|
+
"""Base class for Client Credentials grant type/flow."""
|
|
16
|
+
|
|
17
|
+
SERVICE_NAME: str
|
|
18
|
+
ACCESS_TOKEN_URL: str
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
service_name: str,
|
|
23
|
+
access_token_url: str,
|
|
24
|
+
client_id: str = None,
|
|
25
|
+
client_secret: str = None,
|
|
26
|
+
defer_load: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initializes client (using Client Credentials grant type/flow) and sets up the session.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
service_name : str
|
|
33
|
+
The service name class belongs to. For example, "Spotify".
|
|
34
|
+
access_token_url : str
|
|
35
|
+
The url endpoint to request access token.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
self.SERVICE_NAME = service_name
|
|
39
|
+
self.ACCESS_TOKEN_URL = access_token_url
|
|
40
|
+
|
|
41
|
+
self._client_id = client_id
|
|
42
|
+
self._client_secret = client_secret
|
|
43
|
+
|
|
44
|
+
self._defer_load = defer_load
|
|
45
|
+
self._is_session_closed = False
|
|
46
|
+
self._normalize_non_english = True
|
|
47
|
+
|
|
48
|
+
self._access_token = None
|
|
49
|
+
self._token_expires_in = None
|
|
50
|
+
self._token_requested_at = None
|
|
51
|
+
self._session = requests.Session()
|
|
52
|
+
self._translation_session = requests.Session()
|
|
53
|
+
|
|
54
|
+
if not defer_load:
|
|
55
|
+
# Attempt to load access token during initialization if not deferred
|
|
56
|
+
self.load_token_after_init()
|
|
57
|
+
else:
|
|
58
|
+
logger.warning(
|
|
59
|
+
"`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
"""Enters the runtime context related to this object."""
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
|
67
|
+
"""Exits the runtime context related to this object."""
|
|
68
|
+
self.close_session()
|
|
69
|
+
|
|
70
|
+
def close_session(self) -> None:
|
|
71
|
+
"""Closes the current session(s)."""
|
|
72
|
+
if not self.is_session_closed:
|
|
73
|
+
self._session.close()
|
|
74
|
+
self._translation_session.close()
|
|
75
|
+
self._is_session_closed = True
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_session_closed(self) -> bool:
|
|
79
|
+
"""Checks if the session is closed."""
|
|
80
|
+
return self._is_session_closed
|
|
81
|
+
|
|
82
|
+
def load_token_after_init(self) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Explicitly load the access token after initialization.
|
|
85
|
+
This is useful when ``defer_load`` is set to ``True`` during initialization.
|
|
86
|
+
"""
|
|
87
|
+
token_info = None
|
|
88
|
+
try:
|
|
89
|
+
token_info = self.load_access_token()
|
|
90
|
+
if not isinstance(token_info, dict):
|
|
91
|
+
raise InvalidValueException(
|
|
92
|
+
"`load_access_token()` should return a dict."
|
|
93
|
+
)
|
|
94
|
+
except NotImplementedError:
|
|
95
|
+
logger.warning(
|
|
96
|
+
"`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
if not token_info:
|
|
100
|
+
token_info = self._get_access_token()
|
|
101
|
+
self._access_token = token_info.get("access_token")
|
|
102
|
+
self._token_expires_in = token_info.get("expires_in")
|
|
103
|
+
self._token_requested_at = token_info.get("requested_at")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
self.save_access_token(token_info)
|
|
107
|
+
except NotImplementedError:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"`save_access_token` is not implemented, falling back to in-memory storage. Access token will not be saved."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _authorization_header(self) -> dict:
|
|
113
|
+
"""
|
|
114
|
+
Generates the authorization header for API requests.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
dict
|
|
119
|
+
A dictionary containing the Bearer token for authentication.
|
|
120
|
+
"""
|
|
121
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
122
|
+
|
|
123
|
+
def _get_access_token(self) -> dict:
|
|
124
|
+
"""
|
|
125
|
+
Gets the API access token information.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
dict
|
|
130
|
+
The API access token, with additional information such as expires in, etc.
|
|
131
|
+
"""
|
|
132
|
+
auth_string = f"{self._client_id}:{self._client_secret}"
|
|
133
|
+
auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
134
|
+
|
|
135
|
+
url = self.ACCESS_TOKEN_URL
|
|
136
|
+
headers = {
|
|
137
|
+
"Authorization": f"Basic {auth_base64}",
|
|
138
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
139
|
+
}
|
|
140
|
+
data = {"grant_type": "client_credentials"}
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
logger.info(
|
|
144
|
+
f"Authenticating with {self.SERVICE_NAME} API using Client Credentials grant type."
|
|
145
|
+
)
|
|
146
|
+
response = self._session.post(
|
|
147
|
+
url=url, headers=headers, data=data, timeout=30
|
|
148
|
+
)
|
|
149
|
+
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
except requests.RequestException as e:
|
|
152
|
+
raise AuthenticationException(
|
|
153
|
+
f"Something went wrong authenticating with {self.SERVICE_NAME}: {e}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
response_json = response.json()
|
|
157
|
+
response_json["requested_at"] = time()
|
|
158
|
+
return response_json
|
|
159
|
+
|
|
160
|
+
def _refresh_access_token(self) -> None:
|
|
161
|
+
"""Refreshes the token if it has expired."""
|
|
162
|
+
try:
|
|
163
|
+
if time() - self._token_requested_at >= self._token_expires_in:
|
|
164
|
+
token_info = self._get_access_token()
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
self.save_access_token(token_info)
|
|
168
|
+
except NotImplementedError as e:
|
|
169
|
+
logger.warning(e)
|
|
170
|
+
|
|
171
|
+
self._access_token = token_info.get("access_token")
|
|
172
|
+
self._token_expires_in = token_info.get("expires_in")
|
|
173
|
+
self._token_requested_at = token_info.get("requested_at")
|
|
174
|
+
else:
|
|
175
|
+
logger.info("The access token is still valid, no need to refresh.")
|
|
176
|
+
except TypeError:
|
|
177
|
+
logger.debug(
|
|
178
|
+
f"token requested at: {self._token_requested_at} | token expires in: {self._token_expires_in}"
|
|
179
|
+
)
|
|
180
|
+
logger.info(
|
|
181
|
+
"Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def save_access_token(self, token_info: dict) -> None:
|
|
185
|
+
"""
|
|
186
|
+
Saves the access token and related information.
|
|
187
|
+
|
|
188
|
+
This method must be overridden in a subclass to persist the access token and other
|
|
189
|
+
related information (e.g., expiration time). If not implemented,
|
|
190
|
+
the access token will not be saved, and it will be requested each time the
|
|
191
|
+
application restarts.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
token_info : dict
|
|
196
|
+
A dictionary containing the access token and related information, such as
|
|
197
|
+
refresh token, expiration time, etc.
|
|
198
|
+
|
|
199
|
+
Raises
|
|
200
|
+
------
|
|
201
|
+
NotImplementedError
|
|
202
|
+
If the method is not overridden in a subclass.
|
|
203
|
+
"""
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
"The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
|
|
206
|
+
"If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def load_access_token(self) -> dict:
|
|
210
|
+
"""
|
|
211
|
+
Loads the access token and related information.
|
|
212
|
+
|
|
213
|
+
This method must be overridden in a subclass to retrieve the access token and other
|
|
214
|
+
related information (e.g., expiration time) from persistent storage.
|
|
215
|
+
If not implemented, the access token will not be loaded, and it will be requested
|
|
216
|
+
each time the application restarts.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
dict | None
|
|
221
|
+
A dictionary containing the access token and related information, such as
|
|
222
|
+
refresh token, expiration time, etc., or None if no token is found.
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
NotImplementedError
|
|
227
|
+
If the method is not overridden in a subclass.
|
|
228
|
+
"""
|
|
229
|
+
raise NotImplementedError(
|
|
230
|
+
"The `load_access_token` method must be overridden in a subclass to load access token and related information. "
|
|
231
|
+
"If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class BaseAuthClient:
|
|
236
|
+
"""Base class for Authorization Code grant type/flow."""
|
|
237
|
+
|
|
238
|
+
SERVICE_NAME: str
|
|
239
|
+
ACCESS_TOKEN_URL: str
|
|
240
|
+
USER_AUTH_URL: str
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
service_name: str,
|
|
245
|
+
access_token_url: str,
|
|
246
|
+
user_auth_url: str,
|
|
247
|
+
client_id: str,
|
|
248
|
+
client_secret: str,
|
|
249
|
+
redirect_uri: str,
|
|
250
|
+
scopes: str = None,
|
|
251
|
+
defer_load: bool = False,
|
|
252
|
+
):
|
|
253
|
+
"""
|
|
254
|
+
Initializes client (using Authorization Code grant type/flow) and sets up the session.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
service_name : str
|
|
259
|
+
The service name class belongs to. For example, "Spotify".
|
|
260
|
+
access_token_url : str
|
|
261
|
+
The url endpoint to request access token.
|
|
262
|
+
user_auth_url : str
|
|
263
|
+
The url endpoint for user authentication.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
self.SERVICE_NAME = service_name
|
|
267
|
+
self.ACCESS_TOKEN_URL = access_token_url
|
|
268
|
+
self.USER_AUTH_URL = user_auth_url
|
|
269
|
+
|
|
270
|
+
self._client_id = client_id
|
|
271
|
+
self._client_secret = client_secret
|
|
272
|
+
self._redirect_uri = redirect_uri
|
|
273
|
+
self._scopes = scopes
|
|
274
|
+
|
|
275
|
+
self._defer_load = defer_load
|
|
276
|
+
self._is_session_closed = False
|
|
277
|
+
|
|
278
|
+
self._access_token = None
|
|
279
|
+
self._refresh_token = None
|
|
280
|
+
self._token_expires_in = None
|
|
281
|
+
self._token_requested_at = None
|
|
282
|
+
self._session = requests.Session()
|
|
283
|
+
|
|
284
|
+
if not defer_load:
|
|
285
|
+
# Attempt to load access token during initialization if not deferred
|
|
286
|
+
self.load_token_after_init()
|
|
287
|
+
else:
|
|
288
|
+
logger.warning(
|
|
289
|
+
"`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def __enter__(self):
|
|
293
|
+
"""Enters the runtime context related to this object."""
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
297
|
+
"""Exits the runtime context related to this object."""
|
|
298
|
+
self.close_session()
|
|
299
|
+
|
|
300
|
+
def close_session(self) -> None:
|
|
301
|
+
"""Closes the current session(s)."""
|
|
302
|
+
if not self.is_session_closed:
|
|
303
|
+
self._session.close()
|
|
304
|
+
self._is_session_closed = True
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def is_session_closed(self) -> bool:
|
|
308
|
+
"""Checks if the session is closed."""
|
|
309
|
+
return self._is_session_closed
|
|
310
|
+
|
|
311
|
+
def load_token_after_init(self):
|
|
312
|
+
"""
|
|
313
|
+
Explicitly load the access token after initialization.
|
|
314
|
+
This is useful when ``defer_load`` is set to ``True`` during initialization.
|
|
315
|
+
"""
|
|
316
|
+
token_info = None
|
|
317
|
+
try:
|
|
318
|
+
token_info = self.load_access_token()
|
|
319
|
+
if not isinstance(token_info, dict):
|
|
320
|
+
raise InvalidValueException(
|
|
321
|
+
"`load_access_token()` should return a dict."
|
|
322
|
+
)
|
|
323
|
+
except NotImplementedError:
|
|
324
|
+
logger.warning(
|
|
325
|
+
"`load_access_token` is not implemented. Falling back to in-memory storage."
|
|
326
|
+
)
|
|
327
|
+
finally:
|
|
328
|
+
if token_info:
|
|
329
|
+
self._access_token = token_info.get("access_token")
|
|
330
|
+
self._refresh_token = token_info.get("refresh_token")
|
|
331
|
+
self._token_expires_in = token_info.get("expires_in")
|
|
332
|
+
self._token_requested_at = token_info.get("requested_at")
|
|
333
|
+
else:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"No access token found during initialization. You must authenticate to obtain a new token."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _authorization_header(self) -> dict:
|
|
339
|
+
"""
|
|
340
|
+
Generates the authorization header for Spotify API requests.
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
dict
|
|
345
|
+
A dictionary containing the Bearer token for authentication.
|
|
346
|
+
"""
|
|
347
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
348
|
+
|
|
349
|
+
def _get_access_token(
|
|
350
|
+
self,
|
|
351
|
+
authorization_code: str = None,
|
|
352
|
+
refresh_token: str = None,
|
|
353
|
+
) -> dict:
|
|
354
|
+
"""
|
|
355
|
+
Gets the API access token information.
|
|
356
|
+
|
|
357
|
+
If ``authorization_code`` provided, it will try to get a new access token. Otherwise, if ``refresh_token`` is provided,
|
|
358
|
+
it will refresh the access token using it and return new access token information.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
dict
|
|
363
|
+
The API access token, with additional information such as expires in, refresh token, etc.
|
|
364
|
+
"""
|
|
365
|
+
auth_string = f"{self.client_id}:{self.client_secret}"
|
|
366
|
+
auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
367
|
+
|
|
368
|
+
url = self.ACCESS_TOKEN_URL
|
|
369
|
+
headers = {
|
|
370
|
+
"Authorization": f"Basic {auth_base64}",
|
|
371
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if authorization_code:
|
|
375
|
+
data = {
|
|
376
|
+
"grant_type": "authorization_code",
|
|
377
|
+
"code": authorization_code,
|
|
378
|
+
"redirect_uri": self.redirect_uri,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if refresh_token:
|
|
382
|
+
data = {
|
|
383
|
+
"grant_type": "refresh_token",
|
|
384
|
+
"refresh_token": self._refresh_token,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
logger.info(
|
|
389
|
+
f"Authenticating with {self.SERVICE_NAME} API using Authorization Code grant type."
|
|
390
|
+
)
|
|
391
|
+
response = self._session.post(
|
|
392
|
+
url=url, headers=headers, data=data, timeout=30
|
|
393
|
+
)
|
|
394
|
+
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
395
|
+
response.raise_for_status()
|
|
396
|
+
except requests.RequestException as e:
|
|
397
|
+
raise AuthenticationException(
|
|
398
|
+
f"Something went wrong authenticating with {self.SERVICE_NAME}: {e}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
response_json = response.json()
|
|
402
|
+
response_json["requested_at"] = time()
|
|
403
|
+
return response_json
|
|
404
|
+
|
|
405
|
+
def _refresh_access_token(self):
|
|
406
|
+
"""Refreshes the token if it has expired."""
|
|
407
|
+
try:
|
|
408
|
+
if time() - self._token_requested_at >= self._token_expires_in:
|
|
409
|
+
token_info = self._get_access_token(refresh_token=self._refresh_token)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
self.save_access_token(token_info)
|
|
413
|
+
except NotImplementedError as e:
|
|
414
|
+
logger.warning(e)
|
|
415
|
+
|
|
416
|
+
self._access_token = token_info.get("access_token")
|
|
417
|
+
self._refresh_token = token_info.get("refresh_token")
|
|
418
|
+
self._token_expires_in = token_info.get("expires_in")
|
|
419
|
+
self._token_requested_at = token_info.get("requested_at")
|
|
420
|
+
|
|
421
|
+
else:
|
|
422
|
+
logger.info("The access token is still valid, no need to refresh.")
|
|
423
|
+
except TypeError:
|
|
424
|
+
logger.debug(
|
|
425
|
+
f"token requested at: {self._token_requested_at} | token expires in: {self._token_expires_in}"
|
|
426
|
+
)
|
|
427
|
+
logger.warning(
|
|
428
|
+
"Something went wrong while trying to refresh the access token. Set logging level to `DEBUG` to see the issue."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def generate_state() -> str:
|
|
433
|
+
"""
|
|
434
|
+
Generates a random state string for use in OAuth 2.0 authorization.
|
|
435
|
+
|
|
436
|
+
This method creates a cryptographically secure, URL-safe string that can be used
|
|
437
|
+
to prevent cross-site request forgery (CSRF) attacks during the authorization process.
|
|
438
|
+
|
|
439
|
+
Returns
|
|
440
|
+
-------
|
|
441
|
+
str
|
|
442
|
+
A random URL-safe string to be used as the state parameter in OAuth 2.0.
|
|
443
|
+
"""
|
|
444
|
+
return secrets.token_urlsafe()
|
|
445
|
+
|
|
446
|
+
def get_authorization_url(
|
|
447
|
+
self, state: str = None, show_dialog: bool = False
|
|
448
|
+
) -> str:
|
|
449
|
+
"""
|
|
450
|
+
Constructs the Spotify authorization URL for user authentication.
|
|
451
|
+
|
|
452
|
+
This method generates a URL that can be used to redirect users to Spotify's
|
|
453
|
+
authorization page for user authentication.
|
|
454
|
+
|
|
455
|
+
Parameters
|
|
456
|
+
----------
|
|
457
|
+
state : str, optional
|
|
458
|
+
A random string to maintain state between the request and callback.
|
|
459
|
+
If not provided, no state parameter is included.
|
|
460
|
+
|
|
461
|
+
You may use :meth:`SpotifyAuth.generate_state` method to generate one.
|
|
462
|
+
show_dialog : bool, optional
|
|
463
|
+
Whether or not to force the user to approve the app again if they’ve already done so.
|
|
464
|
+
If ``False`` (default), a user who has already approved the application may be automatically
|
|
465
|
+
redirected to the URI specified by redirect_uri. If ``True``, the user will not be automatically
|
|
466
|
+
redirected and will have to approve the app again.
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
str
|
|
471
|
+
The full authorization URL to redirect users for Spotify authentication.
|
|
472
|
+
"""
|
|
473
|
+
payload = {
|
|
474
|
+
"response_type": "code",
|
|
475
|
+
"client_id": self.client_id,
|
|
476
|
+
"redirect_uri": self.redirect_uri,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if self._scopes:
|
|
480
|
+
payload["scope"] = self._scopes
|
|
481
|
+
|
|
482
|
+
if show_dialog:
|
|
483
|
+
payload["show_dialog"] = show_dialog
|
|
484
|
+
|
|
485
|
+
if state:
|
|
486
|
+
payload["state"] = state
|
|
487
|
+
|
|
488
|
+
return f"{self.USER_AUTH_URL}?{urlencode(payload)}"
|
|
489
|
+
|
|
490
|
+
def save_access_token(self, token_info: dict) -> None:
|
|
491
|
+
"""
|
|
492
|
+
Saves the access token and related information.
|
|
493
|
+
|
|
494
|
+
This method must be overridden in a subclass to persist the access token and other
|
|
495
|
+
related information (e.g., refresh token, expiration time). If not implemented,
|
|
496
|
+
the access token will not be saved, and users will need to re-authenticate after
|
|
497
|
+
application restarts.
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
token_info : dict
|
|
502
|
+
A dictionary containing the access token and related information, such as
|
|
503
|
+
refresh token, expiration time, etc.
|
|
504
|
+
|
|
505
|
+
Raises
|
|
506
|
+
------
|
|
507
|
+
NotImplementedError
|
|
508
|
+
If the method is not overridden in a subclass.
|
|
509
|
+
"""
|
|
510
|
+
raise NotImplementedError(
|
|
511
|
+
"The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
|
|
512
|
+
"If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def load_access_token(self) -> dict:
|
|
516
|
+
"""
|
|
517
|
+
Loads the access token and related information.
|
|
518
|
+
|
|
519
|
+
This method must be overridden in a subclass to retrieve the access token and other
|
|
520
|
+
related information (e.g., refresh token, expiration time) from persistent storage.
|
|
521
|
+
If not implemented, the access token will not be loaded, and users will need to
|
|
522
|
+
re-authenticate after application restarts.
|
|
523
|
+
|
|
524
|
+
Returns
|
|
525
|
+
-------
|
|
526
|
+
dict | None
|
|
527
|
+
A dictionary containing the access token and related information, such as
|
|
528
|
+
refresh token, expiration time, etc., or None if no token is found.
|
|
529
|
+
|
|
530
|
+
Raises
|
|
531
|
+
------
|
|
532
|
+
NotImplementedError
|
|
533
|
+
If the method is not overridden in a subclass.
|
|
534
|
+
"""
|
|
535
|
+
raise NotImplementedError(
|
|
536
|
+
"The `load_access_token` method must be overridden in a subclass to load access token and related information. "
|
|
537
|
+
"If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def callback_handler(self, code, state, expected_state):
|
|
541
|
+
"""
|
|
542
|
+
Handles the callback phase of the OAuth 2.0 authorization process.
|
|
543
|
+
|
|
544
|
+
This method processes the authorization code and state returned by Spotify after the user
|
|
545
|
+
has granted permission. It validates the state to prevent CSRF attacks, exchanges the
|
|
546
|
+
authorization code for an access token, and saves the token for future use.
|
|
547
|
+
|
|
548
|
+
Parameters
|
|
549
|
+
----------
|
|
550
|
+
code : str
|
|
551
|
+
The authorization code returned by Spotify after user authorization.
|
|
552
|
+
state : str
|
|
553
|
+
The state parameter returned by Spotify to ensure the request's integrity.
|
|
554
|
+
expected_state : str
|
|
555
|
+
The original state parameter sent during the authorization request, used to validate the response.
|
|
556
|
+
|
|
557
|
+
Raises
|
|
558
|
+
------
|
|
559
|
+
SpotifyAuthException
|
|
560
|
+
If the returned state does not match the expected state.
|
|
561
|
+
|
|
562
|
+
Notes
|
|
563
|
+
-----
|
|
564
|
+
- This method can be used in a web application (e.g., Flask) in the `/callback` route to handle
|
|
565
|
+
successful authorization.
|
|
566
|
+
- Ensure that the ``save_access_token`` and ``load_access_token`` methods are implemented in a subclass
|
|
567
|
+
if token persistence is required.
|
|
568
|
+
"""
|
|
569
|
+
if state != expected_state:
|
|
570
|
+
raise AuthenticationException("state does not match!")
|
|
571
|
+
|
|
572
|
+
token_info = self._get_access_token(authorization_code=code)
|
|
573
|
+
|
|
574
|
+
self._access_token = token_info.get("access_token")
|
|
575
|
+
self._refresh_token = token_info.get("refresh_token")
|
|
576
|
+
self._token_expires_in = token_info.get("expires_in")
|
|
577
|
+
self._token_requested_at = token_info.get("requested_at")
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
self.save_access_token(token_info)
|
|
581
|
+
except NotImplementedError as e:
|
|
582
|
+
logger.warning(e)
|
yutipy/deezer.py
CHANGED
|
@@ -5,11 +5,7 @@ from typing import Dict, List, Optional
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
-
from yutipy.exceptions import
|
|
9
|
-
DeezerException,
|
|
10
|
-
InvalidResponseException,
|
|
11
|
-
InvalidValueException,
|
|
12
|
-
)
|
|
8
|
+
from yutipy.exceptions import DeezerException, InvalidValueException
|
|
13
9
|
from yutipy.logger import logger
|
|
14
10
|
from yutipy.models import MusicInfo
|
|
15
11
|
from yutipy.utils.helpers import are_strings_similar, is_valid_string
|
|
@@ -26,7 +22,7 @@ class Deezer:
|
|
|
26
22
|
self.__session = requests.Session()
|
|
27
23
|
self._translation_session = requests.Session()
|
|
28
24
|
|
|
29
|
-
def __enter__(self)
|
|
25
|
+
def __enter__(self):
|
|
30
26
|
"""Enters the runtime context related to this object."""
|
|
31
27
|
return self
|
|
32
28
|
|
|
@@ -81,7 +77,6 @@ class Deezer:
|
|
|
81
77
|
self.normalize_non_english = normalize_non_english
|
|
82
78
|
|
|
83
79
|
search_types = ["track", "album"]
|
|
84
|
-
|
|
85
80
|
for search_type in search_types:
|
|
86
81
|
endpoint = f"{self.api_url}/search/{search_type}"
|
|
87
82
|
query = f'?q=artist:"{artist}" {search_type}:"{song}"&limit={limit}'
|
|
@@ -96,18 +91,15 @@ class Deezer:
|
|
|
96
91
|
logger.debug(f"Response status code: {response.status_code}")
|
|
97
92
|
response.raise_for_status()
|
|
98
93
|
except requests.RequestException as e:
|
|
99
|
-
logger.warning(f"Network error while fetching music info: {e}")
|
|
100
|
-
return None
|
|
101
|
-
except Exception as e:
|
|
102
94
|
logger.warning(f"Unexpected error while searching Deezer: {e}")
|
|
103
|
-
|
|
95
|
+
return None
|
|
104
96
|
|
|
105
97
|
try:
|
|
106
98
|
logger.debug(f"Parsing response JSON: {response.json()}")
|
|
107
99
|
result = response.json()["data"]
|
|
108
100
|
except (IndexError, KeyError, ValueError) as e:
|
|
109
101
|
logger.warning(f"Invalid response structure from Deezer: {e}")
|
|
110
|
-
|
|
102
|
+
return None
|
|
111
103
|
|
|
112
104
|
music_info = self._parse_results(artist, song, result)
|
|
113
105
|
if music_info:
|
|
@@ -139,7 +131,7 @@ class Deezer:
|
|
|
139
131
|
elif music_type == "album":
|
|
140
132
|
return self._get_album_info(music_id)
|
|
141
133
|
else:
|
|
142
|
-
raise
|
|
134
|
+
raise InvalidValueException(f"Invalid music type: {music_type}")
|
|
143
135
|
|
|
144
136
|
def _get_track_info(self, track_id: int) -> Optional[Dict]:
|
|
145
137
|
"""
|
|
@@ -165,16 +157,13 @@ class Deezer:
|
|
|
165
157
|
except requests.RequestException as e:
|
|
166
158
|
logger.warning(f"Error fetching track info: {e}")
|
|
167
159
|
return None
|
|
168
|
-
except Exception as e:
|
|
169
|
-
logger.warning(f"Error fetching track info: {e}")
|
|
170
|
-
raise DeezerException(f"An error occurred while fetching track info: {e}")
|
|
171
160
|
|
|
172
161
|
try:
|
|
173
162
|
logger.debug(f"Response JSON: {response.json()}")
|
|
174
163
|
result = response.json()
|
|
175
164
|
except ValueError as e:
|
|
176
165
|
logger.warning(f"Invalid response received from Deezer: {e}")
|
|
177
|
-
|
|
166
|
+
return None
|
|
178
167
|
|
|
179
168
|
return {
|
|
180
169
|
"isrc": result.get("isrc"),
|
|
@@ -206,16 +195,13 @@ class Deezer:
|
|
|
206
195
|
except requests.RequestException as e:
|
|
207
196
|
logger.warning(f"Error fetching album info: {e}")
|
|
208
197
|
return None
|
|
209
|
-
except Exception as e:
|
|
210
|
-
logger.warning(f"Error fetching album info: {e}")
|
|
211
|
-
raise DeezerException(f"An error occurred while fetching album info: {e}")
|
|
212
198
|
|
|
213
199
|
try:
|
|
214
200
|
logger.debug(f"Response JSON: {response.json()}")
|
|
215
201
|
result = response.json()
|
|
216
202
|
except ValueError as e:
|
|
217
203
|
logger.warning(f"Invalid response received from Deezer: {e}")
|
|
218
|
-
|
|
204
|
+
return None
|
|
219
205
|
|
|
220
206
|
return {
|
|
221
207
|
"genre": (
|