yutipy 2.0.0__py3-none-any.whl → 2.1.1__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/deezer.py CHANGED
@@ -21,10 +21,10 @@ class Deezer:
21
21
 
22
22
  def __init__(self) -> None:
23
23
  """Initializes the Deezer class and sets up the session."""
24
- self._session = requests.Session()
25
24
  self.api_url = "https://api.deezer.com"
26
25
  self._is_session_closed = False
27
26
  self.normalize_non_english = True
27
+ self.__session = requests.Session()
28
28
  self._translation_session = requests.Session()
29
29
 
30
30
  def __enter__(self) -> "Deezer":
@@ -38,7 +38,7 @@ class Deezer:
38
38
  def close_session(self) -> None:
39
39
  """Closes the current session."""
40
40
  if not self.is_session_closed:
41
- self._session.close()
41
+ self.__session.close()
42
42
  self._translation_session.close()
43
43
  self._is_session_closed = True
44
44
 
@@ -93,7 +93,7 @@ class Deezer:
93
93
  f'Searching music info for `artist="{artist}"` and `song="{song}"`'
94
94
  )
95
95
  logger.debug(f"Query URL: {query_url}")
96
- response = self._session.get(query_url, timeout=30)
96
+ response = self.__session.get(query_url, timeout=30)
97
97
  logger.debug(f"Response status code: {response.status_code}")
98
98
  response.raise_for_status()
99
99
  except requests.RequestException as e:
@@ -160,7 +160,7 @@ class Deezer:
160
160
  try:
161
161
  logger.info(f"Fetching track info for track_id: {track_id}")
162
162
  logger.debug(f"Query URL: {query_url}")
163
- response = self._session.get(query_url, timeout=30)
163
+ response = self.__session.get(query_url, timeout=30)
164
164
  logger.debug(f"Response status code: {response.status_code}")
165
165
  response.raise_for_status()
166
166
  except requests.RequestException as e:
@@ -201,7 +201,7 @@ class Deezer:
201
201
  try:
202
202
  logger.info(f"Fetching album info for album_id: {album_id}")
203
203
  logger.debug(f"Query URL: {query_url}")
204
- response = self._session.get(query_url, timeout=30)
204
+ response = self.__session.get(query_url, timeout=30)
205
205
  logger.info(f"Response status code: {response.status_code}")
206
206
  response.raise_for_status()
207
207
  except requests.RequestException as e:
yutipy/itunes.py CHANGED
@@ -26,11 +26,11 @@ class Itunes:
26
26
 
27
27
  def __init__(self) -> None:
28
28
  """Initializes the iTunes class and sets up the session."""
29
- self._session = requests.Session()
29
+ self.__session = requests.Session()
30
30
  self.api_url = "https://itunes.apple.com"
31
31
  self._is_session_closed = False
32
32
  self.normalize_non_english = True
33
- self._translation_session = requests.Session()
33
+ self.__translation_session = requests.Session()
34
34
 
35
35
  def __enter__(self) -> "Itunes":
36
36
  """Enters the runtime context related to this object."""
@@ -43,8 +43,8 @@ class Itunes:
43
43
  def close_session(self) -> None:
44
44
  """Closes the current session."""
45
45
  if not self.is_session_closed:
46
- self._session.close()
47
- self._translation_session.close()
46
+ self.__session.close()
47
+ self.__translation_session.close()
48
48
  self._is_session_closed = True
49
49
 
50
50
  @property
@@ -96,7 +96,7 @@ class Itunes:
96
96
  f'Searching iTunes for `artist="{artist}"` and `song="{song}"`'
97
97
  )
98
98
  logger.debug(f"Query URL: {query_url}")
99
- response = self._session.get(query_url, timeout=30)
99
+ response = self.__session.get(query_url, timeout=30)
100
100
  logger.debug(f"Response status code: {response.status_code}")
101
101
  response.raise_for_status()
102
102
  except requests.RequestException as e:
@@ -148,13 +148,13 @@ class Itunes:
148
148
  result.get("trackName", result["collectionName"]),
149
149
  song,
150
150
  use_translation=self.normalize_non_english,
151
- translation_session=self._translation_session,
151
+ translation_session=self.__translation_session,
152
152
  )
153
153
  and are_strings_similar(
154
154
  result["artistName"],
155
155
  artist,
156
156
  use_translation=self.normalize_non_english,
157
- translation_session=self._translation_session,
157
+ translation_session=self.__translation_session,
158
158
  )
159
159
  ):
160
160
  continue
yutipy/kkbox.py CHANGED
@@ -2,8 +2,9 @@ __all__ = ["KKBox", "KKBoxException"]
2
2
 
3
3
  import base64
4
4
  import os
5
- import time
5
+ from dataclasses import asdict
6
6
  from pprint import pprint
7
+ from time import time
7
8
  from typing import Optional, Union
8
9
 
9
10
  import requests
@@ -16,9 +17,9 @@ from yutipy.exceptions import (
16
17
  KKBoxException,
17
18
  NetworkException,
18
19
  )
20
+ from yutipy.logger import logger
19
21
  from yutipy.models import MusicInfo
20
22
  from yutipy.utils.helpers import are_strings_similar, is_valid_string
21
- from yutipy.logger import logger
22
23
 
23
24
  load_dotenv()
24
25
 
@@ -35,7 +36,7 @@ class KKBox:
35
36
  """
36
37
 
37
38
  def __init__(
38
- self, client_id: str = KKBOX_CLIENT_ID, client_secret: str = KKBOX_CLIENT_SECRET
39
+ self, client_id: str = None, client_secret: str = None, defer_load: bool = False
39
40
  ) -> None:
40
41
  """
