yutipy 1.5.2__py3-none-any.whl → 1.6.12__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/cli/search.py +1 -1
- yutipy/deezer.py +2 -2
- yutipy/exceptions.py +18 -34
- yutipy/itunes.py +2 -2
- yutipy/kkbox.py +2 -2
- yutipy/musicyt.py +2 -2
- yutipy/spotify.py +520 -45
- yutipy/utils/__init__.py +1 -1
- yutipy/yutipy_music.py +2 -2
- {yutipy-1.5.2.dist-info → yutipy-1.6.12.dist-info}/METADATA +2 -1
- yutipy-1.6.12.dist-info/RECORD +21 -0
- {yutipy-1.5.2.dist-info → yutipy-1.6.12.dist-info}/WHEEL +1 -1
- yutipy-1.5.2.dist-info/RECORD +0 -21
- /yutipy/{utils/logger.py → logger.py} +0 -0
- {yutipy-1.5.2.dist-info → yutipy-1.6.12.dist-info}/entry_points.txt +0 -0
- {yutipy-1.5.2.dist-info → yutipy-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {yutipy-1.5.2.dist-info → yutipy-1.6.12.dist-info}/top_level.txt +0 -0
yutipy/cli/search.py
CHANGED
|
@@ -14,7 +14,7 @@ from yutipy.itunes import Itunes
|
|
|
14
14
|
from yutipy.kkbox import KKBox
|
|
15
15
|
from yutipy.musicyt import MusicYT
|
|
16
16
|
from yutipy.spotify import Spotify
|
|
17
|
-
from yutipy.
|
|
17
|
+
from yutipy.logger import disable_logging, enable_logging
|
|
18
18
|
from yutipy.yutipy_music import YutipyMusic
|
|
19
19
|
|
|
20
20
|
|
yutipy/deezer.py
CHANGED
|
@@ -13,7 +13,7 @@ from yutipy.exceptions import (
|
|
|
13
13
|
)
|
|
14
14
|
from yutipy.models import MusicInfo
|
|
15
15
|
from yutipy.utils.helpers import are_strings_similar, is_valid_string
|
|
16
|
-
from yutipy.
|
|
16
|
+
from yutipy.logger import logger
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class Deezer:
|
|
@@ -323,7 +323,7 @@ class Deezer:
|
|
|
323
323
|
|
|
324
324
|
if __name__ == "__main__":
|
|
325
325
|
import logging
|
|
326
|
-
from yutipy.
|
|
326
|
+
from yutipy.logger import enable_logging
|
|
327
327
|
|
|
328
328
|
enable_logging(level=logging.DEBUG)
|
|
329
329
|
deezer = Deezer()
|
yutipy/exceptions.py
CHANGED
|
@@ -11,60 +11,44 @@ __all__ = [
|
|
|
11
11
|
class YutipyException(Exception):
|
|
12
12
|
"""Base class for exceptions in the Yutipy package."""
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
# Generic Exceptions
|
|
16
|
+
class AuthenticationException(YutipyException):
|
|
17
|
+
"""Exception raised for authentication errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidResponseException(YutipyException):
|
|
21
|
+
"""Exception raised for invalid responses from APIs."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidValueException(YutipyException):
|
|
25
|
+
"""Exception raised for invalid values."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NetworkException(YutipyException):
|
|
29
|
+
"""Exception raised for network-related errors."""
|
|
15
30
|
|
|
16
31
|
|
|
17
32
|
# Service Exceptions
|
|
18
33
|
class DeezerException(YutipyException):
|
|
19
34
|
"""Exception raised for errors related to the Deezer API."""
|
|
20
35
|
|
|
21
|
-
pass
|
|
22
|
-
|
|
23
36
|
|
|
24
37
|
class ItunesException(YutipyException):
|
|
25
38
|
"""Exception raised for errors related to the iTunes API."""
|
|
26
39
|
|
|
27
|
-
pass
|
|
28
|
-
|
|
29
40
|
|
|
30
41
|
class KKBoxException(YutipyException):
|
|
31
42
|
"""Exception raised for erros related to the KKBOX Open API."""
|
|
32
43
|
|
|
33
|
-
pass
|
|
34
|
-
|
|
35
44
|
|
|
36
45
|
class MusicYTException(YutipyException):
|
|
37
46
|
"""Exception raised for errors related to the YouTube Music API."""
|
|
38
47
|
|
|
39
|
-
pass
|
|
40
|
-
|
|
41
48
|
|
|
42
49
|
class SpotifyException(YutipyException):
|
|
43
50
|
"""Exception raised for errors related to the Spotify API."""
|
|
44
51
|
|
|
45
|
-
pass
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Generic Exceptions
|
|
49
|
-
class AuthenticationException(YutipyException):
|
|
50
|
-
"""Exception raised for authentication errors."""
|
|
51
|
-
|
|
52
|
-
pass
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class InvalidResponseException(YutipyException):
|
|
56
|
-
"""Exception raised for invalid responses from APIs."""
|
|
57
|
-
|
|
58
|
-
pass
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class InvalidValueException(YutipyException):
|
|
62
|
-
"""Exception raised for invalid values."""
|
|
63
|
-
|
|
64
|
-
pass
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class NetworkException(YutipyException):
|
|
68
|
-
"""Exception raised for network-related errors."""
|
|
69
52
|
|
|
70
|
-
|
|
53
|
+
class SpotifyAuthException(AuthenticationException):
|
|
54
|
+
"""Exception raised for Spotify authorization code grant type / flow"""
|
yutipy/itunes.py
CHANGED
|
@@ -18,7 +18,7 @@ from yutipy.utils.helpers import (
|
|
|
18
18
|
guess_album_type,
|
|
19
19
|
is_valid_string,
|
|
20
20
|
)
|
|
21
|
-
from yutipy.
|
|
21
|
+
from yutipy.logger import logger
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class Itunes:
|
|
@@ -228,7 +228,7 @@ class Itunes:
|
|
|
228
228
|
|
|
229
229
|
if __name__ == "__main__":
|
|
230
230
|
import logging
|
|
231
|
-
from yutipy.
|
|
231
|
+
from yutipy.logger import enable_logging
|
|
232
232
|
|
|
233
233
|
enable_logging(level=logging.DEBUG)
|
|
234
234
|
itunes = Itunes()
|
yutipy/kkbox.py
CHANGED
|
@@ -18,7 +18,7 @@ from yutipy.exceptions import (
|
|
|
18
18
|
)
|
|
19
19
|
from yutipy.models import MusicInfo
|
|
20
20
|
from yutipy.utils.helpers import are_strings_similar, is_valid_string
|
|
21
|
-
from yutipy.
|
|
21
|
+
from yutipy.logger import logger
|
|
22
22
|
|
|
23
23
|
load_dotenv()
|
|
24
24
|
|
|
@@ -417,7 +417,7 @@ class KKBox:
|
|
|
417
417
|
|
|
418
418
|
if __name__ == "__main__":
|
|
419
419
|
import logging
|
|
420
|
-
from yutipy.
|
|
420
|
+
from yutipy.logger import enable_logging
|
|
421
421
|
|
|
422
422
|
enable_logging(level=logging.DEBUG)
|
|
423
423
|
kkbox = KKBox(KKBOX_CLIENT_ID, KKBOX_CLIENT_SECRET)
|
yutipy/musicyt.py
CHANGED
|
@@ -13,7 +13,7 @@ from yutipy.exceptions import (
|
|
|
13
13
|
)
|
|
14
14
|
from yutipy.models import MusicInfo
|
|
15
15
|
from yutipy.utils.helpers import are_strings_similar, is_valid_string
|
|
16
|
-
from yutipy.
|
|
16
|
+
from yutipy.logger import logger
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class MusicYT:
|
|
@@ -296,7 +296,7 @@ class MusicYT:
|
|
|
296
296
|
if __name__ == "__main__":
|
|
297
297
|
import logging
|
|
298
298
|
|
|
299
|
-
from yutipy.
|
|
299
|
+
from yutipy.logger import enable_logging
|
|
300
300
|
|
|
301
301
|
enable_logging(level=logging.DEBUG)
|
|
302
302
|
music_yt = MusicYT()
|
yutipy/spotify.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
__all__ = ["Spotify", "SpotifyException"]
|
|
1
|
+
__all__ = ["Spotify", "SpotifyException", "SpotifyAuthException"]
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import os
|
|
5
|
-
import
|
|
5
|
+
import secrets
|
|
6
|
+
import webbrowser
|
|
6
7
|
from pprint import pprint
|
|
8
|
+
from time import time
|
|
7
9
|
from typing import Optional, Union
|
|
10
|
+
from urllib.parse import urlencode
|
|
8
11
|
|
|
9
12
|
import requests
|
|
10
13
|
from dotenv import load_dotenv
|
|
@@ -14,8 +17,10 @@ from yutipy.exceptions import (
|
|
|
14
17
|
InvalidResponseException,
|
|
15
18
|
InvalidValueException,
|
|
16
19
|
NetworkException,
|
|
20
|
+
SpotifyAuthException,
|
|
17
21
|
SpotifyException,
|
|
18
22
|
)
|
|
23
|
+
from yutipy.logger import logger
|
|
19
24
|
from yutipy.models import MusicInfo
|
|
20
25
|
from yutipy.utils.helpers import (
|
|
21
26
|
are_strings_similar,
|
|
@@ -23,12 +28,12 @@ from yutipy.utils.helpers import (
|
|
|
23
28
|
is_valid_string,
|
|
24
29
|
separate_artists,
|
|
25
30
|
)
|
|
26
|
-
from yutipy.utils.logger import logger
|
|
27
31
|
|
|
28
32
|
load_dotenv()
|
|
29
33
|
|
|
30
34
|
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
|
|
31
35
|
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
36
|
+
SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI")
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
class Spotify:
|
|
@@ -41,8 +46,8 @@ class Spotify:
|
|
|
41
46
|
|
|
42
47
|
def __init__(
|
|
43
48
|
self,
|
|
44
|
-
client_id: str =
|
|
45
|
-
client_secret: str =
|
|
49
|
+
client_id: str = None,
|
|
50
|
+
client_secret: str = None,
|
|
46
51
|
) -> None:
|
|
47
52
|
"""
|
|
48
53
|
Initializes the Spotify class (using Client Credentials grant type/flow) and sets up the session.
|
|
@@ -54,20 +59,28 @@ class Spotify:
|
|
|
54
59
|
client_secret : str, optional
|
|
55
60
|
The Client secret for the Spotify API. Defaults to ``SPOTIFY_CLIENT_SECRET`` from environment variable or the ``.env`` file.
|
|
56
61
|
"""
|
|
57
|
-
|
|
62
|
+
|
|
63
|
+
self.client_id = client_id or SPOTIFY_CLIENT_ID
|
|
64
|
+
self.client_secret = client_secret or SPOTIFY_CLIENT_SECRET
|
|
65
|
+
|
|
66
|
+
if not self.client_id:
|
|
58
67
|
raise SpotifyException(
|
|
59
|
-
"
|
|
68
|
+
"Client ID was not found. Set it in environment variable or directly pass it when creating object."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not self.client_secret:
|
|
72
|
+
raise SpotifyException(
|
|
73
|
+
"Client Secret was not found. Set it in environment variable or directly pass it when creating object."
|
|
60
74
|
)
|
|
61
75
|
|
|
62
|
-
self.client_id = client_id
|
|
63
|
-
self.client_secret = client_secret
|
|
64
|
-
self._session = requests.Session()
|
|
65
|
-
self.api_url = "https://api.spotify.com/v1"
|
|
66
|
-
self.__header, self.__expires_in = self.__authenticate()
|
|
67
|
-
self.__start_time = time.time()
|
|
68
76
|
self._is_session_closed = False
|
|
69
|
-
self.
|
|
70
|
-
|
|
77
|
+
self._normalize_non_english = True
|
|
78
|
+
|
|
79
|
+
self.__api_url = "https://api.spotify.com/v1"
|
|
80
|
+
self.__session = requests.Session()
|
|
81
|
+
self.__translation_session = requests.Session()
|
|
82
|
+
self.__start_time = time()
|
|
83
|
+
self.__header, self.__expires_in = self.__authenticate()
|
|
71
84
|
|
|
72
85
|
def __enter__(self):
|
|
73
86
|
"""Enters the runtime context related to this object."""
|
|
@@ -80,8 +93,8 @@ class Spotify:
|
|
|
80
93
|
def close_session(self) -> None:
|
|
81
94
|
"""Closes the current session(s)."""
|
|
82
95
|
if not self.is_session_closed:
|
|
83
|
-
self.
|
|
84
|
-
self.
|
|
96
|
+
self.__session.close()
|
|
97
|
+
self.__translation_session.close()
|
|
85
98
|
self._is_session_closed = True
|
|
86
99
|
|
|
87
100
|
@property
|
|
@@ -127,7 +140,7 @@ class Spotify:
|
|
|
127
140
|
|
|
128
141
|
try:
|
|
129
142
|
logger.info("Authenticating with Spotify API")
|
|
130
|
-
response = self.
|
|
143
|
+
response = self.__session.post(
|
|
131
144
|
url=url, headers=headers, data=data, timeout=30
|
|
132
145
|
)
|
|
133
146
|
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
@@ -144,9 +157,9 @@ class Spotify:
|
|
|
144
157
|
|
|
145
158
|
def __refresh_token_if_expired(self):
|
|
146
159
|
"""Refreshes the token if it has expired."""
|
|
147
|
-
if time
|
|
160
|
+
if time() - self.__start_time >= self.__expires_in:
|
|
148
161
|
self.__header, self.__expires_in = self.__authenticate()
|
|
149
|
-
self.__start_time = time
|
|
162
|
+
self.__start_time = time()
|
|
150
163
|
|
|
151
164
|
def search(
|
|
152
165
|
self,
|
|
@@ -179,7 +192,7 @@ class Spotify:
|
|
|
179
192
|
"Artist and song names must be valid strings and can't be empty."
|
|
180
193
|
)
|
|
181
194
|
|
|
182
|
-
self.
|
|
195
|
+
self._normalize_non_english = normalize_non_english
|
|
183
196
|
|
|
184
197
|
music_info = None
|
|
185
198
|
artist_ids = None
|
|
@@ -194,7 +207,7 @@ class Spotify:
|
|
|
194
207
|
|
|
195
208
|
self.__refresh_token_if_expired()
|
|
196
209
|
|
|
197
|
-
query_url = f"{self.
|
|
210
|
+
query_url = f"{self.__api_url}/search{query}"
|
|
198
211
|
|
|
199
212
|
logger.info(
|
|
200
213
|
f"Searching Spotify for `artist='{artist}'` and `song='{song}'`"
|
|
@@ -202,7 +215,7 @@ class Spotify:
|
|
|
202
215
|
logger.debug(f"Query URL: {query_url}")
|
|
203
216
|
|
|
204
217
|
try:
|
|
205
|
-
response = self.
|
|
218
|
+
response = self.__session.get(
|
|
206
219
|
query_url, headers=self.__header, timeout=30
|
|
207
220
|
)
|
|
208
221
|
response.raise_for_status()
|
|
@@ -256,7 +269,7 @@ class Spotify:
|
|
|
256
269
|
"Artist and song names must be valid strings and can't be empty."
|
|
257
270
|
)
|
|
258
271
|
|
|
259
|
-
self.
|
|
272
|
+
self._normalize_non_english = normalize_non_english
|
|
260
273
|
|
|
261
274
|
self.__refresh_token_if_expired()
|
|
262
275
|
|
|
@@ -267,9 +280,9 @@ class Spotify:
|
|
|
267
280
|
else:
|
|
268
281
|
raise InvalidValueException("ISRC or UPC must be provided.")
|
|
269
282
|
|
|
270
|
-
query_url = f"{self.
|
|
283
|
+
query_url = f"{self.__api_url}/search{query}"
|
|
271
284
|
try:
|
|
272
|
-
response = self.
|
|
285
|
+
response = self.__session.get(query_url, headers=self.__header, timeout=30)
|
|
273
286
|
response.raise_for_status()
|
|
274
287
|
except requests.RequestException as e:
|
|
275
288
|
raise NetworkException(f"Network error occurred: {e}")
|
|
@@ -298,9 +311,9 @@ class Spotify:
|
|
|
298
311
|
"""
|
|
299
312
|
artist_ids = []
|
|
300
313
|
for name in separate_artists(artist):
|
|
301
|
-
query_url = f"{self.
|
|
314
|
+
query_url = f"{self.__api_url}/search?q={name}&type=artist&limit=5"
|
|
302
315
|
try:
|
|
303
|
-
response = self.
|
|
316
|
+
response = self.__session.get(
|
|
304
317
|
query_url, headers=self.__header, timeout=30
|
|
305
318
|
)
|
|
306
319
|
response.raise_for_status()
|
|
@@ -383,8 +396,8 @@ class Spotify:
|
|
|
383
396
|
if not are_strings_similar(
|
|
384
397
|
track["name"],
|
|
385
398
|
song,
|
|
386
|
-
use_translation=self.
|
|
387
|
-
translation_session=self.
|
|
399
|
+
use_translation=self._normalize_non_english,
|
|
400
|
+
translation_session=self.__translation_session,
|
|
388
401
|
):
|
|
389
402
|
return None
|
|
390
403
|
|
|
@@ -395,8 +408,8 @@ class Spotify:
|
|
|
395
408
|
if are_strings_similar(
|
|
396
409
|
x["name"],
|
|
397
410
|
artist,
|
|
398
|
-
use_translation=self.
|
|
399
|
-
translation_session=self.
|
|
411
|
+
use_translation=self._normalize_non_english,
|
|
412
|
+
translation_session=self.__translation_session,
|
|
400
413
|
)
|
|
401
414
|
or x["id"] in artist_ids
|
|
402
415
|
]
|
|
@@ -446,8 +459,8 @@ class Spotify:
|
|
|
446
459
|
if not are_strings_similar(
|
|
447
460
|
album["name"],
|
|
448
461
|
song,
|
|
449
|
-
use_translation=self.
|
|
450
|
-
translation_session=self.
|
|
462
|
+
use_translation=self._normalize_non_english,
|
|
463
|
+
translation_session=self.__translation_session,
|
|
451
464
|
):
|
|
452
465
|
return None
|
|
453
466
|
|
|
@@ -458,8 +471,8 @@ class Spotify:
|
|
|
458
471
|
if are_strings_similar(
|
|
459
472
|
x["name"],
|
|
460
473
|
artist,
|
|
461
|
-
use_translation=self.
|
|
462
|
-
translation_session=self.
|
|
474
|
+
use_translation=self._normalize_non_english,
|
|
475
|
+
translation_session=self.__translation_session,
|
|
463
476
|
)
|
|
464
477
|
or x["id"] in artist_ids
|
|
465
478
|
]
|
|
@@ -490,17 +503,479 @@ class Spotify:
|
|
|
490
503
|
return None
|
|
491
504
|
|
|
492
505
|
|
|
506
|
+
class SpotifyAuth:
|
|
507
|
+
"""
|
|
508
|
+
A class to interact with the Spotify API. It uses "Authorization Code" grant type (or flow).
|
|
509
|
+
|
|
510
|
+
This class reads the ``SPOTIFY_CLIENT_ID``, ``SPOTIFY_CLIENT_SECRET`` and ``SPOTIFY_REDIRECT_URI``
|
|
511
|
+
from environment variables or the ``.env`` file by default.
|
|
512
|
+
Alternatively, you can manually provide these values when creating an object.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(
|
|
516
|
+
self,
|
|
517
|
+
client_id: str = None,
|
|
518
|
+
client_secret: str = None,
|
|
519
|
+
redirect_uri: str = None,
|
|
520
|
+
scopes: list[str] = None,
|
|
521
|
+
defer_load: bool = False,
|
|
522
|
+
):
|
|
523
|
+
"""
|
|
524
|
+
Initializes the SpotifyAuth class (using Authorization Code grant type/flow) and sets up the session.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
client_id : str, optional
|
|
529
|
+
The Client ID for the Spotify API. Defaults to ``SPOTIFY_CLIENT_ID`` from environment variable or the ``.env`` file.
|
|
530
|
+
client_secret : str, optional
|
|
531
|
+
The Client secret for the Spotify API. Defaults to ``SPOTIFY_CLIENT_SECRET`` from environment variable or the ``.env`` file.
|
|
532
|
+
redirect_uri : str, optional
|
|
533
|
+
The Redirect URI for the Spotify API. Defaults to ``SPOTIFY_REDIRECT_URI`` from environment variable or the ``.env`` file.
|
|
534
|
+
scopes : list[str], optional
|
|
535
|
+
A list of scopes for the Spotify API. For example: `['user-read-email', 'user-read-private']`.
|
|
536
|
+
defer_load : bool, optional
|
|
537
|
+
Whether to defer loading the access token during initialization. Default is ``False``.
|
|
538
|
+
"""
|
|
539
|
+
self.client_id = client_id or os.getenv("SPOTIFY_CLIENT_ID")
|
|
540
|
+
self.client_secret = client_secret or os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
541
|
+
self.redirect_uri = redirect_uri or os.getenv("SPOTIFY_REDIRECT_URI")
|
|
542
|
+
|
|
543
|
+
if not self.client_id:
|
|
544
|
+
raise SpotifyAuthException(
|
|
545
|
+
"Client ID was not found. Set it in environment variable or directly pass it when creating object."
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if not self.client_secret:
|
|
549
|
+
raise SpotifyAuthException(
|
|
550
|
+
"Client Secret was not found. Set it in environment variable or directly pass it when creating object."
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if not self.redirect_uri:
|
|
554
|
+
raise SpotifyAuthException(
|
|
555
|
+
"No redirect URI was provided! Set it in environment variable or directly pass it when creating object."
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
self.scope = scopes
|
|
559
|
+
self.defer_load = defer_load
|
|
560
|
+
|
|
561
|
+
self._is_session_closed = False
|
|
562
|
+
|
|
563
|
+
self.__access_token = None
|
|
564
|
+
self.__refresh_token = None
|
|
565
|
+
self.__token_expires_in = None
|
|
566
|
+
self.__token_requested_at = None
|
|
567
|
+
self.__session = requests.Session()
|
|
568
|
+
|
|
569
|
+
if not scopes:
|
|
570
|
+
logger.warning(
|
|
571
|
+
"No scopes were provided. Authorization will only grant access to publicly available information."
|
|
572
|
+
)
|
|
573
|
+
self.scope = None
|
|
574
|
+
else:
|
|
575
|
+
self.scope = " ".join(scopes)
|
|
576
|
+
|
|
577
|
+
if not defer_load:
|
|
578
|
+
# Attempt to load access token during initialization if not deferred
|
|
579
|
+
try:
|
|
580
|
+
token_info = self.load_access_token()
|
|
581
|
+
if token_info:
|
|
582
|
+
self.__access_token = token_info.get("access_token")
|
|
583
|
+
self.__refresh_token = token_info.get("refresh_token")
|
|
584
|
+
self.__token_expires_in = token_info.get("expires_in")
|
|
585
|
+
self.__token_requested_at = token_info.get("requested_at")
|
|
586
|
+
else:
|
|
587
|
+
logger.warning(
|
|
588
|
+
"No access token found during initialization. You must authenticate to obtain a new token."
|
|
589
|
+
)
|
|
590
|
+
except NotImplementedError:
|
|
591
|
+
logger.warning(
|
|
592
|
+
"`load_access_token` is not implemented. Falling back to in-memory storage."
|
|
593
|
+
)
|
|
594
|
+
else:
|
|
595
|
+
logger.warning(
|
|
596
|
+
"`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def __enter__(self):
|
|
600
|
+
"""Enters the runtime context related to this object."""
|
|
601
|
+
return self
|
|
602
|
+
|
|
603
|
+
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
604
|
+
"""Exits the runtime context related to this object."""
|
|
605
|
+
self.close_session()
|
|
606
|
+
|
|
607
|
+
def close_session(self) -> None:
|
|
608
|
+
"""Closes the current session(s)."""
|
|
609
|
+
if not self.is_session_closed:
|
|
610
|
+
self.__session.close()
|
|
611
|
+
self.__translation_session.close()
|
|
612
|
+
self._is_session_closed = True
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def is_session_closed(self) -> bool:
|
|
616
|
+
"""Checks if the session is closed."""
|
|
617
|
+
return self._is_session_closed
|
|
618
|
+
|
|
619
|
+
def load_token_after_init(self):
|
|
620
|
+
"""
|
|
621
|
+
Explicitly load the access token after initialization.
|
|
622
|
+
This is useful when ``defer_load`` is set to ``True`` during initialization.
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
token_info = self.load_access_token()
|
|
626
|
+
if token_info:
|
|
627
|
+
self.__access_token = token_info.get("access_token")
|
|
628
|
+
self.__refresh_token = token_info.get("refresh_token")
|
|
629
|
+
self.__token_expires_in = token_info.get("expires_in")
|
|
630
|
+
self.__token_requested_at = token_info.get("requested_at")
|
|
631
|
+
else:
|
|
632
|
+
logger.warning(
|
|
633
|
+
"No access token found. You must authenticate to obtain a new token."
|
|
634
|
+
)
|
|
635
|
+
except NotImplementedError:
|
|
636
|
+
logger.warning(
|
|
637
|
+
"`load_access_token` is not implemented. Falling back to in-memory storage."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def __authorization_header(self) -> dict:
|
|
641
|
+
"""
|
|
642
|
+
Generates the authorization header for Spotify API requests.
|
|
643
|
+
|
|
644
|
+
Returns
|
|
645
|
+
-------
|
|
646
|
+
dict
|
|
647
|
+
A dictionary containing the Bearer token for authentication.
|
|
648
|
+
"""
|
|
649
|
+
return {"Authorization": f"Bearer {self.__access_token}"}
|
|
650
|
+
|
|
651
|
+
def __get_access_token(
|
|
652
|
+
self,
|
|
653
|
+
authorization_code: str = None,
|
|
654
|
+
refresh_token: str = None,
|
|
655
|
+
) -> dict:
|
|
656
|
+
"""
|
|
657
|
+
Gets the Spotify API access token information.
|
|
658
|
+
|
|
659
|
+
If ``authorization_code`` provided, it will try to get a new access token from Spotify.
|
|
660
|
+
Otherwise, if `refresh_token` is provided, it will refresh the access token using it
|
|
661
|
+
and return new access token information.
|
|
662
|
+
|
|
663
|
+
Returns
|
|
664
|
+
-------
|
|
665
|
+
dict
|
|
666
|
+
The Spotify API access token, with additional information such as expires in, refresh token, etc.
|
|
667
|
+
"""
|
|
668
|
+
auth_string = f"{self.client_id}:{self.client_secret}"
|
|
669
|
+
auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
670
|
+
|
|
671
|
+
url = "https://accounts.spotify.com/api/token"
|
|
672
|
+
headers = {
|
|
673
|
+
"Authorization": f"Basic {auth_base64}",
|
|
674
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if authorization_code:
|
|
678
|
+
data = {
|
|
679
|
+
"grant_type": "authorization_code",
|
|
680
|
+
"code": authorization_code,
|
|
681
|
+
"redirect_uri": self.redirect_uri,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if refresh_token:
|
|
685
|
+
data = {
|
|
686
|
+
"grant_type": "refresh_token",
|
|
687
|
+
"refresh_token": self.__refresh_token,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
logger.info(
|
|
692
|
+
"Authenticating with Spotify API using Authorization Code grant type."
|
|
693
|
+
)
|
|
694
|
+
response = self.__session.post(
|
|
695
|
+
url=url, headers=headers, data=data, timeout=30
|
|
696
|
+
)
|
|
697
|
+
logger.debug(f"Authentication response status code: {response.status_code}")
|
|
698
|
+
response.raise_for_status()
|
|
699
|
+
except requests.RequestException as e:
|
|
700
|
+
logger.error(f"Network error during Spotify authentication: {e}")
|
|
701
|
+
raise NetworkException(f"Network error occurred: {e}")
|
|
702
|
+
|
|
703
|
+
if response.status_code == 200:
|
|
704
|
+
response_json = response.json()
|
|
705
|
+
response_json["requested_at"] = time()
|
|
706
|
+
return response_json
|
|
707
|
+
else:
|
|
708
|
+
raise InvalidResponseException(
|
|
709
|
+
f"Invalid response received: {response.json()}"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def refresh_access_token(self):
|
|
713
|
+
"""Refreshes the token if it has expired."""
|
|
714
|
+
if time() - self.__token_requested_at >= self.__token_expires_in:
|
|
715
|
+
token_info = self.__get_access_token(refresh_token=self.__refresh_token)
|
|
716
|
+
|
|
717
|
+
try:
|
|
718
|
+
self.save_access_token(token_info)
|
|
719
|
+
except NotImplementedError as e:
|
|
720
|
+
print(e)
|
|
721
|
+
|
|
722
|
+
self.__access_token = token_info.get("access_token")
|
|
723
|
+
self.__refresh_token = token_info.get("refresh_token")
|
|
724
|
+
self.__token_expires_in = token_info.get("expires_in")
|
|
725
|
+
self.__token_requested_at = token_info.get("requested_at")
|
|
726
|
+
|
|
727
|
+
logger.info("The access token is still valid, no need to refresh.")
|
|
728
|
+
|
|
729
|
+
@staticmethod
|
|
730
|
+
def generate_state() -> str:
|
|
731
|
+
"""
|
|
732
|
+
Generates a random state string for use in OAuth 2.0 authorization.
|
|
733
|
+
|
|
734
|
+
This method creates a cryptographically secure, URL-safe string that can be used
|
|
735
|
+
to prevent cross-site request forgery (CSRF) attacks during the authorization process.
|
|
736
|
+
|
|
737
|
+
Returns
|
|
738
|
+
-------
|
|
739
|
+
str
|
|
740
|
+
A random URL-safe string to be used as the state parameter in OAuth 2.0.
|
|
741
|
+
"""
|
|
742
|
+
return secrets.token_urlsafe(16)
|
|
743
|
+
|
|
744
|
+
def get_authorization_url(self, state: str = None):
|
|
745
|
+
"""
|
|
746
|
+
Constructs the Spotify authorization URL for user authentication.
|
|
747
|
+
|
|
748
|
+
This method generates a URL that can be used to redirect users to Spotify's
|
|
749
|
+
authorization page for user authentication.
|
|
750
|
+
|
|
751
|
+
Parameters
|
|
752
|
+
----------
|
|
753
|
+
state : str, optional
|
|
754
|
+
A random string to maintain state between the request and callback.
|
|
755
|
+
If not provided, no state parameter is included.
|
|
756
|
+
|
|
757
|
+
You may use :meth:`SpotifyAuth.generate_state` method to generate one.
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
str
|
|
762
|
+
The full authorization URL to redirect users for Spotify authentication.
|
|
763
|
+
"""
|
|
764
|
+
auth_endpoint = "https://accounts.spotify.com/authorize"
|
|
765
|
+
payload = {
|
|
766
|
+
"response_type": "code",
|
|
767
|
+
"client_id": self.client_id,
|
|
768
|
+
"redirect_uri": self.redirect_uri,
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if self.scope:
|
|
772
|
+
payload["scope"] = self.scope
|
|
773
|
+
|
|
774
|
+
if state:
|
|
775
|
+
payload["state"] = state
|
|
776
|
+
|
|
777
|
+
return f"{auth_endpoint}?{urlencode(payload)}"
|
|
778
|
+
|
|
779
|
+
def save_access_token(self, token_info: dict) -> None:
|
|
780
|
+
"""
|
|
781
|
+
Saves the access token and related information.
|
|
782
|
+
|
|
783
|
+
This method must be overridden in a subclass to persist the access token and other
|
|
784
|
+
related information (e.g., refresh token, expiration time). If not implemented,
|
|
785
|
+
the access token will not be saved, and users will need to re-authenticate after
|
|
786
|
+
application restarts.
|
|
787
|
+
|
|
788
|
+
Parameters
|
|
789
|
+
----------
|
|
790
|
+
token_info : dict
|
|
791
|
+
A dictionary containing the access token and related information, such as
|
|
792
|
+
refresh token, expiration time, etc.
|
|
793
|
+
|
|
794
|
+
Raises
|
|
795
|
+
------
|
|
796
|
+
NotImplementedError
|
|
797
|
+
If the method is not overridden in a subclass.
|
|
798
|
+
"""
|
|
799
|
+
raise NotImplementedError(
|
|
800
|
+
"The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
|
|
801
|
+
"If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def load_access_token(self) -> Union[dict, None]:
|
|
805
|
+
"""
|
|
806
|
+
Loads the access token and related information.
|
|
807
|
+
|
|
808
|
+
This method must be overridden in a subclass to retrieve the access token and other
|
|
809
|
+
related information (e.g., refresh token, expiration time) from persistent storage.
|
|
810
|
+
If not implemented, the access token will not be loaded, and users will need to
|
|
811
|
+
re-authenticate after application restarts.
|
|
812
|
+
|
|
813
|
+
Returns
|
|
814
|
+
-------
|
|
815
|
+
dict | None
|
|
816
|
+
A dictionary containing the access token and related information, such as
|
|
817
|
+
refresh token, expiration time, etc., or None if no token is found.
|
|
818
|
+
|
|
819
|
+
Raises
|
|
820
|
+
------
|
|
821
|
+
NotImplementedError
|
|
822
|
+
If the method is not overridden in a subclass.
|
|
823
|
+
"""
|
|
824
|
+
raise NotImplementedError(
|
|
825
|
+
"The `load_access_token` method must be overridden in a subclass to load access token and related information. "
|
|
826
|
+
"If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def callback_handler(self, code, state, expected_state):
|
|
830
|
+
"""
|
|
831
|
+
Handles the callback phase of the OAuth 2.0 authorization process.
|
|
832
|
+
|
|
833
|
+
This method processes the authorization code and state returned by Spotify after the user
|
|
834
|
+
has granted permission. It validates the state to prevent CSRF attacks, exchanges the
|
|
835
|
+
authorization code for an access token, and saves the token for future use.
|
|
836
|
+
|
|
837
|
+
Parameters
|
|
838
|
+
----------
|
|
839
|
+
code : str
|
|
840
|
+
The authorization code returned by Spotify after user authorization.
|
|
841
|
+
state : str
|
|
842
|
+
The state parameter returned by Spotify to ensure the request's integrity.
|
|
843
|
+
expected_state : str
|
|
844
|
+
The original state parameter sent during the authorization request, used to validate the response.
|
|
845
|
+
|
|
846
|
+
Raises
|
|
847
|
+
------
|
|
848
|
+
SpotifyAuthException
|
|
849
|
+
If the returned state does not match the expected state.
|
|
850
|
+
|
|
851
|
+
Notes
|
|
852
|
+
-----
|
|
853
|
+
- This method can be used in a web application (e.g., Flask) in the `/callback` route to handle
|
|
854
|
+
successful authorization.
|
|
855
|
+
- Ensure that the ``save_access_token`` and ``load_access_token`` methods are implemented in a subclass
|
|
856
|
+
if token persistence is required.
|
|
857
|
+
|
|
858
|
+
Example
|
|
859
|
+
-------
|
|
860
|
+
In a Flask application, you can use this method in the ``/callback`` route:
|
|
861
|
+
|
|
862
|
+
.. code-block:: python
|
|
863
|
+
|
|
864
|
+
@app.route('/callback')
|
|
865
|
+
def callback():
|
|
866
|
+
code = request.args.get('code')
|
|
867
|
+
state = request.args.get('state')
|
|
868
|
+
expected_state = session['state'] # Retrieve the state stored during authorization URL generation
|
|
869
|
+
|
|
870
|
+
try:
|
|
871
|
+
spotify_auth.callback_handler(code, state, expected_state)
|
|
872
|
+
return "Authorization successful!"
|
|
873
|
+
except SpotifyAuthException as e:
|
|
874
|
+
return f"Authorization failed: {e}", 400
|
|
875
|
+
"""
|
|
876
|
+
if state != expected_state:
|
|
877
|
+
raise SpotifyAuthException("state does not match!")
|
|
878
|
+
|
|
879
|
+
try:
|
|
880
|
+
token_info = self.load_access_token()
|
|
881
|
+
except NotImplementedError as e:
|
|
882
|
+
logger.warning(e)
|
|
883
|
+
token_info = self.__get_access_token(authorization_code=code)
|
|
884
|
+
|
|
885
|
+
self.__access_token = token_info.get("access_token")
|
|
886
|
+
self.__refresh_token = token_info.get("refresh_token")
|
|
887
|
+
self.__token_expires_in = token_info.get("expires_in")
|
|
888
|
+
self.__token_requested_at = token_info.get("requested_at")
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
self.save_access_token(token_info)
|
|
892
|
+
except NotImplementedError as e:
|
|
893
|
+
logger.warning(e)
|
|
894
|
+
|
|
895
|
+
def get_user_profile(self):
|
|
896
|
+
"""
|
|
897
|
+
Fetches the user's display name and profile images.
|
|
898
|
+
|
|
899
|
+
Notes
|
|
900
|
+
-----
|
|
901
|
+
- ``user-read-email`` and ``user-read-private`` scopes are required to access user profile information.
|
|
902
|
+
|
|
903
|
+
Returns
|
|
904
|
+
-------
|
|
905
|
+
dict
|
|
906
|
+
A dictionary containing the user's display name and profile images.
|
|
907
|
+
"""
|
|
908
|
+
endpoint_url = "https://api.spotify.com/v1/me"
|
|
909
|
+
header = self.__authorization_header()
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
response = self.__session.get(endpoint_url, headers=header, timeout=30)
|
|
913
|
+
response.raise_for_status()
|
|
914
|
+
except requests.RequestException as e:
|
|
915
|
+
logger.error(f"Failed to fetch user profile: {e}")
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
if response.status_code != 200:
|
|
919
|
+
logger.error(f"Unexpected response: {response.json()}")
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
response_json = response.json()
|
|
923
|
+
return {
|
|
924
|
+
"display_name": response_json.get("display_name"),
|
|
925
|
+
"images": response_json.get("images", []),
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
|
|
493
929
|
if __name__ == "__main__":
|
|
494
930
|
import logging
|
|
931
|
+
from dataclasses import asdict
|
|
495
932
|
|
|
496
|
-
from yutipy.
|
|
933
|
+
from yutipy.logger import enable_logging
|
|
497
934
|
|
|
498
935
|
enable_logging(level=logging.DEBUG)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
spotify
|
|
936
|
+
|
|
937
|
+
print("\nChoose Spotify Grant Type/Flow:")
|
|
938
|
+
print("1. Client Credentials (Spotify)")
|
|
939
|
+
print("2. Authorization Code (SpotifyAuth)")
|
|
940
|
+
choice = input("\nEnter your choice (1 or 2): ")
|
|
941
|
+
|
|
942
|
+
if choice == "1":
|
|
943
|
+
spotify = Spotify(SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET)
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
artist_name = input("Artist Name: ")
|
|
947
|
+
song_name = input("Song Name: ")
|
|
948
|
+
pprint(f"\n{asdict(spotify.search(artist_name, song_name))}")
|
|
949
|
+
finally:
|
|
950
|
+
spotify.close_session()
|
|
951
|
+
|
|
952
|
+
elif choice == "2":
|
|
953
|
+
redirect_uri = input("Enter Redirect URI: ")
|
|
954
|
+
scopes = ["user-read-email", "user-read-private"]
|
|
955
|
+
|
|
956
|
+
spotify_auth = SpotifyAuth(
|
|
957
|
+
client_id=SPOTIFY_CLIENT_ID,
|
|
958
|
+
client_secret=SPOTIFY_CLIENT_SECRET,
|
|
959
|
+
redirect_uri=redirect_uri,
|
|
960
|
+
scopes=scopes,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
try:
|
|
964
|
+
state = spotify_auth.generate_state()
|
|
965
|
+
auth_url = spotify_auth.get_authorization_url(state=state)
|
|
966
|
+
print(f"Opening the following URL in your browser: {auth_url}")
|
|
967
|
+
webbrowser.open(auth_url)
|
|
968
|
+
|
|
969
|
+
code = input("Enter the authorization code: ")
|
|
970
|
+
spotify_auth.callback_handler(code, state, state)
|
|
971
|
+
|
|
972
|
+
user_profile = spotify_auth.get_user_profile()
|
|
973
|
+
if user_profile:
|
|
974
|
+
print(f"Successfully authenticated \"{user_profile['display_name']}\".")
|
|
975
|
+
else:
|
|
976
|
+
print("Authentication successful, but failed to fetch user profile.")
|
|
977
|
+
finally:
|
|
978
|
+
spotify_auth.close_session()
|
|
979
|
+
|
|
980
|
+
else:
|
|
981
|
+
print("Invalid choice. Exiting.")
|
yutipy/utils/__init__.py
CHANGED
yutipy/yutipy_music.py
CHANGED
|
@@ -10,7 +10,7 @@ from yutipy.models import MusicInfo, MusicInfos
|
|
|
10
10
|
from yutipy.musicyt import MusicYT
|
|
11
11
|
from yutipy.spotify import Spotify
|
|
12
12
|
from yutipy.utils.helpers import is_valid_string
|
|
13
|
-
from yutipy.
|
|
13
|
+
from yutipy.logger import logger
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class YutipyMusic:
|
|
@@ -182,7 +182,7 @@ class YutipyMusic:
|
|
|
182
182
|
|
|
183
183
|
if __name__ == "__main__":
|
|
184
184
|
import logging
|
|
185
|
-
from yutipy.
|
|
185
|
+
from yutipy.logger import enable_logging
|
|
186
186
|
|
|
187
187
|
enable_logging(level=logging.DEBUG)
|
|
188
188
|
yutipy_music = YutipyMusic()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yutipy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.12
|
|
4
4
|
Summary: A simple package for retrieving music information from various music platforms APIs.
|
|
5
5
|
Author: Cheap Nightbot
|
|
6
6
|
Author-email: Cheap Nightbot <hi@cheapnightbot.slmail.me>
|
|
@@ -81,6 +81,7 @@ A _**simple**_ Python package for searching and retrieving music information fro
|
|
|
81
81
|
- Search for music by artist and song title across multiple platforms.
|
|
82
82
|
- It uses `RapidFuzz` to compare & return the best match so that you can be sure you got what you asked for without having to worry and doing all that work by yourself.
|
|
83
83
|
- Retrieve detailed music information, including album art, release dates, lyrics, ISRC, and UPC codes.
|
|
84
|
+
- Authorize and access user resources easily.
|
|
84
85
|
|
|
85
86
|
### Available Music Platforms
|
|
86
87
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
yutipy/__init__.py,sha256=qcQS17OJL9NNnQuT_gW5t6ygHawjHhqwz2hmjjDF__M,376
|
|
2
|
+
yutipy/deezer.py,sha256=YZs2ilHL3pwc3AB9CmctKlyZ4GfD6qcMjZ6dgjYVxXo,11397
|
|
3
|
+
yutipy/exceptions.py,sha256=fur945x1Ibu7yeIPDRsOcujfVRRa5JHQw27dsOUreK4,1393
|
|
4
|
+
yutipy/itunes.py,sha256=hXMybI_lVWgAZ6QE4q_F1jOmWqIBShYQnn9nZUa8XW8,7922
|
|
5
|
+
yutipy/kkbox.py,sha256=tS5LqIMlExvW1tUxMEhFXs9d9t5iFMxPp1mgaf9l7uk,14184
|
|
6
|
+
yutipy/logger.py,sha256=cHCjpDslVsBOnp7jluqrOOi4ekDIggPhbSfqHeIfT-U,1263
|
|
7
|
+
yutipy/models.py,sha256=vvWIA3MwCOOM2CBHSabqmFXz4NdVHaQtObU6zhGpJOM,1931
|
|
8
|
+
yutipy/musicyt.py,sha256=6BvHM6SK8kSdFlGlWZIXThljIVhpwqEGUYZ7LMmXqbE,9226
|
|
9
|
+
yutipy/spotify.py,sha256=9bE-qRS_Tz7F5J3T-6tSUWtimbjwczmuL-liOAOLyX8,34667
|
|
10
|
+
yutipy/yutipy_music.py,sha256=3ry1KHukTInWV7xxzigYB-zWsAupBUXiAoL0F61Bmis,6531
|
|
11
|
+
yutipy/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
yutipy/cli/config.py,sha256=6p69ZlT3ebUH5wzhd0iisLYKOnYX6xTSzoyrdBwGNo0,3230
|
|
13
|
+
yutipy/cli/search.py,sha256=8SQw0bjRzRqAg-FuVz9aWjB2KBZqmCf38SyKAQ3rx5E,3025
|
|
14
|
+
yutipy/utils/__init__.py,sha256=AY1L9yFznlBzuDquyz7AnWB7t9BfFhnXJewc20hMSK4,325
|
|
15
|
+
yutipy/utils/helpers.py,sha256=W3g9iqoSygcFFCKCp2sk0NQrZOEG26wI2XuNi9pgAXE,5207
|
|
16
|
+
yutipy-1.6.12.dist-info/licenses/LICENSE,sha256=_89JsS2QnBG8tAb5-VWbJDj_uJ002zPJAYBJJdh3DPY,1071
|
|
17
|
+
yutipy-1.6.12.dist-info/METADATA,sha256=X3qFVrJRJsoiBYc_s6Kax5lDQltQixUASipVKnFrfhM,6545
|
|
18
|
+
yutipy-1.6.12.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
|
19
|
+
yutipy-1.6.12.dist-info/entry_points.txt,sha256=BrgmanaPjQqKQ3Ip76JLcsPgGANtrBSURf5CNIxl1HA,106
|
|
20
|
+
yutipy-1.6.12.dist-info/top_level.txt,sha256=t2A5V2_mUcfnHkbCy6tAQlb3909jDYU5GQgXtA4756I,7
|
|
21
|
+
yutipy-1.6.12.dist-info/RECORD,,
|
yutipy-1.5.2.dist-info/RECORD
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
yutipy/__init__.py,sha256=qcQS17OJL9NNnQuT_gW5t6ygHawjHhqwz2hmjjDF__M,376
|
|
2
|
-
yutipy/deezer.py,sha256=_-UtSq32FYqmMpmM4Y3S8KYjsxSC1tuU0jLyWZo7bBs,11409
|
|
3
|
-
yutipy/exceptions.py,sha256=LdVYtmQLpX5is9iMsbjECxZYoBUdbtWR3nFN4SBVJOM,1362
|
|
4
|
-
yutipy/itunes.py,sha256=IfJmAhd1OF-LOPYz4jsYOYrWqGV2hhBq8Y-ynOnohas,7934
|
|
5
|
-
yutipy/kkbox.py,sha256=v21qcFzfH2Kg_dYGoEwtaBrWS3JejqpZdddeOqm2F_4,14196
|
|
6
|
-
yutipy/models.py,sha256=vvWIA3MwCOOM2CBHSabqmFXz4NdVHaQtObU6zhGpJOM,1931
|
|
7
|
-
yutipy/musicyt.py,sha256=gPbmhAaFa4PZ7xNNJkKnzcwnkqieOsTUcXgIenp41OM,9238
|
|
8
|
-
yutipy/spotify.py,sha256=BOPa-Gc_CZbZVf-soDxCPbEOUlRc3nIVBfvf5hluCUg,16482
|
|
9
|
-
yutipy/yutipy_music.py,sha256=ec_a5QcygaSISV7OeOeYkMpwzE5YBeJjfobnQ4cb-Cw,6543
|
|
10
|
-
yutipy/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
yutipy/cli/config.py,sha256=6p69ZlT3ebUH5wzhd0iisLYKOnYX6xTSzoyrdBwGNo0,3230
|
|
12
|
-
yutipy/cli/search.py,sha256=i2GkiKYnBewjJNu0WCqSfF7IVd0JtKwglUNk2tCLv2w,3031
|
|
13
|
-
yutipy/utils/__init__.py,sha256=ehnqYct_hA5K5jXsHg3A9RNWFQLCTud86-WRhAbWOcQ,324
|
|
14
|
-
yutipy/utils/helpers.py,sha256=W3g9iqoSygcFFCKCp2sk0NQrZOEG26wI2XuNi9pgAXE,5207
|
|
15
|
-
yutipy/utils/logger.py,sha256=cHCjpDslVsBOnp7jluqrOOi4ekDIggPhbSfqHeIfT-U,1263
|
|
16
|
-
yutipy-1.5.2.dist-info/licenses/LICENSE,sha256=_89JsS2QnBG8tAb5-VWbJDj_uJ002zPJAYBJJdh3DPY,1071
|
|
17
|
-
yutipy-1.5.2.dist-info/METADATA,sha256=vA7sievHdgl8izs_0UeniMImIfNkUIf1_QrHZsi2az8,6498
|
|
18
|
-
yutipy-1.5.2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
19
|
-
yutipy-1.5.2.dist-info/entry_points.txt,sha256=BrgmanaPjQqKQ3Ip76JLcsPgGANtrBSURf5CNIxl1HA,106
|
|
20
|
-
yutipy-1.5.2.dist-info/top_level.txt,sha256=t2A5V2_mUcfnHkbCy6tAQlb3909jDYU5GQgXtA4756I,7
|
|
21
|
-
yutipy-1.5.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|