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 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.utils.logger import disable_logging, enable_logging
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.utils.logger import logger
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.utils.logger import enable_logging
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
- pass
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
- pass
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.utils.logger import logger
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.utils.logger import enable_logging
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.utils.logger import logger
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.utils.logger import enable_logging
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.utils.logger import logger
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.utils.logger import enable_logging
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 time
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 = SPOTIFY_CLIENT_ID,
45
- client_secret: str = SPOTIFY_CLIENT_SECRET,
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
- if not client_id or not client_secret:
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
- "Failed to read `SPOTIFY_CLIENT_ID` and/or `SPOTIFY_CLIENT_SECRET` from environment variables. Client ID and Client Secret must be provided."
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.normalize_non_english = True
70
- self._translation_session = requests.Session()
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._session.close()
84
- self._translation_session.close()
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._session.post(
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.time() - self.__start_time >= self.__expires_in:
160
+ if time() - self.__start_time >= self.__expires_in:
148
161
  self.__header, self.__expires_in = self.__authenticate()
149
- self.__start_time = 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.normalize_non_english = normalize_non_english
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.api_url}/search{query}"
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._session.get(
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.normalize_non_english = normalize_non_english
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.api_url}/search{query}"
283
+ query_url = f"{self.__api_url}/search{query}"
271
284
  try:
272
- response = self._session.get(query_url, headers=self.__header, timeout=30)
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.api_url}/search?q={name}&type=artist&limit=5"
314
+ query_url = f"{self.__api_url}/search?q={name}&type=artist&limit=5"
302
315
  try:
303
- response = self._session.get(
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.normalize_non_english,
387
- translation_session=self._translation_session,
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.normalize_non_english,
399
- translation_session=self._translation_session,
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.normalize_non_english,
450
- translation_session=self._translation_session,
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.normalize_non_english,
462
- translation_session=self._translation_session,
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.utils.logger import enable_logging
933
+ from yutipy.logger import enable_logging
497
934
 
498
935
  enable_logging(level=logging.DEBUG)
499
- spotify = Spotify(SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET)
500
-
501
- try:
502
- artist_name = input("Artist Name: ")
503
- song_name = input("Song Name: ")
504
- pprint(spotify.search(artist_name, song_name))
505
- finally:
506
- spotify.close_session()
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
@@ -4,7 +4,7 @@ from .helpers import (
4
4
  is_valid_string,
5
5
  separate_artists,
6
6
  )
7
- from .logger import disable_logging, enable_logging
7
+ from ..logger import disable_logging, enable_logging
8
8
 
9
9
  __all__ = [
10
10
  "guess_album_type",
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.utils.logger import logger
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.utils.logger import enable_logging
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.5.2
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (78.1.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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