41
42
  Initializes the KKBox class and sets up the session.
@@ -46,22 +47,54 @@ class KKBox:
46
47
  The Client ID for the KKBOX Open API. Defaults to ``KKBOX_CLIENT_ID`` from .env file.
47
48
  client_secret : str, optional
48
49
  The Client secret for the KKBOX Open API. Defaults to ``KKBOX_CLIENT_SECRET`` from .env file.
50
+ defer_load : bool, optional
51
+ Whether to defer loading the access token during initialization. Default is ``False``.
49
52
  """
50
- if not client_id or not client_secret:
53
+ self.client_id = client_id or KKBOX_CLIENT_ID
54
+ self.client_secret = client_secret or KKBOX_CLIENT_SECRET
55
+
56
+ if not self.client_id:
51
57
  raise KKBoxException(
52
- "Failed to read `KKBOX_CLIENT_ID` and/or `KKBOX_CLIENT_SECRET` from environment variables. Client ID and Client Secret must be provided."
58
+ "Client ID was not found. Set it in environment variable or directly pass it when creating object."
53
59
  )
54
60
 
55
- self.client_id = client_id
56
- self.client_secret = client_secret
57
- self._session = requests.Session()
58
- self.api_url = "https://api.kkbox.com/v1.1"
59
- self.__header, self.__expires_in = self.__authenticate()
60
- self.__start_time = time.time()
61
+ if not self.client_secret:
62
+ raise KKBoxException(
63
+ "Client Secret was not found. Set it in environment variable or directly pass it when creating object."
64
+ )
65
+
66
+ self.defer_load = defer_load
67
+
61
68
  self._is_session_closed = False
62
- self.valid_territories = ["HK", "JP", "MY", "SG", "TW"]
63
- self.normalize_non_english = True
64
- self._translation_session = requests.Session()
69
+ self._normalize_non_english = True
70
+ self._valid_territories = ["HK", "JP", "MY", "SG", "TW"]
71
+
72
+ self.api_url = "https://api.kkbox.com/v1.1"
73
+ self.__access_token = None
74
+ self.__token_expires_in = None
75
+ self.__token_requested_at = None
76
+ self.__session = requests.Session()
77
+ self.__translation_session = requests.Session()
78
+
79
+ if not defer_load:
80
+ # Attempt to load access token during initialization if not deferred
81
+ token_info = None
82
+ try:
83
+ token_info = self.load_access_token()
84
+ except NotImplementedError:
85
+ logger.warning(
86
+ "`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
87
+ )
88
+ finally:
89
+ if not token_info:
90
+ token_info = self.__get_access_token()
91
+ self.__access_token = token_info.get("access_token")
92
+ self.__token_expires_in = token_info.get("expires_in")
93
+ self.__token_requested_at = token_info.get("requested_at")
94
+ else:
95
+ logger.warning(
96
+ "`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
97
+ )
65
98
 
66
99
  def __enter__(self):
67
100
  """Enters the runtime context related to this object."""
@@ -74,8 +107,8 @@ class KKBox:
74
107
  def close_session(self) -> None:
75
108
  """Closes the current session."""
76
109
  if not self.is_session_closed:
77
- self._session.close()
78
- self._translation_session.close()
110
+ self.__session.close()
111
+ self.__translation_session.close()
79
112
  self._is_session_closed = True
80
113
 
81
114
  @property
@@ -83,31 +116,44 @@ class KKBox:
83
116
  """Checks if the session is closed."""
84
117
  return self._is_session_closed
85
118
 
86
- def __authenticate(self) -> tuple:
119
+ def load_token_after_init(self):
120
+ """
121
+ Explicitly load the access token after initialization.
122
+ This is useful when ``defer_load`` is set to ``True`` during initialization.
123
+ """
124
+ token_info = None
125
+ try:
126
+ token_info = self.load_access_token()
127
+ except NotImplementedError:
128
+ logger.warning(
129
+ "`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
130
+ )
131
+ finally:
132
+ if not token_info:
133
+ token_info = self.__get_access_token()
134
+ self.__access_token = token_info.get("access_token")
135
+ self.__token_expires_in = token_info.get("expires_in")
136
+ self.__token_requested_at = token_info.get("requested_at")
137
+
138
+ def __authorization_header(self) -> dict:
87
139
  """
88
- Authenticates with the KKBOX Open API and returns the authorization header.
140
+ Generates the authorization header for Spotify API requests.
89
141
 
90
142
  Returns
91
143
  -------
92
144
  dict
93
- The authorization header.
145
+ A dictionary containing the Bearer token for authentication.
94
146
  """
95
- try:
96
- token, expires_in = self.__get_access_token()
97
- return {"Authorization": f"Bearer {token}"}, expires_in
98
- except Exception as e:
99
- raise AuthenticationException(
100
- "Failed to authenticate with KKBOX Open API"
101
- ) from e
102
-
103
- def __get_access_token(self) -> tuple:
147
+ return {"Authorization": f"Bearer {self.__access_token}"}
148
+
149
+ def __get_access_token(self) -> dict:
104
150
  """
105
- Gets the KKBOX Open API token.
151
+ Gets the KKBOX Open API access token information.
106
152
 
107
153
  Returns
108
154
  -------
109
155
  str
110
- The KKBOX Open API token.
156
+ The KKBOX Open API access token, with additional information such as expires in, etc.
111
157
  """
112
158
  auth_string = f"{self.client_id}:{self.client_secret}"
113
159
  auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
@@ -121,7 +167,7 @@ class KKBox:
121
167
 
122
168
  try:
123
169
  logger.info("Authenticating with KKBOX Open API")
124
- response = self._session.post(
170
+ response = self.__session.post(
125
171
  url=url, headers=headers, data=data, timeout=30
126
172
  )
127
173
  logger.debug(f"Authentication response status code: {response.status_code}")
@@ -130,17 +176,80 @@ class KKBox:
130
176
  logger.error(f"Network error during KKBOX authentication: {e}")
131
177
  raise NetworkException(f"Network error occurred: {e}")
132
178
 
133
- try:
179
+ if response.status_code == 200:
134
180
  response_json = response.json()
135
- return response_json.get("access_token"), response_json.get("expires_in")
136
- except (KeyError, ValueError) as e:
137
- raise InvalidResponseException(f"Invalid response received: {e}")
181
+ response_json["requested_at"] = time()
182
+ return response_json
183
+ else:
184
+ raise InvalidResponseException(
185
+ f"Invalid response received: {response.json()}"
186
+ )
138
187
 
139
- def __refresh_token_if_expired(self):
188
+ def __refresh_access_token(self):
140
189
  """Refreshes the token if it has expired."""
141
- if time.time() - self.__start_time >= self.__expires_in:
142
- self.__header, self.__expires_in = self.__authenticate()
143
- self.__start_time = time.time()
190
+ if time() - self.__token_requested_at >= self.__token_expires_in:
191
+ token_info = self.__get_access_token()
192
+
193
+ try:
194
+ self.save_access_token(token_info)
195
+ except NotImplementedError as e:
196
+ logger.warning(e)
197
+
198
+ self.__access_token = token_info.get("access_token")
199
+ self.__token_expires_in = token_info.get("expires_in")
200
+ self.__token_requested_at = token_info.get("requested_at")
201
+
202
+ logger.info("The access token is still valid, no need to refresh.")
203
+
204
+ def save_access_token(self, token_info: dict) -> None:
205
+ """
206
+ Saves the access token and related information.
207
+
208
+ This method must be overridden in a subclass to persist the access token and other
209
+ related information (e.g., expiration time). If not implemented,
210
+ the access token will not be saved, and it will be requested each time the
211
+ application restarts.
212
+
213
+ Parameters
214
+ ----------
215
+ token_info : dict
216
+ A dictionary containing the access token and related information, such as
217
+ refresh token, expiration time, etc.
218
+
219
+ Raises
220
+ ------
221
+ NotImplementedError
222
+ If the method is not overridden in a subclass.
223
+ """
224
+ raise NotImplementedError(
225
+ "The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
226
+ "If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
227
+ )
228
+
229
+ def load_access_token(self) -> Union[dict, None]:
230
+ """
231
+ Loads the access token and related information.
232
+
233
+ This method must be overridden in a subclass to retrieve the access token and other
234
+ related information (e.g., expiration time) from persistent storage.
235
+ If not implemented, the access token will not be loaded, and it will be requested
236
+ each time the application restarts.
237
+
238
+ Returns
239
+ -------
240
+ dict | None
241
+ A dictionary containing the access token and related information, such as
242
+ refresh token, expiration time, etc., or None if no token is found.
243
+
244
+ Raises
245
+ ------
246
+ NotImplementedError
247
+ If the method is not overridden in a subclass.
248
+ """
249
+ raise NotImplementedError(
250
+ "The `load_access_token` method must be overridden in a subclass to load access token and related information. "
251
+ "If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
252
+ )
144
253
 
145
254
  def search(
146
255
  self,
@@ -177,9 +286,9 @@ class KKBox:
177
286
  "Artist and song names must be valid strings and can't be empty."
178
287
  )
179
288
 
180
- self.normalize_non_english = normalize_non_english
289
+ self._normalize_non_english = normalize_non_english
181
290
 
182
- self.__refresh_token_if_expired()
291
+ self.__refresh_access_token()
183
292
 
184
293
  query = (
185
294
  f"?q={artist} - {song}&type=track,album&territory={territory}&limit={limit}"
@@ -190,7 +299,9 @@ class KKBox:
190
299
  logger.debug(f"Query URL: {query_url}")
191
300
 
192
301
  try:
193
- response = self._session.get(query_url, headers=self.__header, timeout=30)
302
+ response = self.__session.get(
303
+ query_url, headers=self.__authorization_header(), timeout=30
304
+ )
194
305
  logger.debug(f"Parsing response JSON: {response.json()}")
195
306
  response.raise_for_status()
196
307
  except requests.RequestException as e:
@@ -241,9 +352,9 @@ class KKBox:
241
352
  f"`content_type` must be one of these: {valid_content_types} !"
242
353
  )
243
354
 
244
- if territory not in self.valid_territories:
355
+ if territory not in self._valid_territories:
245
356
  raise InvalidValueException(
246
- f"`territory` must be one of these: {self.valid_territories} !"
357
+ f"`territory` must be one of these: {self._valid_territories} !"
247
358
  )
248
359
 
249
360
  if widget_lang not in valid_widget_langs:
@@ -306,8 +417,6 @@ class KKBox:
306
417
  The name of the artist.
307
418
  track : dict
308
419
  A single track from the search results.
309
- artist_ids : list
310
- A list of artist IDs.
311
420
 
312
421
  Returns
313
422
  -------
@@ -317,39 +426,39 @@ class KKBox:
317
426
  if not are_strings_similar(
318
427
  track["name"],
319
428
  song,
320
- use_translation=self.normalize_non_english,
321
- translation_session=self._translation_session,
429
+ use_translation=self._normalize_non_english,
430
+ translation_session=self.__translation_session,
322
431
  ):
323
432
  return None
324
433
 
325
- artists_name = track["album"]["artist"]["name"]
434
+ artists_name = track.get("album", {}).get("artist", {}).get("name")
326
435
  matching_artists = (
327
436
  artists_name
328
437
  if are_strings_similar(
329
438
  artists_name,
330
439
  artist,
331
- use_translation=self.normalize_non_english,
332
- translation_session=self._translation_session,
440
+ use_translation=self._normalize_non_english,
441
+ translation_session=self.__translation_session,
333
442
  )
334
443
  else None
335
444
  )
336
445
 
337
446
  if matching_artists:
338
447
  return MusicInfo(
339
- album_art=track["album"]["images"][2]["url"],
340
- album_title=track["album"]["name"],
448
+ album_art=track.get("album", {}).get("images", [])[2]["url"],
449
+ album_title=track.get("album", {}).get("name"),
341
450
  album_type=None,
342
451
  artists=artists_name,
343
452
  genre=None,
344
- id=track["id"],
345
- isrc=track["isrc"],
453
+ id=track.get("id"),
454
+ isrc=track.get("isrc"),
346
455
  lyrics=None,
347
- release_date=track["album"]["release_date"],
456
+ release_date=track.get("album", {}).get("release_date"),
348
457
  tempo=None,
349
- title=track["name"],
458
+ title=track.get("name"),
350
459
  type="track",
351
460
  upc=None,
352
- url=track["url"],
461
+ url=track.get("url"),
353
462
  )
354
463
 
355
464
  return None
@@ -366,8 +475,6 @@ class KKBox:
366
475
  The name of the artist.
367
476
  album : dict
368
477
  A single album from the search results.
369
- artist_ids : list
370
- A list of artist IDs.
371
478
 
372
479
  Returns
373
480
  -------
@@ -377,39 +484,39 @@ class KKBox:
377
484
  if not are_strings_similar(
378
485
  album["name"],
379
486
  song,
380
- use_translation=self.normalize_non_english,
381
- translation_session=self._translation_session,
487
+ use_translation=self._normalize_non_english,
488
+ translation_session=self.__translation_session,
382
489
  ):
383
490
  return None
384
491
 
385
- artists_name = album["artist"]["name"]
492
+ artists_name = album.get("artist", {}).get("name")
386
493
  matching_artists = (
387
494
  artists_name
388
495
  if are_strings_similar(
389
496
  artists_name,
390
497
  artist,
391
- use_translation=self.normalize_non_english,
392
- translation_session=self._translation_session,
498
+ use_translation=self._normalize_non_english,
499
+ translation_session=self.__translation_session,
393
500
  )
394
501
  else None
395
502
  )
396
503
 
397
504
  if matching_artists:
398
505
  return MusicInfo(
399
- album_art=album["images"][2]["url"],
400
- album_title=album["name"],
506
+ album_art=album.get("images", [])[2]["url"],
507
+ album_title=album.get("name"),
401
508
  album_type=None,
402
509
  artists=artists_name,
403
510
  genre=None,
404
- id=album["id"],
511
+ id=album.get("id"),
405
512
  isrc=None,
406
513
  lyrics=None,
407
- release_date=album["release_date"],
514
+ release_date=album.get("release_date"),
408
515
  tempo=None,
409
- title=album["name"],
516
+ title=album.get("name"),
410
517
  type="album",
411
518
  upc=None,
412
- url=album["url"],
519
+ url=album.get("url"),
413
520
  )
414
521
 
415
522
  return None
@@ -417,6 +524,7 @@ class KKBox:
417
524
 
418
525
  if __name__ == "__main__":
419
526
  import logging
527
+
420
528
  from yutipy.logger import enable_logging
421
529
 
422
530
  enable_logging(level=logging.DEBUG)
@@ -425,6 +533,7 @@ if __name__ == "__main__":
425
533
  try:
426
534
  artist_name = input("Artist Name: ")
427
535
  song_name = input("Song Name: ")
428
- pprint(kkbox.search(artist_name, song_name))
536
+ result = kkbox.search(artist_name, song_name)
537
+ pprint(asdict(result))
429
538
  finally:
430
539
  kkbox.close_session()
yutipy/models.py CHANGED
@@ -66,3 +66,16 @@ class MusicInfos(MusicInfo):
66
66
  """
