yutipy 1.3.1__tar.gz → 1.4.0__tar.gz

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.

Files changed (55) hide show
  1. yutipy-1.4.0/.github/release-drafter.yml +16 -0
  2. {yutipy-1.3.1 → yutipy-1.4.0}/.github/workflows/release.yml +19 -3
  3. {yutipy-1.3.1 → yutipy-1.4.0}/PKG-INFO +1 -1
  4. {yutipy-1.3.1 → yutipy-1.4.0}/docs/usage_examples.rst +4 -4
  5. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_deezer.py +7 -0
  6. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_musicyt.py +5 -0
  7. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_utils.py +8 -1
  8. yutipy-1.4.0/tests/test_yutipy_music.py +48 -0
  9. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/deezer.py +30 -4
  10. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/itunes.py +27 -4
  11. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/kkbox.py +46 -6
  12. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/musicyt.py +42 -5
  13. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/spotify.py +60 -11
  14. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/utils/cheap_utils.py +34 -5
  15. yutipy-1.4.0/yutipy/utils/logger.py +4 -0
  16. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/yutipy_music.py +71 -13
  17. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy.egg-info/PKG-INFO +1 -1
  18. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy.egg-info/SOURCES.txt +4 -1
  19. {yutipy-1.3.1 → yutipy-1.4.0}/.gitattributes +0 -0
  20. {yutipy-1.3.1 → yutipy-1.4.0}/.github/FUNDING.yml +0 -0
  21. {yutipy-1.3.1 → yutipy-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {yutipy-1.3.1 → yutipy-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  23. {yutipy-1.3.1 → yutipy-1.4.0}/.github/workflows/pytest-unit-testing.yml +0 -0
  24. {yutipy-1.3.1 → yutipy-1.4.0}/.gitignore +0 -0
  25. {yutipy-1.3.1 → yutipy-1.4.0}/.readthedocs.yaml +0 -0
  26. {yutipy-1.3.1 → yutipy-1.4.0}/LICENSE +0 -0
  27. {yutipy-1.3.1 → yutipy-1.4.0}/MANIFEST.in +0 -0
  28. {yutipy-1.3.1 → yutipy-1.4.0}/README.md +0 -0
  29. {yutipy-1.3.1 → yutipy-1.4.0}/docs/Makefile +0 -0
  30. {yutipy-1.3.1 → yutipy-1.4.0}/docs/_static/yutipy_header.png +0 -0
  31. {yutipy-1.3.1 → yutipy-1.4.0}/docs/_static/yutipy_logo.png +0 -0
  32. {yutipy-1.3.1 → yutipy-1.4.0}/docs/api_reference.rst +0 -0
  33. {yutipy-1.3.1 → yutipy-1.4.0}/docs/available_platforms.rst +0 -0
  34. {yutipy-1.3.1 → yutipy-1.4.0}/docs/conf.py +0 -0
  35. {yutipy-1.3.1 → yutipy-1.4.0}/docs/faq.rst +0 -0
  36. {yutipy-1.3.1 → yutipy-1.4.0}/docs/index.rst +0 -0
  37. {yutipy-1.3.1 → yutipy-1.4.0}/docs/installation.rst +0 -0
  38. {yutipy-1.3.1 → yutipy-1.4.0}/docs/make.bat +0 -0
  39. {yutipy-1.3.1 → yutipy-1.4.0}/docs/requirements.txt +0 -0
  40. {yutipy-1.3.1 → yutipy-1.4.0}/pyproject.toml +0 -0
  41. {yutipy-1.3.1 → yutipy-1.4.0}/requirements-dev.txt +0 -0
  42. {yutipy-1.3.1 → yutipy-1.4.0}/requirements.txt +0 -0
  43. {yutipy-1.3.1 → yutipy-1.4.0}/setup.cfg +0 -0
  44. {yutipy-1.3.1 → yutipy-1.4.0}/tests/__init__.py +0 -0
  45. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_itunes.py +0 -0
  46. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_kkbox.py +0 -0
  47. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_models.py +0 -0
  48. {yutipy-1.3.1 → yutipy-1.4.0}/tests/test_spotify.py +0 -0
  49. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/__init__.py +0 -0
  50. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/exceptions.py +0 -0
  51. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/models.py +0 -0
  52. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy/utils/__init__.py +0 -0
  53. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy.egg-info/dependency_links.txt +0 -0
  54. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy.egg-info/requires.txt +0 -0
  55. {yutipy-1.3.1 → yutipy-1.4.0}/yutipy.egg-info/top_level.txt +0 -0
@@ -0,0 +1,16 @@
1
+ name-template: 'v$NEXT_PATCH_VERSION'
2
+ tag-template: 'v$NEXT_PATCH_VERSION'
3
+ categories:
4
+ - title: '🚀 New Features'
5
+ label: 'feature'
6
+ - title: '🐛 Bug Fixes'
7
+ label: 'bug'
8
+ - title: '🧰 Maintenance'
9
+ label: 'maintenance'
10
+ change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
11
+ template: |
12
+ ## What's Changed
13
+
14
+ $CHANGES
15
+
16
+ **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...$NEW_TAG
@@ -1,6 +1,9 @@
1
- name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
1
+ name: Publish Python 🐍 distribution 📦 to PyPI
2
2
 
3
- on: push
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
4
7
 
5
8
  jobs:
6
9
  build:
@@ -51,12 +54,25 @@ jobs:
51
54
  path: dist/
52
55
  - name: Publish distribution 📦 to PyPI
53
56
  uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
57
+ draft-release:
58
+ name: Draft Release Notes
59
+ runs-on: ubuntu-latest
60
+ permissions:
61
+ contents: read
62
+ needs:
63
+ - publish-to-pypi
64
+ steps:
65
+ - name: Draft Release Notes
66
+ uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
67
+ with:
68
+ config-name: .github/release-drafter.yml
54
69
  github-release:
55
70
  name: >-
56
71
  Sign the Python 🐍 distribution 📦 with Sigstore
57
72
  and upload them to GitHub Release
58
73
  needs:
59
74
  - publish-to-pypi
75
+ - draft-release
60
76
  runs-on: ubuntu-latest
61
77
 
62
78
  permissions:
@@ -82,7 +98,7 @@ jobs:
82
98
  gh release create
83
99
  "$GITHUB_REF_NAME"
84
100
  --repo "$GITHUB_REPOSITORY"
85
- --notes ""
101
+ --notes-file .github/release-drafter.yml
86
102
  --generate-notes
87
103
  - name: Upload artifact signatures to GitHub Release
88
104
  env:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yutipy
3
- Version: 1.3.1
3
+ Version: 1.4.0
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>
@@ -5,7 +5,7 @@ Usage Examples
5
5
  Here's a quick example of how to use the **yutipy** package to search for a song:
6
6
 
7
7
  .. important::
8
- All examples here—except for the `YouTube Music`_ & `Yutipy Music`_—use the ``with`` context manager to initialize an instance of the respective class,
8
+ All examples here—except for the `YouTube Music`_—use the ``with`` context manager to initialize an instance of the respective class,
9
9
  as those classes internally use ``requests.Session()`` for making requests to APIs.
10
10
  This approach ensures that the session is automatically closed once you exit the context. Although using ``with`` is not mandatory,
11
11
  if you instantiate an object without it, you are responsible for closing the session after use by calling the ``close_session()`` method on that object.
@@ -118,6 +118,6 @@ Yutipy Music
118
118
 
119
119
  from yutipy.yutify_music import YutipyMusic
120
120
 
121
- yutipy_music = YutipyMusic()
122
- result = yutify_music.search("Artist Name", "Song Title")
123
- print(result)
121
+ with YutipyMusic() as yutipy_music:
122
+ result = yutify_music.search("Artist Name", "Song Title")
123
+ print(result)
@@ -21,6 +21,13 @@ def test_search_invalid(deezer):
21
21
  assert result is None
22
22
 
23
23
 
24
+ def test_search_translation_matching(deezer):
25
+ result = deezer.search("Porter Robinson", "Shelter", normalize_non_english=False)
26
+ assert result.id != 1355346522
27
+ result = deezer.search("Porter Robinson", "Shelter", normalize_non_english=True)
28
+ assert result.id == 1355346522 or 2404408865
29
+
30
+
24
31
  def test_get_upc_isrc_track(deezer):
25
32
  track_id = 781592622
26
33
  result = deezer._get_upc_isrc(track_id, "track")
@@ -49,3 +49,8 @@ def test_search_empty_song(music_yt):
49
49
 
50
50
  with raises(InvalidValueException):
51
51
  music_yt.search(artist, song)
52
+
53
+
54
+ def test_close_session(music_yt):
55
+ music_yt.close_session()
56
+ assert music_yt.is_session_closed
@@ -7,10 +7,17 @@ def test_are_strings_similar():
7
7
  assert are_strings_similar("Hello World", "Hello") is True
8
8
 
9
9
 
10
+ def test_are_strings_similar_translation():
11
+ assert are_strings_similar("ポーター", "Porter") is True
12
+
13
+
10
14
  def test_separate_artists():
11
15
  assert separate_artists("Artist A & Artist B") == ["Artist A", "Artist B"]
12
16
  assert separate_artists("Artist A ft. Artist B") == ["Artist A", "Artist B"]
13
17
  assert separate_artists("Artist A") == ["Artist A"]
14
18
  assert separate_artists("Artist A and Artist B") == ["Artist A", "Artist B"]
15
19
  assert separate_artists("Artist A / Artist B") == ["Artist A", "Artist B"]
16
- assert separate_artists("Artist A, Artist B", custom_separator=",") == ["Artist A", "Artist B"]
20
+ assert separate_artists("Artist A, Artist B", custom_separator=",") == [
21
+ "Artist A",
22
+ "Artist B",
23
+ ]
@@ -0,0 +1,48 @@
1
+ import pytest
2
+ from yutipy.yutipy_music import YutipyMusic
3
+ from yutipy.models import MusicInfos
4
+ from yutipy.exceptions import InvalidValueException
5
+
6
+
7
+ @pytest.fixture
8
+ def yutipy_music():
9
+ return YutipyMusic()
10
+
11
+
12
+ def test_search_valid(yutipy_music):
13
+ artist = "Adele"
14
+ song = "Hello"
15
+ result = yutipy_music.search(artist, song)
16
+ assert result is not None
17
+ assert isinstance(result, MusicInfos)
18
+ assert "Hello" in result.title
19
+ assert "Adele" in result.artists
20
+
21
+
22
+ def test_search_invalid(yutipy_music):
23
+ artist = "Nonexistent Artist"
24
+ song = "Nonexistent Song"
25
+ result = yutipy_music.search(artist, song)
26
+ assert result is None
27
+
28
+
29
+ def test_search_empty_artist(yutipy_music):
30
+ artist = ""
31
+ song = "Hello"
32
+
33
+ with pytest.raises(InvalidValueException):
34
+ yutipy_music.search(artist, song)
35
+
36
+
37
+ def test_search_empty_song(yutipy_music):
38
+ artist = "Adele"
39
+ song = ""
40
+
41
+ with pytest.raises(InvalidValueException):
42
+ yutipy_music.search(artist, song)
43
+
44
+
45
+ def test_close_sessions(yutipy_music):
46
+ yutipy_music.close_sessions()
47
+ for service in yutipy_music.services.values():
48
+ assert service.is_session_closed
@@ -21,6 +21,8 @@ class Deezer:
21
21
  self._session = requests.Session()
22
22
  self.api_url = "https://api.deezer.com"
23
23
  self._is_session_closed = False
24
+ self.normalize_non_english = True
25
+ self._translation_session = requests.Session()
24
26
 
25
27
  def __enter__(self) -> "Deezer":
26
28
  """Enters the runtime context related to this object."""
@@ -34,6 +36,7 @@ class Deezer:
34
36
  """Closes the current session."""
35
37
  if not self.is_session_closed:
36
38
  self._session.close()
39
+ self._translation_session.close()
37
40
  self._is_session_closed = True
38
41
 
39
42
  @property
@@ -41,7 +44,13 @@ class Deezer:
41
44
  """Checks if the session is closed."""
42
45
  return self._is_session_closed
43
46
 
44
- def search(self, artist: str, song: str) -> Optional[MusicInfo]:
47
+ def search(
48
+ self,
49
+ artist: str,
50
+ song: str,
51
+ limit: int = 10,
52
+ normalize_non_english: bool = True,
53
+ ) -> Optional[MusicInfo]:
45
54
  """
46
55
  Searches for a song by artist and title.
47
56
 
@@ -51,6 +60,11 @@ class Deezer:
51
60
  The name of the artist.
52
61
  song : str
53
62
  The title of the song.
63
+ limit: int, optional
64
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``10``.
65
+ normalize_non_english : bool, optional
66
+ Whether to normalize non-English characters for comparison. Default is ``True``.
67
+
54
68
 
55
69
  Returns
56
70
  -------
@@ -62,11 +76,13 @@ class Deezer:
62
76
  "Artist and song names must be valid strings and can't be empty."
63
77
  )
64
78
 
79
+ self.normalize_non_english = normalize_non_english
80
+
65
81
  search_types = ["track", "album"]
66
82
 
67
83
  for search_type in search_types:
68
84
  endpoint = f"{self.api_url}/search/{search_type}"
69
- query = f'?q=artist:"{artist}" {search_type}:"{song}"&limit=10'
85
+ query = f'?q=artist:"{artist}" {search_type}:"{song}"&limit={limit}'
70
86
  query_url = endpoint + query
71
87
 
72
88
  try:
@@ -205,8 +221,18 @@ class Deezer:
205
221
  """
206
222
  for result in results:
207
223
  if not (
208
- are_strings_similar(result["title"], song)
209
- and are_strings_similar(result["artist"]["name"], artist)
224
+ are_strings_similar(
225
+ result["title"],
226
+ song,
227
+ use_translation=self.normalize_non_english,
228
+ translation_session=self._translation_session,
229
+ )
230
+ and are_strings_similar(
231
+ result["artist"]["name"],
232
+ artist,
233
+ use_translation=self.normalize_non_english,
234
+ translation_session=self._translation_session,
235
+ )
210
236
  ):
211
237
  continue
212
238
 
@@ -22,6 +22,8 @@ class Itunes:
22
22
  self._session = requests.Session()
23
23
  self.api_url = "https://itunes.apple.com"
24
24
  self._is_session_closed = False
25
+ self.normalize_non_english = True
26
+ self._translation_session = requests.Session()
25
27
 
26
28
  def __enter__(self) -> "Itunes":
27
29
  """Enters the runtime context related to this object."""
@@ -35,6 +37,7 @@ class Itunes:
35
37
  """Closes the current session."""
36
38
  if not self.is_session_closed:
37
39
  self._session.close()
40
+ self._translation_session.close()
38
41
  self._is_session_closed = True
39
42
 
40
43
  @property
@@ -42,7 +45,13 @@ class Itunes:
42
45
  """Checks if the session is closed."""
43
46
  return self._is_session_closed
44
47
 
45
- def search(self, artist: str, song: str) -> Optional[MusicInfo]:
48
+ def search(
49
+ self,
50
+ artist: str,
51
+ song: str,
52
+ limit: int = 10,
53
+ normalize_non_english: bool = True,
54
+ ) -> Optional[MusicInfo]:
46
55
  """
47
56
  Searches for a song by artist and title.
48
57
 
@@ -52,6 +61,10 @@ class Itunes:
52
61
  The name of the artist.
53
62
  song : str
54
63
  The title of the song.
64
+ limit: int, optional
65
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``10``.
66
+ normalize_non_english : bool, optional
67
+ Whether to normalize non-English characters for comparison. Default is ``True``.
55
68
 
56
69
  Returns
57
70
  -------
@@ -63,10 +76,12 @@ class Itunes:
63
76
  "Artist and song names must be valid strings and can't be empty."
64
77
  )
65
78
 
79
+ self.normalize_non_english = normalize_non_english
80
+
66
81
  entities = ["song", "album"]
67
82
  for entity in entities:
68
83
  endpoint = f"{self.api_url}/search"
69
- query = f"?term={artist} - {song}&media=music&entity={entity}&limit=10"
84
+ query = f"?term={artist} - {song}&media=music&entity={entity}&limit={limit}"
70
85
  query_url = endpoint + query
71
86
 
72
87
  try:
@@ -111,9 +126,17 @@ class Itunes:
111
126
  for result in results:
112
127
  if not (
113
128
  are_strings_similar(
114
- result.get("trackName", result["collectionName"]), song
129
+ result.get("trackName", result["collectionName"]),
130
+ song,
131
+ use_translation=self.normalize_non_english,
132
+ translation_session=self._translation_session,
133
+ )
134
+ and are_strings_similar(
135
+ result["artistName"],
136
+ artist,
137
+ use_translation=self.normalize_non_english,
138
+ translation_session=self._translation_session,
115
139
  )
116
- and are_strings_similar(result["artistName"], artist)
117
140
  ):
118
141
  continue
119
142
 
@@ -57,6 +57,8 @@ class KKBox:
57
57
  self.__start_time = time.time()
58
58
  self._is_session_closed = False
59
59
  self.valid_territories = ["HK", "JP", "MY", "SG", "TW"]
60
+ self.normalize_non_english = True
61
+ self._translation_session = requests.Session()
60
62
 
61
63
  def __enter__(self):
62
64
  """Enters the runtime context related to this object."""
@@ -70,6 +72,7 @@ class KKBox:
70
72
  """Closes the current session."""
71
73
  if not self.is_session_closed:
72
74
  self._session.close()
75
+ self._translation_session.close()
73
76
  self._is_session_closed = True
74
77
 
75
78
  @property
@@ -134,7 +137,12 @@ class KKBox:
134
137
  self.__start_time = time.time()
135
138
 
136
139
  def search(
137
- self, artist: str, song: str, territory: str = "TW"
140
+ self,
141
+ artist: str,
142
+ song: str,
143
+ territory: str = "TW",
144
+ limit: int = 10,
145
+ normalize_non_english: bool = True,
138
146
  ) -> Optional[MusicInfo]:
139
147
  """
140
148
  Searches for a song by artist and title.
@@ -148,6 +156,10 @@ class KKBox:
148
156
  territory : str
149
157
  Two-letter country codes from ISO 3166-1 alpha-2.
150
158
  Allowed values: ``HK``, ``JP``, ``MY``, ``SG``, ``TW``.
159
+ limit: int, optional
160
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``10``.
161
+ normalize_non_english : bool, optional
162
+ Whether to normalize non-English characters for comparison. Default is ``True``.
151
163
 
152
164
  Returns
153
165
  -------
@@ -159,9 +171,13 @@ class KKBox:
159
171
  "Artist and song names must be valid strings and can't be empty."
160
172
  )
161
173
 
174
+ self.normalize_non_english = normalize_non_english
175
+
162
176
  self.__refresh_token_if_expired()
163
177
 
164
- query = f"?q={artist} - {song}&type=track,album&territory={territory}&limit=10"
178
+ query = (
179
+ f"?q={artist} - {song}&type=track,album&territory={territory}&limit={limit}"
180
+ )
165
181
  query_url = f"{self.api_url}/search{query}"
166
182
 
167
183
  try:
@@ -285,12 +301,24 @@ class KKBox:
285
301
  Optional[MusicInfo]
286
302
  The music information if found, otherwise None.
287
303
  """
288
- if not are_strings_similar(track["name"], song):
304
+ if not are_strings_similar(
305
+ track["name"],
306
+ song,
307
+ use_translation=self.normalize_non_english,
308
+ translation_session=self._translation_session,
309
+ ):
289
310
  return None
290
311
 
291
312
  artists_name = track["album"]["artist"]["name"]
292
313
  matching_artists = (
293
- artists_name if are_strings_similar(artists_name, artist) else None
314
+ artists_name
315
+ if are_strings_similar(
316
+ artists_name,
317
+ artist,
318
+ use_translation=self.normalize_non_english,
319
+ translation_session=self._translation_session,
320
+ )
321
+ else None
294
322
  )
295
323
 
296
324
  if matching_artists:
@@ -333,12 +361,24 @@ class KKBox:
333
361
  Optional[MusicInfo]
334
362
  The music information if found, otherwise None.
335
363
  """
336
- if not are_strings_similar(album["name"], song):
364
+ if not are_strings_similar(
365
+ album["name"],
366
+ song,
367
+ use_translation=self.normalize_non_english,
368
+ translation_session=self._translation_session,
369
+ ):
337
370
  return None
338
371
 
339
372
  artists_name = album["artist"]["name"]
340
373
  matching_artists = (
341
- artists_name if are_strings_similar(artists_name, artist) else None
374
+ artists_name
375
+ if are_strings_similar(
376
+ artists_name,
377
+ artist,
378
+ use_translation=self.normalize_non_english,
379
+ translation_session=self._translation_session,
380
+ )
381
+ else None
342
382
  )
343
383
 
344
384
  if matching_artists:
@@ -2,6 +2,7 @@ import os
2
2
  from pprint import pprint
3
3
  from typing import Optional
4
4
 
5
+ import requests
5
6
  from ytmusicapi import YTMusic, exceptions
6
7
 
7
8
  from yutipy.exceptions import (
@@ -19,8 +20,28 @@ class MusicYT:
19
20
  def __init__(self) -> None:
20
21
  """Initializes the YouTube Music class and sets up the session."""
21
22
  self.ytmusic = YTMusic()
22
-
23
- def search(self, artist: str, song: str) -> Optional[MusicInfo]:
23
+ self._is_session_closed = False
24
+ self.normalize_non_english = True
25
+ self._translation_session = requests.Session()
26
+
27
+ def close_session(self) -> None:
28
+ """Closes the current session(s)."""
29
+ if not self.is_session_closed:
30
+ self._translation_session.close()
31
+ self._is_session_closed = True
32
+
33
+ @property
34
+ def is_session_closed(self) -> bool:
35
+ """Checks if the session is closed."""
36
+ return self._is_session_closed
37
+
38
+ def search(
39
+ self,
40
+ artist: str,
41
+ song: str,
42
+ limit: int = 10,
43
+ normalize_non_english: bool = True,
44
+ ) -> Optional[MusicInfo]:
24
45
  """
25
46
  Searches for a song by artist and title.
26
47
 
@@ -30,6 +51,10 @@ class MusicYT:
30
51
  The name of the artist.
31
52
  song : str
32
53
  The title of the song.
54
+ limit: int, optional
55
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``10``.
56
+ normalize_non_english : bool, optional
57
+ Whether to normalize non-English characters for comparison. Default is ``True``.
33
58
 
34
59
  Returns
35
60
  -------
@@ -41,10 +66,12 @@ class MusicYT:
41
66
  "Artist and song names must be valid strings and can't be empty."
42
67
  )
43
68
 
69
+ self.normalize_non_english = normalize_non_english
70
+
44
71
  query = f"{artist} - {song}"
45
72
 
46
73
  try:
47
- results = self.ytmusic.search(query=query)
74
+ results = self.ytmusic.search(query=query, limit=limit)
48
75
  except exceptions.YTMusicServerError as e:
49
76
  raise NetworkException(f"Network error occurred: {e}")
50
77
 
@@ -76,8 +103,18 @@ class MusicYT:
76
103
  return False
77
104
 
78
105
  return any(
79
- are_strings_similar(result.get("title"), song)
80
- and are_strings_similar(_artist.get("name"), artist)
106
+ are_strings_similar(
107
+ result.get("title"),
108
+ song,
109
+ use_translation=self.normalize_non_english,
110
+ translation_session=self._translation_session,
111
+ )
112
+ and are_strings_similar(
113
+ _artist.get("name"),
114
+ artist,
115
+ use_translation=self.normalize_non_english,
116
+ translation_session=self._translation_session,
117
+ )
81
118
  for _artist in result.get("artists", [])
82
119
  )
83
120
 
@@ -62,6 +62,8 @@ class Spotify:
62
62
  self.__header, self.__expires_in = self.__authenticate()
63
63
  self.__start_time = time.time()
64
64
  self._is_session_closed = False
65
+ self.normalize_non_english = True
66
+ self._translation_session = requests.Session()
65
67
 
66
68
  def __enter__(self):
67
69
  """Enters the runtime context related to this object."""
@@ -72,9 +74,10 @@ class Spotify:
72
74
  self.close_session()
73
75
 
74
76
  def close_session(self) -> None:
75
- """Closes the current session."""
77
+ """Closes the current session(s)."""
76
78
  if not self.is_session_closed:
77
79
  self._session.close()
80
+ self._translation_session.close()
78
81
  self._is_session_closed = True
79
82
 
80
83
  @property
@@ -138,7 +141,13 @@ class Spotify:
138
141
  self.__header, self.__expires_in = self.__authenticate()
139
142
  self.__start_time = time.time()
140
143
 
141
- def search(self, artist: str, song: str) -> Optional[MusicInfo]:
144
+ def search(
145
+ self,
146
+ artist: str,
147
+ song: str,
148
+ limit: int = 10,
149
+ normalize_non_english: bool = True,
150
+ ) -> Optional[MusicInfo]:
142
151
  """
143
152
  Searches for a song by artist and title.
144
153
 
@@ -148,6 +157,10 @@ class Spotify:
148
157
  The name of the artist.
149
158
  song : str
150
159
  The title of the song.
160
+ limit: int, optional
161
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``10``.
162
+ normalize_non_english : bool, optional
163
+ Whether to normalize non-English characters for comparison. Default is ``True``.
151
164
 
152
165
  Returns
153
166
  -------
@@ -159,10 +172,12 @@ class Spotify:
159
172
  "Artist and song names must be valid strings and can't be empty."
160
173
  )
161
174
 
175
+ self.normalize_non_english = normalize_non_english
176
+
162
177
  music_info = None
163
178
  queries = [
164
- f"?q=artist:{artist} track:{song}&type=track&limit=10",
165
- f"?q=artist:{artist} album:{song}&type=album&limit=10",
179
+ f"?q=artist:{artist} track:{song}&type=track&limit={limit}",
180
+ f"?q=artist:{artist} album:{song}&type=album&limit={limit}",
166
181
  ]
167
182
 
168
183
  for query in queries:
@@ -192,7 +207,13 @@ class Spotify:
192
207
  return music_info
193
208
 
194
209
  def search_advanced(
195
- self, artist: str, song: str, isrc: str = None, upc: str = None
210
+ self,
211
+ artist: str,
212
+ song: str,
213
+ isrc: str = None,
214
+ upc: str = None,
215
+ limit: int = 1,
216
+ normalize_non_english: bool = True,
196
217
  ) -> Optional[MusicInfo]:
197
218
  """
198
219
  Searches for a song by artist, title, ISRC, or UPC.
@@ -207,6 +228,10 @@ class Spotify:
207
228
  The ISRC of the track.
208
229
  upc : str, optional
209
230
  The UPC of the album.
231
+ limit: int, optional
232
+ The number of items to retrieve from API. ``limit >=1 and <= 50``. Default is ``1``.
233
+ normalize_non_english : bool, optional
234
+ Whether to normalize non-English characters for comparison. Default is ``True``.
210
235
 
211
236
  Returns
212
237
  -------
@@ -218,12 +243,14 @@ class Spotify:
218
243
  "Artist and song names must be valid strings and can't be empty."
219
244
  )
220
245
 
246
+ self.normalize_non_english = normalize_non_english
247
+
221
248
  self.__refresh_token_if_expired()
222
249
 
223
250
  if isrc:
224
- query = f"?q={artist} {song} isrc:{isrc}&type=track&limit=1"
251
+ query = f"?q={artist} {song} isrc:{isrc}&type=track&limit={limit}"
225
252
  elif upc:
226
- query = f"?q={artist} {song} upc:{upc}&type=album&limit=1"
253
+ query = f"?q={artist} {song} upc:{upc}&type=album&limit={limit}"
227
254
  else:
228
255
  raise InvalidValueException("ISRC or UPC must be provided.")
229
256
 
@@ -337,14 +364,25 @@ class Spotify:
337
364
  Optional[MusicInfo]
338
365
  The music information if found, otherwise None.
339
366
  """
340
- if not are_strings_similar(track["name"], song):
367
+ if not are_strings_similar(
368
+ track["name"],
369
+ song,
370
+ use_translation=self.normalize_non_english,
371
+ translation_session=self._translation_session,
372
+ ):
341
373
  return None
342
374
 
343
375
  artists_name = [x["name"] for x in track["artists"]]
344
376
  matching_artists = [
345
377
  x["name"]
346
378
  for x in track["artists"]
347
- if are_strings_similar(x["name"], artist) or x["id"] in artist_ids
379
+ if are_strings_similar(
380
+ x["name"],
381
+ artist,
382
+ use_translation=self.normalize_non_english,
383
+ translation_session=self._translation_session,
384
+ )
385
+ or x["id"] in artist_ids
348
386
  ]
349
387
 
350
388
  if matching_artists:
@@ -389,14 +427,25 @@ class Spotify:
389
427
  Optional[MusicInfo]
390
428
  The music information if found, otherwise None.
391
429
  """
392
- if not are_strings_similar(album["name"], song):
430
+ if not are_strings_similar(
431
+ album["name"],
432
+ song,
433
+ use_translation=self.normalize_non_english,
434
+ translation_session=self._translation_session,
435
+ ):
393
436
  return None
394
437
 
395
438
  artists_name = [x["name"] for x in album["artists"]]
396
439
  matching_artists = [
397
440
  x["name"]
398
441
  for x in album["artists"]
399
- if are_strings_similar(x["name"], artist) or x["id"] in artist_ids
442
+ if are_strings_similar(
443
+ x["name"],
444
+ artist,
445
+ use_translation=self.normalize_non_english,
446
+ translation_session=self._translation_session,
447
+ )
448
+ or x["id"] in artist_ids
400
449
  ]
401
450
 
402
451
  if matching_artists:
@@ -7,6 +7,7 @@ def translate_text(
7
7
  text: str,
8
8
  sl: str = None,
9
9
  dl: str = "en",
10
+ session: requests.Session = None,
10
11
  ) -> dict:
11
12
  """
12
13
  Translate text from one language to another.
@@ -15,7 +16,8 @@ def translate_text(
15
16
  text (str): The text to be translated.
16
17
  sl (str, optional): The source language code (e.g., 'en' for English, 'es' for Spanish). If not provided, the API will attempt to detect the source language.
17
18
  dl (str, optional): The destination language code (default is 'en' for English).
18
-
19
+ session (requests.Session, optional): A `requests.Session` object to use for making the API request. If not provided, a new session will be created and closed within the function.
20
+ Providing your own session can improve performance by reusing the same session for multiple requests. Don't forget to close the session afterwards.
19
21
 
20
22
  Returns:
21
23
  dict: A dictionary containing the following keys:
@@ -24,12 +26,17 @@ def translate_text(
24
26
  - 'destination-text': The translated text.
25
27
  - 'destination-language': The destination language code.
26
28
  """
29
+ default_session = False
30
+ if session is None:
31
+ default_session = True
32
+ session = requests.Session()
33
+
27
34
  if sl:
28
35
  url = f"https://ftapi.pythonanywhere.com/translate?sl={sl}&dl={dl}&text={text}"
29
36
  else:
30
37
  url = f"https://ftapi.pythonanywhere.com/translate?dl={dl}&text={text}"
31
38
 
32
- response = requests.get(url)
39
+ response = session.get(url)
33
40
  response_json = response.json()
34
41
  result = {
35
42
  "source-text": response_json["source-text"],
@@ -37,10 +44,20 @@ def translate_text(
37
44
  "destination-text": response_json["destination-text"],
38
45
  "destination-language": response_json["destination-language"],
39
46
  }
47
+
48
+ if default_session:
49
+ session.close()
50
+
40
51
  return result
41
52
 
42
53
 
43
- def are_strings_similar(str1: str, str2: str, threshold: int = 80) -> bool:
54
+ def are_strings_similar(
55
+ str1: str,
56
+ str2: str,
57
+ threshold: int = 80,
58
+ use_translation: bool = True,
59
+ translation_session: requests.Session = None,
60
+ ) -> bool:
44
61
  """
45
62
  Determine if two strings are similar based on a given threshold.
46
63
 
@@ -48,12 +65,24 @@ def are_strings_similar(str1: str, str2: str, threshold: int = 80) -> bool:
48
65
  str1 (str): First string to compare.
49
66
  str2 (str): Second string to compare.
50
67
  threshold (int, optional): Similarity threshold. Defaults to 80.
68
+ use_translation (bool, optional): Use translations to compare strings. Defaults to ``True``
69
+ translation_session (requests.Session, optional): A `requests.Session` object to use for making the API request. If not provided, a new session will be created and closed within the function.
70
+ Providing your own session can improve performance by reusing the same session for multiple requests. Don't forget to close the session afterwards.
51
71
 
52
72
  Returns:
53
73
  bool: True if the strings are similar, otherwise False.
54
74
  """
55
- str1 = translate_text(str1)["destination-text"]
56
- str2 = translate_text(str2)["destination-text"]
75
+ if use_translation:
76
+ str1 = (
77
+ translate_text(str1, session=translation_session)["destination-text"]
78
+ if translation_session
79
+ else translate_text(str1)["destination-text"]
80
+ )
81
+ str2 = (
82
+ translate_text(str2, session=translation_session)["destination-text"]
83
+ if translation_session
84
+ else translate_text(str2)["destination-text"]
85
+ )
57
86
 
58
87
  similarity_score = fuzz.WRatio(str1, str2, processor=default_process)
59
88
  return similarity_score > threshold
@@ -0,0 +1,4 @@
1
+ import logging
2
+
3
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
4
+ logger = logging.getLogger(__name__)
@@ -2,14 +2,17 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
2
2
  from pprint import pprint
3
3
  from typing import Optional
4
4
 
5
+ import requests
6
+
5
7
  from yutipy.deezer import Deezer
6
- from yutipy.exceptions import InvalidValueException
8
+ from yutipy.exceptions import InvalidValueException, KKBoxException, SpotifyException
7
9
  from yutipy.itunes import Itunes
8
10
  from yutipy.kkbox import KKBox
9
11
  from yutipy.models import MusicInfo, MusicInfos
10
12
  from yutipy.musicyt import MusicYT
11
13
  from yutipy.spotify import Spotify
12
14
  from yutipy.utils.cheap_utils import is_valid_string
15
+ from yutipy.utils.logger import logger
13
16
 
14
17
 
15
18
  class YutipyMusic:
@@ -22,9 +25,53 @@ class YutipyMusic:
22
25
  def __init__(self) -> None:
23
26
  """Initializes the YutipyMusic class."""
24
27
  self.music_info = MusicInfos()
25
- self.album_art_priority = ["deezer", "kkbox", "spotify", "musicyt", "itunes"]
26
-
27
- def search(self, artist: str, song: str) -> Optional[MusicInfos]:
28
+ self.album_art_priority = ["deezer", "ytmusic", "itunes"]
29
+ self.services = {
30
+ "deezer": Deezer(),
31
+ "itunes": Itunes(),
32
+ "ytmusic": MusicYT(),
33
+ }
34
+
35
+ try:
36
+ self.services["kkbox"] = KKBox()
37
+ except KKBoxException as e:
38
+ logger.warning(
39
+ f"{self.__class__.__name__}: Skipping KKBox due to KKBoxException: {e}"
40
+ )
41
+ else:
42
+ idx = self.album_art_priority.index("ytmusic")
43
+ self.album_art_priority.insert(idx, "kkbox")
44
+
45
+ try:
46
+ self.services["spotify"] = Spotify()
47
+ except SpotifyException as e:
48
+ logger.warning(
49
+ f"{self.__class__.__name__}: Skipping Spotify due to SpotifyException: {e}"
50
+ )
51
+ else:
52
+ idx = self.album_art_priority.index("ytmusic")
53
+ self.album_art_priority.insert(idx, "spotify")
54
+ self.normalize_non_english = True
55
+ self._translation_session = requests.Session()
56
+
57
+ # Assign the translation session to each service
58
+ for service in self.services.values():
59
+ if hasattr(service, "_translation_session"):
60
+ service._translation_session = self._translation_session
61
+
62
+ def __enter__(self) -> "YutipyMusic":
63
+ return self
64
+
65
+ def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
66
+ self.close_sessions()
67
+
68
+ def search(
69
+ self,
70
+ artist: str,
71
+ song: str,
72
+ limit: int = 5,
73
+ normalize_non_english: bool = True,
74
+ ) -> Optional[MusicInfos]:
28
75
  """
29
76
  Searches for a song by artist and title.
30
77
 
@@ -34,6 +81,10 @@ class YutipyMusic:
34
81
  The name of the artist.
35
82
  song : str
36
83
  The title of the song.
84
+ limit: int, optional
85
+ The number of items to retrieve from all APIs. ``limit >=1 and <= 50``. Default is ``5``.
86
+ normalize_non_english : bool, optional
87
+ Whether to normalize non-English characters for comparison. Default is ``True``.
37
88
 
38
89
  Returns
39
90
  -------
@@ -45,18 +96,18 @@ class YutipyMusic:
45
96
  "Artist and song names must be valid strings and can't be empty."
46
97
  )
47
98
 
48
- services = [
49
- (Deezer, "deezer"),
50
- (Itunes, "itunes"),
51
- (KKBox, "kkbox"),
52
- (MusicYT, "musicyt"),
53
- (Spotify, "spotify"),
54
- ]
99
+ self.normalize_non_english = normalize_non_english
55
100
 
56
101
  with ThreadPoolExecutor() as executor:
57
102
  futures = {
58
- executor.submit(service().search, artist, song): name
59
- for service, name in services
103
+ executor.submit(
104
+ service.search,
105
+ artist=artist,
106
+ song=song,
107
+ limit=limit,
108
+ normalize_non_english=self.normalize_non_english,
109
+ ): name
110
+ for name, service in self.services.items()
60
111
  }
61
112
 
62
113
  for future in as_completed(futures):
@@ -117,9 +168,16 @@ class YutipyMusic:
117
168
  self.music_info.id[service_name] = result.id
118
169
  self.music_info.url[service_name] = result.url
119
170
 
171
+ def close_sessions(self) -> None:
172
+ """Closes the sessions for all services."""
173
+ for service in self.services.values():
174
+ if hasattr(service, "close_session"):
175
+ service.close_session()
176
+
120
177
 
121
178
  if __name__ == "__main__":
122
179
  yutipy_music = YutipyMusic()
123
180
  artist_name = input("Artist Name: ")
124
181
  song_name = input("Song Name: ")
125
182
  pprint(yutipy_music.search(artist_name, song_name))
183
+ yutipy_music.close_sessions()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yutipy
3
- Version: 1.3.1
3
+ Version: 1.4.0
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>
@@ -8,6 +8,7 @@ pyproject.toml
8
8
  requirements-dev.txt
9
9
  requirements.txt
10
10
  .github/FUNDING.yml
11
+ .github/release-drafter.yml
11
12
  .github/ISSUE_TEMPLATE/bug_report.md
12
13
  .github/ISSUE_TEMPLATE/feature_request.md
13
14
  .github/workflows/pytest-unit-testing.yml
@@ -32,6 +33,7 @@ tests/test_models.py
32
33
  tests/test_musicyt.py
33
34
  tests/test_spotify.py
34
35
  tests/test_utils.py
36
+ tests/test_yutipy_music.py
35
37
  yutipy/__init__.py
36
38
  yutipy/deezer.py
37
39
  yutipy/exceptions.py
@@ -47,4 +49,5 @@ yutipy.egg-info/dependency_links.txt
47
49
  yutipy.egg-info/requires.txt
48
50
  yutipy.egg-info/top_level.txt
49
51
  yutipy/utils/__init__.py
50
- yutipy/utils/cheap_utils.py
52
+ yutipy/utils/cheap_utils.py
53
+ yutipy/utils/logger.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes