yutipy 0.1.0__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/musicyt.py ADDED
@@ -0,0 +1,247 @@
1
+ import os
2
+ from pprint import pprint
3
+ from typing import Optional
4
+
5
+ from ytmusicapi import YTMusic, exceptions
6
+
7
+ from yutipy.exceptions import (
8
+ InvalidResponseException,
9
+ InvalidValueException,
10
+ NetworkException,
11
+ )
12
+ from yutipy.models import MusicInfo
13
+ from yutipy.utils.cheap_utils import are_strings_similar, is_valid_string
14
+
15
+
16
+ class MusicYT:
17
+ """A class to interact with the YouTube Music API."""
18
+
19
+ def __init__(self) -> None:
20
+ """Initializes the YouTube Music class and sets up the session."""
21
+ self.ytmusic = YTMusic()
22
+
23
+ def search(self, artist: str, song: str) -> Optional[MusicInfo]:
24
+ """
25
+ Searches for a song by artist and title.
26
+
27
+ Parameters
28
+ ----------
29
+ artist : str
30
+ The name of the artist.
31
+ song : str
32
+ The title of the song.
33
+
34
+ Returns
35
+ -------
36
+ Optional[MusicInfo]
37
+ The music information if found, otherwise None.
38
+ """
39
+ if not is_valid_string(artist) or not is_valid_string(song):
40
+ raise InvalidValueException(
41
+ "Artist and song names must be valid strings and can't be empty."
42
+ )
43
+
44
+ query = f"{artist} - {song}"
45
+
46
+ try:
47
+ results = self.ytmusic.search(query=query)
48
+ except exceptions.YTMusicServerError as e:
49
+ raise NetworkException(f"Network error occurred: {e}")
50
+
51
+ for result in results:
52
+ if self._is_relevant_result(artist, song, result):
53
+ return self._process_result(result)
54
+
55
+ return None
56
+
57
+ def _is_relevant_result(self, artist: str, song: str, result: dict) -> bool:
58
+ """
59
+ Determine if a search result is relevant.
60
+
61
+ Parameters
62
+ ----------
63
+ artist : str
64
+ The name of the artist.
65
+ song : str
66
+ The title of the song.
67
+ result : dict
68
+ The search result from the API.
69
+
70
+ Returns
71
+ -------
72
+ bool
73
+ Whether the result is relevant.
74
+ """
75
+ if self._skip_categories(result):
76
+ return False
77
+
78
+ return any(
79
+ are_strings_similar(result.get("title"), song)
80
+ and are_strings_similar(_artist.get("name"), artist)
81
+ for _artist in result.get("artists", [])
82
+ )
83
+
84
+ def _skip_categories(self, result: dict) -> bool:
85
+ """
86
+ Skip certain categories in search results.
87
+
88
+ Parameters
89
+ ----------
90
+ result : dict
91
+ The search result from the API.
92
+
93
+ Returns
94
+ -------
95
+ bool
96
+ Return `True` if the result should be skipped, else `False`.
97
+ """
98
+ categories_skip = [
99
+ "artists",
100
+ "community playlists",
101
+ "featured playlists",
102
+ "podcasts",
103
+ "profiles",
104
+ "uploads",
105
+ "episode",
106
+ "episodes",
107
+ ]
108
+
109
+ return (
110
+ result.get("category", "").lower() in categories_skip
111
+ or result.get("resultType", "").lower() in categories_skip
112
+ )
113
+
114
+ def _process_result(self, result: dict) -> MusicInfo:
115
+ """
116
+ Process the search result and return relevant information as `MusicInfo`.
117
+
118
+ Parameters
119
+ ----------
120
+ result : dict
121
+ The search result from the API.
122
+
123
+ Returns
124
+ -------
125
+ MusicInfo
126
+ The extracted music information.
127
+ """
128
+ if result["resultType"] in ["song", "video"]:
129
+ return self._get_song(result)
130
+ else:
131
+ return self._get_album(result)
132
+
133
+ def _get_song(self, result: dict) -> MusicInfo:
134
+ """
135
+ Return song info as a `MusicInfo` object.
136
+
137
+ Parameters
138
+ ----------
139
+ result : dict
140
+ The search result from the API.
141
+
142
+ Returns
143
+ -------
144
+ MusicInfo
145
+ The extracted music information.
146
+ """
147
+ title = result["title"]
148
+ artist_names = ", ".join([artist["name"] for artist in result["artists"]])
149
+ video_id = result["videoId"]
150
+ song_url = f"https://music.youtube.com/watch?v={video_id}"
151
+ lyrics_id = self.ytmusic.get_watch_playlist(video_id)
152
+
153
+ try:
154
+ song_data = self.ytmusic.get_song(video_id)
155
+ release_date = (
156
+ song_data.get("microformat", {})
157
+ .get("microformatDataRenderer", {})
158
+ .get("uploadDate", "")
159
+ .split("T")[0]
160
+ )
161
+ except (exceptions.YTMusicServerError, exceptions.YTMusicError) as e:
162
+ raise InvalidResponseException(f"Invalid response received: {e}")
163
+
164
+ try:
165
+ lyrics = self.ytmusic.get_lyrics(lyrics_id.get("lyrics"))
166
+ except exceptions.YTMusicUserError:
167
+ lyrics = {}
168
+
169
+ album_art = result.get("thumbnails", [{}])[-1].get("url", None)
170
+
171
+ return MusicInfo(
172
+ album_art=album_art,
173
+ album_title=None,
174
+ album_type="single",
175
+ artists=artist_names,
176
+ genre=None,
177
+ id=video_id,
178
+ isrc=None,
179
+ lyrics=lyrics.get("lyrics"),
180
+ release_date=release_date,
181
+ tempo=None,
182
+ title=title,
183
+ type="song",
184
+ upc=None,
185
+ url=song_url,
186
+ )
187
+
188
+ def _get_album(self, result: dict) -> MusicInfo:
189
+ """
190
+ Return album info as a `MusicInfo` object.
191
+
192
+ Parameters
193
+ ----------
194
+ result : dict
195
+ The search result from the API.
196
+
197
+ Returns
198
+ -------
199
+ MusicInfo
200
+ The extracted music information.
201
+ """
202
+ title = result["title"]
203
+ artist_names = ", ".join([artist["name"] for artist in result["artists"]])
204
+ browse_id = result["browseId"]
205
+ album_url = f"https://music.youtube.com/browse/{browse_id}"
206
+
207
+ try:
208
+ album_data = self.ytmusic.get_album(browse_id)
209
+ release_date = album_data["year"]
210
+ except (exceptions.YTMusicServerError, exceptions.YTMusicError) as e:
211
+ raise InvalidResponseException(f"Invalid response received: {e}")
212
+
213
+ try:
214
+ lyrics_id = self.ytmusic.get_watch_playlist(browse_id)
215
+ lyrics = self.ytmusic.get_lyrics(lyrics_id.get("lyrics"))
216
+ except (exceptions.YTMusicServerError, exceptions.YTMusicUserError):
217
+ lyrics = {}
218
+
219
+ album_art = result.get("thumbnails", [{}])[-1].get(
220
+ "url", album_data.get("thumbnails", [{}])[-1].get("url", None)
221
+ )
222
+
223
+ return MusicInfo(
224
+ album_art=album_art,
225
+ album_title=title,
226
+ album_type="Album",
227
+ artists=artist_names,
228
+ genre=None,
229
+ id=browse_id,
230
+ isrc=None,
231
+ lyrics=lyrics.get("lyrics"),
232
+ release_date=release_date,
233
+ tempo=None,
234
+ title=title,
235
+ type="album",
236
+ upc=None,
237
+ url=album_url,
238
+ )
239
+
240
+
241
+ if __name__ == "__main__":
242
+ music_yt = MusicYT()
243
+
244
+ artist_name = input("Artist Name: ")
245
+ song_name = input("Song Name: ")
246
+
247
+ pprint(music_yt.search(artist_name, song_name))
yutipy/spotify.py ADDED
@@ -0,0 +1,413 @@
1
+ import base64
2
+ import os
3
+ import time
4
+ from pprint import pprint
5
+ from typing import Optional
6
+
7
+ import requests
8
+ from dotenv import load_dotenv
9
+
10
+ from yutipy.exceptions import (
11
+ AuthenticationException,
12
+ InvalidResponseException,
13
+ InvalidValueException,
14
+ NetworkException,
15
+ SpotifyException,
16
+ )
17
+ from yutipy.models import MusicInfo
18
+ from yutipy.utils.cheap_utils import (
19
+ are_strings_similar,
20
+ is_valid_string,
21
+ separate_artists,
22
+ )
23
+
24
+ load_dotenv()
25
+
26
+ CLIENT_ID = os.getenv("CLIENT_ID")
27
+ CLIENT_SECRET = os.getenv("CLIENT_SECRET")
28
+
29
+
30
+ class Spotipy:
31
+ """
32
+ A class to interact with the Spotify API.
33
+
34
+ This class reads the CLIENT_ID and CLIENT_SECRET from the .env file by default.
35
+ Alternatively, users can manually provide these values when creating an object.
36
+ """
37
+
38
+ def __init__(
39
+ self, client_id: str = CLIENT_ID, client_secret: str = CLIENT_SECRET
40
+ ) -> None:
41
+ """
42
+ Initializes the Spotipy class and sets up the session.
43
+
44
+ Parameters
45
+ ----------
46
+ client_id : str, optional
47
+ The client ID for the Spotify API. Defaults to CLIENT_ID from .env file.
48
+ client_secret : str, optional
49
+ The client secret for the Spotify API. Defaults to CLIENT_SECRET from .env file.
50
+ """
51
+ if not client_id or not client_secret:
52
+ raise SpotifyException(
53
+ "Failed to read CLIENT_ID and CLIENT_SECRET from environment variables. Client ID and Client Secret must be provided."
54
+ )
55
+
56
+ self.client_id = client_id
57
+ self.client_secret = client_secret
58
+ self._session = requests.Session()
59
+ self.api_url = "https://api.spotify.com/v1"
60
+ self.__header = self.__authenticate()
61
+ self.__start_time = time.time()
62
+ self._is_session_closed = False
63
+
64
+ def __enter__(self):
65
+ """Enters the runtime context related to this object."""
66
+ return self
67
+
68
+ def __exit__(self, exc_type, exc_value, exc_traceback):
69
+ """Exits the runtime context related to this object."""
70
+ self._close_session()
71
+
72
+ def _close_session(self) -> None:
73
+ """Closes the current session."""
74
+ if not self.is_session_closed:
75
+ self._session.close()
76
+ self._is_session_closed = True
77
+
78
+ @property
79
+ def is_session_closed(self) -> bool:
80
+ """Checks if the session is closed."""
81
+ return self._is_session_closed
82
+
83
+ def __authenticate(self) -> dict:
84
+ """
85
+ Authenticates with the Spotify API and returns the authorization header.
86
+
87
+ Returns
88
+ -------
89
+ dict
90
+ The authorization header.
91
+ """
92
+ try:
93
+ token = self.__get_spotify_token()
94
+ return {"Authorization": f"Bearer {token}"}
95
+ except Exception as e:
96
+ raise AuthenticationException(
97
+ "Failed to authenticate with Spotify API"
98
+ ) from e
99
+
100
+ def __get_spotify_token(self) -> str:
101
+ """
102
+ Gets the Spotify API token.
103
+
104
+ Returns
105
+ -------
106
+ str
107
+ The Spotify API token.
108
+ """
109
+ auth_string = f"{self.client_id}:{self.client_secret}"
110
+ auth_base64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
111
+
112
+ url = "https://accounts.spotify.com/api/token"
113
+ headers = {
114
+ "Authorization": f"Basic {auth_base64}",
115
+ "Content-Type": "application/x-www-form-urlencoded",
116
+ }
117
+ data = {"grant_type": "client_credentials"}
118
+
119
+ try:
120
+ response = self._session.post(
121
+ url=url, headers=headers, data=data, timeout=30
122
+ )
123
+ response.raise_for_status()
124
+ except requests.RequestException as e:
125
+ raise NetworkException(f"Network error occurred: {e}")
126
+
127
+ try:
128
+ return response.json().get("access_token")
129
+ except (KeyError, ValueError) as e:
130
+ raise InvalidResponseException(f"Invalid response received: {e}")
131
+
132
+ def __refresh_token_if_expired(self):
133
+ """Refreshes the token if it has expired."""
134
+ if time.time() - self.__start_time >= 3600:
135
+ self.__header = self.__authenticate()
136
+
137
+ def search(self, artist: str, song: str) -> Optional[MusicInfo]:
138
+ """
139
+ Searches for a song by artist and title.
140
+
141
+ Parameters
142
+ ----------
143
+ artist : str
144
+ The name of the artist.
145
+ song : str
146
+ The title of the song.
147
+
148
+ Returns
149
+ -------
150
+ Optional[MusicInfo]
151
+ The music information if found, otherwise None.
152
+ """
153
+ if not is_valid_string(artist) or not is_valid_string(song):
154
+ raise InvalidValueException(
155
+ "Artist and song names must be valid strings and can't be empty."
156
+ )
157
+
158
+ self.__refresh_token_if_expired()
159
+
160
+ query = f"?q=artist:{artist} track:{song}&type=track&limit=10"
161
+ query_url = f"{self.api_url}/search{query}"
162
+
163
+ try:
164
+ response = self._session.get(query_url, headers=self.__header, timeout=30)
165
+ response.raise_for_status()
166
+ except requests.RequestException as e:
167
+ raise NetworkException(f"Network error occurred: {e}")
168
+
169
+ try:
170
+ result = response.json()["tracks"]["items"]
171
+ except (KeyError, ValueError) as e:
172
+ raise InvalidResponseException(f"Invalid response received: {e}")
173
+
174
+ return self._parse_results(artist, song, result)
175
+
176
+ def search_advanced(
177
+ self, artist: str, song: str, isrc: str = None, upc: str = None
178
+ ) -> Optional[MusicInfo]:
179
+ """
180
+ Searches for a song by artist, title, ISRC, or UPC.
181
+
182
+ Parameters
183
+ ----------
184
+ artist : str
185
+ The name of the artist.
186
+ song : str
187
+ The title of the song.
188
+ isrc : str, optional
189
+ The ISRC of the track.
190
+ upc : str, optional
191
+ The UPC of the album.
192
+
193
+ Returns
194
+ -------
195
+ Optional[MusicInfo]
196
+ The music information if found, otherwise None.
197
+ """
198
+ if not is_valid_string(artist) or not is_valid_string(song):
199
+ raise InvalidValueException(
200
+ "Artist and song names must be valid strings and can't be empty."
201
+ )
202
+
203
+ self.__refresh_token_if_expired()
204
+
205
+ if isrc:
206
+ query = f"?q={artist} {song} isrc:{isrc}&type=track&limit=1"
207
+ elif upc:
208
+ query = f"?q={artist} {song} upc:{upc}&type=album&limit=1"
209
+ else:
210
+ raise InvalidValueException("ISRC or UPC must be provided.")
211
+
212
+ query_url = f"{self.api_url}/search{query}"
213
+ try:
214
+ response = self._session.get(query_url, headers=self.__header, timeout=30)
215
+ response.raise_for_status()
216
+ except requests.RequestException as e:
217
+ raise NetworkException(f"Network error occurred: {e}")
218
+
219
+ if response.status_code != 200:
220
+ raise SpotifyException(
221
+ f"Failed to search music with ISRC/UPC: {response.json()}"
222
+ )
223
+
224
+ artist_ids = self._get_artists_ids(artist)
225
+ return self._find_music_info(artist, song, response.json(), artist_ids)
226
+
227
+ def _get_artists_ids(self, artist: str) -> list | None:
228
+ """
229
+ Retrieves the IDs of the artists.
230
+
231
+ Parameters
232
+ ----------
233
+ artist : str
234
+ The name of the artist.
235
+
236
+ Returns
237
+ -------
238
+ list | None
239
+ A list of artist IDs or None if not found.
240
+ """
241
+ artist_ids = []
242
+ for name in separate_artists(artist):
243
+ query_url = f"{self.api_url}/search?q={name}&type=artist&limit=5"
244
+ try:
245
+ response = self._session.get(
246
+ query_url, headers=self.__header, timeout=30
247
+ )
248
+ response.raise_for_status()
249
+ except requests.RequestException as e:
250
+ raise NetworkException(f"Network error occurred: {e}")
251
+
252
+ if response.status_code != 200:
253
+ return None
254
+
255
+ artist_ids.extend(
256
+ artist["id"] for artist in response.json()["artists"]["items"]
257
+ )
258
+ return artist_ids
259
+
260
+ def _find_music_info(
261
+ self, artist: str, song: str, response_json: dict, artist_ids: list
262
+ ) -> Optional[MusicInfo]:
263
+ """
264
+ Finds the music information from the search results.
265
+
266
+ Parameters
267
+ ----------
268
+ artist : str
269
+ The name of the artist.
270
+ song : str
271
+ The title of the song.
272
+ response_json : dict
273
+ The JSON response from the API.
274
+ artist_ids : list
275
+ A list of artist IDs.
276
+
277
+ Returns
278
+ -------
279
+ Optional[MusicInfo]
280
+ The music information if found, otherwise None.
281
+ """
282
+ try:
283
+ for track in response_json["tracks"]["items"]:
284
+ music_info = self._find_tracks(song, artist, track, artist_ids)
285
+ if music_info:
286
+ return music_info
287
+ except KeyError:
288
+ pass
289
+
290
+ try:
291
+ for album in response_json["albums"]["items"]:
292
+ music_info = self._find_album(song, artist, album, artist_ids)
293
+ if music_info:
294
+ return music_info
295
+ except KeyError:
296
+ pass
297
+
298
+ return None
299
+
300
+ def _find_tracks(
301
+ self, song: str, artist: str, track: dict, artist_ids: list
302
+ ) -> Optional[MusicInfo]:
303
+ """
304
+ Finds the track information from the search results.
305
+
306
+ Parameters
307
+ ----------
308
+ song : str
309
+ The title of the song.
310
+ artist : str
311
+ The name of the artist.
312
+ track : dict
313
+ A single track from the search results.
314
+ artist_ids : list
315
+ A list of artist IDs.
316
+
317
+ Returns
318
+ -------
319
+ Optional[MusicInfo]
320
+ The music information if found, otherwise None.
321
+ """
322
+ if not are_strings_similar(track["name"], song):
323
+ return None
324
+
325
+ artists_name = [x["name"] for x in track["artists"]]
326
+ matching_artists = [
327
+ x["name"]
328
+ for x in track["artists"]
329
+ if are_strings_similar(x["name"], artist) or x["id"] in artist_ids
330
+ ]
331
+
332
+ if matching_artists:
333
+ return MusicInfo(
334
+ album_art=track["album"]["images"][0]["url"],
335
+ album_title=track["album"]["name"],
336
+ album_type=track["album"]["album_type"],
337
+ artists=", ".join(artists_name),
338
+ genre=None,
339
+ id=track["id"],
340
+ isrc=track.get("external_ids").get("isrc"),
341
+ lyrics=None,
342
+ release_date=track["album"]["release_date"],
343
+ tempo=None,
344
+ title=track["name"],
345
+ type="track",
346
+ upc=None,
347
+ url=track["external_urls"]["spotify"],
348
+ )
349
+
350
+ return None
351
+
352
+ def _find_album(
353
+ self, song: str, artist: str, album: dict, artist_ids: list
354
+ ) -> Optional[MusicInfo]:
355
+ """
356
+ Finds the album information from the search results.
357
+
358
+ Parameters
359
+ ----------
360
+ song : str
361
+ The title of the song.
362
+ artist : str
363
+ The name of the artist.
364
+ album : dict
365
+ A single album from the search results.
366
+ artist_ids : list
367
+ A list of artist IDs.
368
+
369
+ Returns
370
+ -------
371
+ Optional[MusicInfo]
372
+ The music information if found, otherwise None.
373
+ """
374
+ if not are_strings_similar(album["name"], song):
375
+ return None
376
+
377
+ artists_name = [x["name"] for x in album["artists"]]
378
+ matching_artists = [
379
+ x["name"]
380
+ for x in album["artists"]
381
+ if are_strings_similar(x["name"], artist) or x["id"] in artist_ids
382
+ ]
383
+
384
+ if matching_artists:
385
+ return MusicInfo(
386
+ album_art=album["images"][0]["url"],
387
+ album_title=album["name"],
388
+ album_type=album["album_type"],
389
+ artists=", ".join(artists_name),
390
+ genre=None,
391
+ id=album["id"],
392
+ isrc=None,
393
+ lyrics=None,
394
+ release_date=album["release_date"],
395
+ tempo=None,
396
+ title=album["name"],
397
+ type="album",
398
+ upc=None,
399
+ url=album["external_urls"]["spotify"],
400
+ )
401
+
402
+ return None
403
+
404
+
405
+ if __name__ == "__main__":
406
+ spotipy = Spotipy(CLIENT_ID, CLIENT_SECRET)
407
+
408
+ try:
409
+ artist_name = input("Artist Name: ")
410
+ song_name = input("Song Name: ")
411
+ pprint(spotipy.search(artist_name, song_name))
412
+ finally:
413
+ spotipy._close_session()
@@ -0,0 +1,7 @@
1
+ from .cheap_utils import are_strings_similar, is_valid_string, separate_artists
2
+
3
+ __all__ = [
4
+ "are_strings_similar",
5
+ "is_valid_string",
6
+ "separate_artists",
7
+ ]