67
67
 
68
68
  album_art_source: Optional[str] = None
69
+
70
+
71
+ @dataclass
72
+ class UserPlaying(MusicInfo):
73
+ """A data class to store users' currently playing music information.
74
+
75
+ Attributes
76
+ ----------
77
+ is_playing : Optional[bool]
78
+ Whether the music is currently playing or paused.
79
+ """
80
+
81
+ is_playing: Optional[bool] = None
yutipy/musicyt.py CHANGED
@@ -24,7 +24,7 @@ class MusicYT:
24
24
  self.ytmusic = YTMusic()
25
25
  self._is_session_closed = False
26
26
  self.normalize_non_english = True
27
- self._translation_session = requests.Session()
27
+ self.__translation_session = requests.Session()
28
28
 
29
29
  def __enter__(self) -> "MusicYT":
30
30
  """Enters the runtime context related to this object."""
@@ -37,7 +37,7 @@ class MusicYT:
37
37
  def close_session(self) -> None:
38
38
  """Closes the current session(s)."""
39
39
  if not self.is_session_closed:
40
- self._translation_session.close()
40
+ self.__translation_session.close()
41
41
  self._is_session_closed = True
42
42
 
43
43
  @property
@@ -125,13 +125,13 @@ class MusicYT:
125
125
  result.get("title"),
126
126
  song,
127
127
  use_translation=self.normalize_non_english,
128
- translation_session=self._translation_session,
128
+ translation_session=self.__translation_session,
129
129
  )
130
130
  and are_strings_similar(
131
131
  _artist.get("name"),
132
132
  artist,
133
133
  use_translation=self.normalize_non_english,
134
- translation_session=self._translation_session,
134
+ translation_session=self.__translation_session,
135
135
  )
136
136
  for _artist in result.get("artists", [])
137
137
  )
@@ -199,9 +199,9 @@ class MusicYT:
199
199
  MusicInfo
200
200
  The extracted music information.
201
201
  """
202
- title = result["title"]
203
- artist_names = ", ".join([artist["name"] for artist in result["artists"]])
204
- video_id = result["videoId"]
202
+ title = result.get("title")
203
+ artist_names = ", ".join([artist.get("name") for artist in result.get("artists", [])])
204
+ video_id = result.get("videoId")
205
205
  song_url = f"https://music.youtube.com/watch?v={video_id}"
206
206
  lyrics_id = self.ytmusic.get_watch_playlist(video_id)
207
207
 
@@ -300,8 +300,9 @@ if __name__ == "__main__":
300
300
 
301
301
  enable_logging(level=logging.DEBUG)
302
302
  music_yt = MusicYT()
303
-
304
- artist_name = input("Artist Name: ")
305
- song_name = input("Song Name: ")
306
-
307
- pprint(music_yt.search(artist_name, song_name))
303
+ try:
304
+ artist_name = input("Artist Name: ")
305
+ song_name = input("Song Name: ")
306
+ pprint(music_yt.search(artist_name, song_name))
307
+ finally:
308
+ music_yt.close_session()
yutipy/spotify.py CHANGED
@@ -21,7 +21,7 @@ from yutipy.exceptions import (
21
21
  SpotifyException,
22
22
  )
23
23
  from yutipy.logger import logger
24
- from yutipy.models import MusicInfo
24
+ from yutipy.models import MusicInfo, UserPlaying
25
25
  from yutipy.utils.helpers import (
26
26
  are_strings_similar,
27
27
  guess_album_type,
@@ -45,9 +45,7 @@ class Spotify:
45
45
  """
46
46
 
47
47
  def __init__(
48
- self,
49
- client_id: str = None,
50
- client_secret: str = None,
48
+ self, client_id: str = None, client_secret: str = None, defer_load: bool = False
51
49
  ) -> None:
52
50
  """
53
51
  Initializes the Spotify class (using Client Credentials grant type/flow) and sets up the session.
@@ -58,6 +56,8 @@ class Spotify:
58
56
  The Client ID for the Spotify API. Defaults to ``SPOTIFY_CLIENT_ID`` from environment variable or the ``.env`` file.
59
57
  client_secret : str, optional
60
58
  The Client secret for the Spotify API. Defaults to ``SPOTIFY_CLIENT_SECRET`` from environment variable or the ``.env`` file.
59
+ defer_load : bool, optional
60
+ Whether to defer loading the access token during initialization. Default is ``False``.
61
61
  """
62
62
 
63
63
  self.client_id = client_id or SPOTIFY_CLIENT_ID
@@ -73,14 +73,37 @@ class Spotify:
73
73
  "Client Secret was not found. Set it in environment variable or directly pass it when creating object."
74
74
  )
75
75
 
76
+ self.defer_load = defer_load
77
+
76
78
  self._is_session_closed = False
77
79
  self._normalize_non_english = True
78
80
 
79
81
  self.__api_url = "https://api.spotify.com/v1"
82
+ self.__access_token = None
83
+ self.__token_expires_in = None
84
+ self.__token_requested_at = None
80
85
  self.__session = requests.Session()
81
86
  self.__translation_session = requests.Session()
82
- self.__start_time = time()
83
- self.__header, self.__expires_in = self.__authenticate()
87
+
88
+ if not defer_load:
89
+ # Attempt to load access token during initialization if not deferred
90
+ token_info = None
91
+ try:
92
+ token_info = self.load_access_token()
93
+ except NotImplementedError:
94
+ logger.warning(
95
+ "`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
96
+ )
97
+ finally:
98
+ if not token_info:
99
+ token_info = self.__get_access_token()
100
+ self.__access_token = token_info.get("access_token")
101
+ self.__token_expires_in = token_info.get("expires_in")
102
+ self.__token_requested_at = token_info.get("requested_at")
103
+ else:
104
+ logger.warning(
105
+ "`defer_load` is set to `True`. Make sure to call `load_token_after_init()`."
106
+ )
84
107
 
85
108
  def __enter__(self):
86
109
  """Enters the runtime context related to this object."""
@@ -102,31 +125,44 @@ class Spotify:
102
125
  """Checks if the session is closed."""
103
126
  return self._is_session_closed
104
127
 
105
- def __authenticate(self) -> tuple:
128
+ def load_token_after_init(self):
129
+ """
130
+ Explicitly load the access token after initialization.
131
+ This is useful when ``defer_load`` is set to ``True`` during initialization.
106
132
  """
107
- Authenticates with the Spotify API and returns the authorization header.
133
+ token_info = None
134
+ try:
135
+ token_info = self.load_access_token()
136
+ except NotImplementedError:
137
+ logger.warning(
138
+ "`load_access_token` is not implemented. Falling back to in-memory storage and requesting new access token."
139
+ )
140
+ finally:
141
+ if not token_info:
142
+ token_info = self.__get_access_token()
143
+ self.__access_token = token_info.get("access_token")
144
+ self.__token_expires_in = token_info.get("expires_in")
145
+ self.__token_requested_at = token_info.get("requested_at")
146
+
147
+ def __authorization_header(self) -> dict:
148
+ """
149
+ Generates the authorization header for Spotify API requests.
108
150
 
109
151
  Returns
110
152
  -------
111
153
  dict
112
- The authorization header.
154
+ A dictionary containing the Bearer token for authentication.
113
155
  """
114
- try:
115
- token, expires_in = self.__get_spotify_token()
116
- return {"Authorization": f"Bearer {token}"}, expires_in
117
- except Exception as e:
118
- raise AuthenticationException(
119
- "Failed to authenticate with Spotify API"
120
- ) from e
156
+ return {"Authorization": f"Bearer {self.__access_token}"}
121
157
 
122
- def __get_spotify_token(self) -> tuple:
158
+ def __get_access_token(self) -> dict:
123
159
  """
124
- Gets the Spotify API token.
160
+ Gets the Spotify API access token information.
125
161
 
126
162
  Returns
127
163
  -------
128
- str
129
- The Spotify API token.
164
+ dict
165
+ The Spotify API access token, with additional information such as expires in, etc.
130
166
  """
131
167
  auth_string = f"{self.client_id}:{self.client_secret}"
132
168
  auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
@@ -139,7 +175,9 @@ class Spotify:
139
175
  data = {"grant_type": "client_credentials"}
140
176
 
141
177
  try:
142
- logger.info("Authenticating with Spotify API")
178
+ logger.info(
179
+ "Authenticating with Spotify API using Client Credentials grant type."
180
+ )
143
181
  response = self.__session.post(
144
182
  url=url, headers=headers, data=data, timeout=30
145
183
  )
@@ -149,17 +187,80 @@ class Spotify:
149
187
  logger.error(f"Network error during Spotify authentication: {e}")
150
188
  raise NetworkException(f"Network error occurred: {e}")
151
189
 
152
- try:
190
+ if response.status_code == 200:
153
191
  response_json = response.json()
154
- return response_json.get("access_token"), response_json.get("expires_in")
155
- except (KeyError, ValueError) as e:
156
- raise InvalidResponseException(f"Invalid response received: {e}")
192
+ response_json["requested_at"] = time()
193
+ return response_json
194
+ else:
195
+ raise InvalidResponseException(
196
+ f"Invalid response received: {response.json()}"
197
+ )
157
198
 
158
- def __refresh_token_if_expired(self):
199
+ def __refresh_access_token(self):
159
200
  """Refreshes the token if it has expired."""
160
- if time() - self.__start_time >= self.__expires_in:
161
- self.__header, self.__expires_in = self.__authenticate()
162
- self.__start_time = time()
201
+ if time() - self.__token_requested_at >= self.__token_expires_in:
202
+ token_info = self.__get_access_token()
203
+
204
+ try:
205
+ self.save_access_token(token_info)
206
+ except NotImplementedError as e:
207
+ logger.warning(e)
208
+
209
+ self.__access_token = token_info.get("access_token")
210
+ self.__token_expires_in = token_info.get("expires_in")
211
+ self.__token_requested_at = token_info.get("requested_at")
212
+
213
+ logger.info("The access token is still valid, no need to refresh.")
214
+
215
+ def save_access_token(self, token_info: dict) -> None:
216
+ """
217
+ Saves the access token and related information.
218
+
219
+ This method must be overridden in a subclass to persist the access token and other
220
+ related information (e.g., expiration time). If not implemented,
221
+ the access token will not be saved, and it will be requested each time the
222
+ application restarts.
223
+
224
+ Parameters
225
+ ----------
226
+ token_info : dict
227
+ A dictionary containing the access token and related information, such as
228
+ refresh token, expiration time, etc.
229
+
230
+ Raises
231
+ ------
232
+ NotImplementedError
233
+ If the method is not overridden in a subclass.
234
+ """
235
+ raise NotImplementedError(
236
+ "The `save_access_token` method must be overridden in a subclass to save the access token and related information. "
237
+ "If not implemented, access token information will not be persisted, and users will need to re-authenticate after application restarts."
238
+ )
239
+
240
+ def load_access_token(self) -> Union[dict, None]:
241
+ """
242
+ Loads the access token and related information.
243
+
244
+ This method must be overridden in a subclass to retrieve the access token and other
245
+ related information (e.g., expiration time) from persistent storage.
246
+ If not implemented, the access token will not be loaded, and it will be requested
247
+ each time the application restarts.
248
+
249
+ Returns
250
+ -------
251
+ dict | None
252
+ A dictionary containing the access token and related information, such as
253
+ refresh token, expiration time, etc., or None if no token is found.
254
+
255
+ Raises
256
+ ------
257
+ NotImplementedError
258
+ If the method is not overridden in a subclass.
259
+ """
260
+ raise NotImplementedError(
261
+ "The `load_access_token` method must be overridden in a subclass to load access token and related information. "
262
+ "If not implemented, access token information will not be loaded, and users will need to re-authenticate after application restarts."
263
+ )
163
264
 
164
265
  def search(
165
266
  self,
@@ -205,7 +306,7 @@ class Spotify:
205
306
  if music_info:
206
307
  return music_info
207
308
 
208
- self.__refresh_token_if_expired()
309
+ self.__refresh_access_token()
209
310
 
210
311
  query_url = f"{self.__api_url}/search{query}"
211
312
 
@@ -216,7 +317,7 @@ class Spotify:
216
317
 
217
318
  try:
218
319
  response = self.__session.get(
219
- query_url, headers=self.__header, timeout=30
320
+ query_url, headers=self.__authorization_header(), timeout=30
220
321
  )
221
322
  response.raise_for_status()
222
323
  except requests.RequestException as e:
@@ -271,7 +372,7 @@ class Spotify:
271
372
 
272
373
  self._normalize_non_english = normalize_non_english
273
374
 
274
- self.__refresh_token_if_expired()
375
+ self.__refresh_access_token()
275
376
 
276
377
  if isrc:
277
378
  query = f"?q={artist} {song} isrc:{isrc}&type=track&limit={limit}"
@@ -282,7 +383,9 @@ class Spotify:
282
383
 
283
384
  query_url = f"{self.__api_url}/search{query}"
284
385
  try:
285
- response = self.__session.get(query_url, headers=self.__header, timeout=30)
386
+ response = self.__session.get(
387
+ query_url, headers=self.__authorization_header(), timeout=30
388
+ )
286
389
  response.raise_for_status()
287
390
  except requests.RequestException as e:
288
391
  raise NetworkException(f"Network error occurred: {e}")
@@ -314,7 +417,7 @@ class Spotify:
314
417
  query_url = f"{self.__api_url}/search?q={name}&type=artist&limit=5"
315
418
  try:
316
419
  response = self.__session.get(
317
- query_url, headers=self.__header, timeout=30
420
+ query_url, headers=self.__authorization_header(), timeout=30
318
421
  )
319
422
  response.raise_for_status()
320
423
  except requests.RequestException as e:
@@ -560,6 +663,7 @@ class SpotifyAuth:
560
663
 
561
664
  self._is_session_closed = False
562
665
 
666
+ self.__api_url = "https://api.spotify.com/v1/me"
563
667
  self.__access_token = None
564
668
  self.__refresh_token = None
565
669
  self.__token_expires_in = None
@@ -608,7 +712,6 @@ class SpotifyAuth:
608
712
  """Closes the current session(s)."""
609
713
  if not self.is_session_closed:
610
714
  self.__session.close()
611
- self.__translation_session.close()
612
715
  self._is_session_closed = True
613
716
 
614
717
  @property
@@ -709,7 +812,7 @@ class SpotifyAuth:
709
812
  f"Invalid response received: {response.json()}"
710
813
  )
711
814
 
712
- def refresh_access_token(self):
815
+ def __refresh_access_token(self):
713
816
  """Refreshes the token if it has expired."""
714
817
  if time() - self.__token_requested_at >= self.__token_expires_in:
715
818
  token_info = self.__get_access_token(refresh_token=self.__refresh_token)
@@ -717,7 +820,7 @@ class SpotifyAuth:
717
820
  try:
718
821
  self.save_access_token(token_info)
719
822
  except NotImplementedError as e:
720
- print(e)
823
+ logger.warning(e)
721
824
 
722
825
  self.__access_token = token_info.get("access_token")
723
826
  self.__refresh_token = token_info.get("refresh_token")
@@ -909,11 +1012,12 @@ class SpotifyAuth:
909
1012
  dict
910
1013
  A dictionary containing the user's display name and profile images.
911
1014
  """
912
- endpoint_url = "https://api.spotify.com/v1/me"
1015
+ self.__refresh_access_token()
1016
+ query_url = self.__api_url
913
1017
  header = self.__authorization_header()
914
1018
 
915
1019
  try:
916
- response = self.__session.get(endpoint_url, headers=header, timeout=30)
1020
+ response = self.__session.get(query_url, headers=header, timeout=30)
917
1021
  response.raise_for_status()
918
1022
  except requests.RequestException as e:
919
1023
  logger.error(f"Failed to fetch user profile: {e}")
@@ -929,6 +1033,72 @@ class SpotifyAuth:
929
1033
  "images": response_json.get("images", []),
930
1034
  }
931
1035
 
1036
+ def get_currently_playing(self) -> Optional[UserPlaying]:
1037
+ """
1038
+ Fetches information about the currently playing track for the authenticated user.
1039
+
1040
+ This method interacts with the Spotify API to retrieve details about the track
1041
+ the user is currently listening to. It includes information such as the track's
1042
+ title, album, artists, release date, and more.
1043
+
1044
+ Returns
1045
+ -------
1046
+ Optional[UserPlaying_]
1047
+ An instance of the ``UserPlaying`` model containing details about the currently
1048
+ playing track if available, or ``None`` if no track is currently playing or an
1049
+ error occurs.
1050
+
1051
+ Notes
1052
+ -----
1053
+ - The user must have granted the necessary permissions (e.g., `user-read-currently-playing` scope) for this method to work.
1054
+ - If the API response does not contain the expected data, the method will return `None`.
1055
+
1056
+ """
1057
+ self.__refresh_access_token()
1058
+ query_url = f"{self.__api_url}/player/currently-playing"
1059
+ header = self.__authorization_header()
1060
+
1061
+ try:
1062
+ response = self.__session.get(query_url, headers=header, timeout=30)
1063
+ response.raise_for_status()
1064
+ except requests.RequestException as e:
1065
+ raise NetworkException(f"Network error occurred: {e}")
1066
+
1067
+ if response.status_code != 200:
1068
+ logger.error(f"Unexpected response: {response.json()}")
1069
+ return None
1070
+
1071
+ response_json = response.json()
1072
+ result = response_json.get("item")
1073
+ if result:
1074
+ guess = guess_album_type(result.get("album", {}).get("total_tracks", 1))
1075
+ guessed_right = are_strings_similar(
1076
+ result.get("album", {}).get("album_type", "x"),
1077
+ guess,
1078
+ use_translation=False,
1079
+ )
1080
+ return UserPlaying(
1081
+ album_art=result.get("album", {}).get("images", [])[0].get("url"),
1082
+ album_title=result.get("album", {}).get("name"),
1083
+ album_type=(
1084
+ result.get("album", {}).get("album_type")
1085
+ if guessed_right
1086
+ else guess
1087
+ ),
1088
+ artists=", ".join([x["name"] for x in result.get("artists", [])]),
1089
+ genre=None,
1090
+ id=result.get("id"),
1091
+ isrc=result.get("external_ids", {}).get("isrc"),
1092
+ is_playing=response_json.get("is_playing"),
1093
+ lyrics=None,
1094
+ release_date=result.get("album", {}).get("release_date"),
1095
+ tempo=None,
1096
+ title=result.get("name"),
1097
+ type=result.get("type"),
1098
+ upc=result.get("external_ids", {}).get("upc"),
1099
+ url=result.get("external_urls", {}).get("spotify"),
1100
+ )
1101
+
932
1102
 
933
1103
  if __name__ == "__main__":
934
1104
  import logging
@@ -949,7 +1119,8 @@ if __name__ == "__main__":
949
1119
  try:
950
1120
  artist_name = input("Artist Name: ")
951
1121
  song_name = input("Song Name: ")
952
- pprint(f"\n{asdict(spotify.search(artist_name, song_name))}")
1122
+ result = spotify.search(artist_name, song_name)
1123
+ pprint(asdict(result))
953
1124
  finally:
954
1125
  spotify.close_session()
955
1126
 
yutipy/yutipy_music.py CHANGED
@@ -1,3 +1,5 @@
1
+ __all__ = ["YutipyMusic"]
2
+
1
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
2
4
  from pprint import pprint
3
5
  from typing import Optional
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yutipy
3
- Version: 2.0.0
3
+ Version: 2.1.1
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>
@@ -0,0 +1,21 @@
1
+ yutipy/__init__.py,sha256=eURfdAFBvA9xF6cuUJUMWLf44LHxRQuXONfIPD-CkIQ,268
2
+ yutipy/deezer.py,sha256=PTTTfeORh1HZ_ta7_Uu4YARouSknUnAxO9AQJPFm4v0,11402
3
+ yutipy/exceptions.py,sha256=fur945x1Ibu7yeIPDRsOcujfVRRa5JHQw27dsOUreK4,1393
4
+ yutipy/itunes.py,sha256=fV7KLsXWvfM_97KwVwn_KfnWM7j0cVGE7RytvnDGlZM,7929
5
+ yutipy/kkbox.py,sha256=MuYpR_UTZQxeitn2rc0UUgiMVikIXcVWmns3eSVjd_g,18847
6
+ yutipy/logger.py,sha256=cHCjpDslVsBOnp7jluqrOOi4ekDIggPhbSfqHeIfT-U,1263
7
+ yutipy/models.py,sha256=_92e54uXXCw53oWZiNLBBai6C0InOZMJL7r8GJ5smbM,2215
8
+ yutipy/musicyt.py,sha256=6Vz8bI8hDNFoDKRh6GK90dGMRbn_d5d6eGPsaYogb_Y,9315
9
+ yutipy/spotify.py,sha256=m9GV3xZ94jRKtajMo34BARuR4l2gP6sUfZ8CKsJQDWo,42135
10
+ yutipy/yutipy_music.py,sha256=cHJ95HxGILweVrnEacj8tTlU0NPxMpuDVMpngdX0mZQ,6558
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=AZaqvs6AJwnqwJuodbGnHu702WSUqc8plVC16SppOcU,239
15
+ yutipy/utils/helpers.py,sha256=W3g9iqoSygcFFCKCp2sk0NQrZOEG26wI2XuNi9pgAXE,5207
16
+ yutipy-2.1.1.dist-info/licenses/LICENSE,sha256=_89JsS2QnBG8tAb5-VWbJDj_uJ002zPJAYBJJdh3DPY,1071
17
+ yutipy-2.1.1.dist-info/METADATA,sha256=86Xbqgk9pHIiGIQkZAMzaOKkUu4vmo8sadsoH1hAlQ8,6494
18
+ yutipy-2.1.1.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
+ yutipy-2.1.1.dist-info/entry_points.txt,sha256=BrgmanaPjQqKQ3Ip76JLcsPgGANtrBSURf5CNIxl1HA,106
20
+ yutipy-2.1.1.dist-info/top_level.txt,sha256=t2A5V2_mUcfnHkbCy6tAQlb3909jDYU5GQgXtA4756I,7
21
+ yutipy-2.1.1.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- yutipy/__init__.py,sha256=eURfdAFBvA9xF6cuUJUMWLf44LHxRQuXONfIPD-CkIQ,268
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=7BG9iv42mQk5CGAd7879fvUwo5ImpQlbFf3yfAzhnQI,34722
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=AZaqvs6AJwnqwJuodbGnHu702WSUqc8plVC16SppOcU,239
15
- yutipy/utils/helpers.py,sha256=W3g9iqoSygcFFCKCp2sk0NQrZOEG26wI2XuNi9pgAXE,5207
16
- yutipy-2.0.0.dist-info/licenses/LICENSE,sha256=_89JsS2QnBG8tAb5-VWbJDj_uJ002zPJAYBJJdh3DPY,1071
17
- yutipy-2.0.0.dist-info/METADATA,sha256=3KzURL_aKX4g6PHVo0RYO_cMxTnQ8cCHNeXVZsMxvRE,6494
18
- yutipy-2.0.0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
- yutipy-2.0.0.dist-info/entry_points.txt,sha256=BrgmanaPjQqKQ3Ip76JLcsPgGANtrBSURf5CNIxl1HA,106
20
- yutipy-2.0.0.dist-info/top_level.txt,sha256=t2A5V2_mUcfnHkbCy6tAQlb3909jDYU5GQgXtA4756I,7
21
- yutipy-2.0.0.dist-info/RECORD,,
File without changes