yutipy 1.3.2__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.
- yutipy-1.4.0/.github/release-drafter.yml +16 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.github/workflows/release.yml +19 -3
- {yutipy-1.3.2 → yutipy-1.4.0}/PKG-INFO +1 -1
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_deezer.py +7 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_musicyt.py +5 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_utils.py +8 -1
- yutipy-1.4.0/tests/test_yutipy_music.py +48 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/deezer.py +28 -5
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/itunes.py +25 -5
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/kkbox.py +45 -8
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/musicyt.py +40 -6
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/spotify.py +57 -11
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/utils/cheap_utils.py +34 -5
- yutipy-1.4.0/yutipy/utils/logger.py +4 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/yutipy_music.py +49 -8
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy.egg-info/PKG-INFO +1 -1
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy.egg-info/SOURCES.txt +4 -1
- {yutipy-1.3.2 → yutipy-1.4.0}/.gitattributes +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.github/FUNDING.yml +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.github/workflows/pytest-unit-testing.yml +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.gitignore +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/.readthedocs.yaml +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/LICENSE +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/MANIFEST.in +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/README.md +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/Makefile +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/_static/yutipy_header.png +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/_static/yutipy_logo.png +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/api_reference.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/available_platforms.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/conf.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/faq.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/index.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/installation.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/make.bat +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/requirements.txt +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/docs/usage_examples.rst +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/pyproject.toml +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/requirements-dev.txt +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/requirements.txt +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/setup.cfg +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/__init__.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_itunes.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_kkbox.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_models.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/tests/test_spotify.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/__init__.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/exceptions.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/models.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy/utils/__init__.py +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy.egg-info/dependency_links.txt +0 -0
- {yutipy-1.3.2 → yutipy-1.4.0}/yutipy.egg-info/requires.txt +0 -0
- {yutipy-1.3.2 → 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
|
|
1
|
+
name: Publish Python 🐍 distribution 📦 to PyPI
|
|
2
2
|
|
|
3
|
-
on:
|
|
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:
|
|
@@ -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")
|
|
@@ -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=",") == [
|
|
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(
|
|
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
|
|
|
@@ -52,8 +61,10 @@ class Deezer:
|
|
|
52
61
|
song : str
|
|
53
62
|
The title of the song.
|
|
54
63
|
limit: int, optional
|
|
55
|
-
The number of items to retrieve from API.
|
|
56
|
-
|
|
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
|
+
|
|
57
68
|
|
|
58
69
|
Returns
|
|
59
70
|
-------
|
|
@@ -65,6 +76,8 @@ class Deezer:
|
|
|
65
76
|
"Artist and song names must be valid strings and can't be empty."
|
|
66
77
|
)
|
|
67
78
|
|
|
79
|
+
self.normalize_non_english = normalize_non_english
|
|
80
|
+
|
|
68
81
|
search_types = ["track", "album"]
|
|
69
82
|
|
|
70
83
|
for search_type in search_types:
|
|
@@ -208,8 +221,18 @@ class Deezer:
|
|
|
208
221
|
"""
|
|
209
222
|
for result in results:
|
|
210
223
|
if not (
|
|
211
|
-
are_strings_similar(
|
|
212
|
-
|
|
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
|
+
)
|
|
213
236
|
):
|
|
214
237
|
continue
|
|
215
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(
|
|
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
|
|
|
@@ -53,8 +62,9 @@ class Itunes:
|
|
|
53
62
|
song : str
|
|
54
63
|
The title of the song.
|
|
55
64
|
limit: int, optional
|
|
56
|
-
|
|
57
|
-
|
|
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``.
|
|
58
68
|
|
|
59
69
|
Returns
|
|
60
70
|
-------
|
|
@@ -66,6 +76,8 @@ class Itunes:
|
|
|
66
76
|
"Artist and song names must be valid strings and can't be empty."
|
|
67
77
|
)
|
|
68
78
|
|
|
79
|
+
self.normalize_non_english = normalize_non_english
|
|
80
|
+
|
|
69
81
|
entities = ["song", "album"]
|
|
70
82
|
for entity in entities:
|
|
71
83
|
endpoint = f"{self.api_url}/search"
|
|
@@ -114,9 +126,17 @@ class Itunes:
|
|
|
114
126
|
for result in results:
|
|
115
127
|
if not (
|
|
116
128
|
are_strings_similar(
|
|
117
|
-
result.get("trackName", result["collectionName"]),
|
|
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,
|
|
118
139
|
)
|
|
119
|
-
and are_strings_similar(result["artistName"], artist)
|
|
120
140
|
):
|
|
121
141
|
continue
|
|
122
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,
|
|
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.
|
|
@@ -149,8 +157,9 @@ class KKBox:
|
|
|
149
157
|
Two-letter country codes from ISO 3166-1 alpha-2.
|
|
150
158
|
Allowed values: ``HK``, ``JP``, ``MY``, ``SG``, ``TW``.
|
|
151
159
|
limit: int, optional
|
|
152
|
-
The number of items to retrieve from API.
|
|
153
|
-
|
|
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``.
|
|
154
163
|
|
|
155
164
|
Returns
|
|
156
165
|
-------
|
|
@@ -162,9 +171,13 @@ class KKBox:
|
|
|
162
171
|
"Artist and song names must be valid strings and can't be empty."
|
|
163
172
|
)
|
|
164
173
|
|
|
174
|
+
self.normalize_non_english = normalize_non_english
|
|
175
|
+
|
|
165
176
|
self.__refresh_token_if_expired()
|
|
166
177
|
|
|
167
|
-
query =
|
|
178
|
+
query = (
|
|
179
|
+
f"?q={artist} - {song}&type=track,album&territory={territory}&limit={limit}"
|
|
180
|
+
)
|
|
168
181
|
query_url = f"{self.api_url}/search{query}"
|
|
169
182
|
|
|
170
183
|
try:
|
|
@@ -288,12 +301,24 @@ class KKBox:
|
|
|
288
301
|
Optional[MusicInfo]
|
|
289
302
|
The music information if found, otherwise None.
|
|
290
303
|
"""
|
|
291
|
-
if not are_strings_similar(
|
|
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
|
+
):
|
|
292
310
|
return None
|
|
293
311
|
|
|
294
312
|
artists_name = track["album"]["artist"]["name"]
|
|
295
313
|
matching_artists = (
|
|
296
|
-
artists_name
|
|
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
|
|
297
322
|
)
|
|
298
323
|
|
|
299
324
|
if matching_artists:
|
|
@@ -336,12 +361,24 @@ class KKBox:
|
|
|
336
361
|
Optional[MusicInfo]
|
|
337
362
|
The music information if found, otherwise None.
|
|
338
363
|
"""
|
|
339
|
-
if not are_strings_similar(
|
|
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
|
+
):
|
|
340
370
|
return None
|
|
341
371
|
|
|
342
372
|
artists_name = album["artist"]["name"]
|
|
343
373
|
matching_artists = (
|
|
344
|
-
artists_name
|
|
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
|
|
345
382
|
)
|
|
346
383
|
|
|
347
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
|
-
|
|
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
|
|
|
@@ -31,8 +52,9 @@ class MusicYT:
|
|
|
31
52
|
song : str
|
|
32
53
|
The title of the song.
|
|
33
54
|
limit: int, optional
|
|
34
|
-
The number of items to retrieve from API.
|
|
35
|
-
|
|
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``.
|
|
36
58
|
|
|
37
59
|
Returns
|
|
38
60
|
-------
|
|
@@ -44,6 +66,8 @@ class MusicYT:
|
|
|
44
66
|
"Artist and song names must be valid strings and can't be empty."
|
|
45
67
|
)
|
|
46
68
|
|
|
69
|
+
self.normalize_non_english = normalize_non_english
|
|
70
|
+
|
|
47
71
|
query = f"{artist} - {song}"
|
|
48
72
|
|
|
49
73
|
try:
|
|
@@ -79,8 +103,18 @@ class MusicYT:
|
|
|
79
103
|
return False
|
|
80
104
|
|
|
81
105
|
return any(
|
|
82
|
-
are_strings_similar(
|
|
83
|
-
|
|
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
|
+
)
|
|
84
118
|
for _artist in result.get("artists", [])
|
|
85
119
|
)
|
|
86
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(
|
|
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
|
|
|
@@ -149,8 +158,9 @@ class Spotify:
|
|
|
149
158
|
song : str
|
|
150
159
|
The title of the song.
|
|
151
160
|
limit: int, optional
|
|
152
|
-
The number of items to retrieve from API.
|
|
153
|
-
|
|
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``.
|
|
154
164
|
|
|
155
165
|
Returns
|
|
156
166
|
-------
|
|
@@ -162,6 +172,8 @@ class Spotify:
|
|
|
162
172
|
"Artist and song names must be valid strings and can't be empty."
|
|
163
173
|
)
|
|
164
174
|
|
|
175
|
+
self.normalize_non_english = normalize_non_english
|
|
176
|
+
|
|
165
177
|
music_info = None
|
|
166
178
|
queries = [
|
|
167
179
|
f"?q=artist:{artist} track:{song}&type=track&limit={limit}",
|
|
@@ -195,7 +207,13 @@ class Spotify:
|
|
|
195
207
|
return music_info
|
|
196
208
|
|
|
197
209
|
def search_advanced(
|
|
198
|
-
self,
|
|
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,
|
|
199
217
|
) -> Optional[MusicInfo]:
|
|
200
218
|
"""
|
|
201
219
|
Searches for a song by artist, title, ISRC, or UPC.
|
|
@@ -210,6 +228,10 @@ class Spotify:
|
|
|
210
228
|
The ISRC of the track.
|
|
211
229
|
upc : str, optional
|
|
212
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``.
|
|
213
235
|
|
|
214
236
|
Returns
|
|
215
237
|
-------
|
|
@@ -221,12 +243,14 @@ class Spotify:
|
|
|
221
243
|
"Artist and song names must be valid strings and can't be empty."
|
|
222
244
|
)
|
|
223
245
|
|
|
246
|
+
self.normalize_non_english = normalize_non_english
|
|
247
|
+
|
|
224
248
|
self.__refresh_token_if_expired()
|
|
225
249
|
|
|
226
250
|
if isrc:
|
|
227
|
-
query = f"?q={artist} {song} isrc:{isrc}&type=track&limit=
|
|
251
|
+
query = f"?q={artist} {song} isrc:{isrc}&type=track&limit={limit}"
|
|
228
252
|
elif upc:
|
|
229
|
-
query = f"?q={artist} {song} upc:{upc}&type=album&limit=
|
|
253
|
+
query = f"?q={artist} {song} upc:{upc}&type=album&limit={limit}"
|
|
230
254
|
else:
|
|
231
255
|
raise InvalidValueException("ISRC or UPC must be provided.")
|
|
232
256
|
|
|
@@ -340,14 +364,25 @@ class Spotify:
|
|
|
340
364
|
Optional[MusicInfo]
|
|
341
365
|
The music information if found, otherwise None.
|
|
342
366
|
"""
|
|
343
|
-
if not are_strings_similar(
|
|
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
|
+
):
|
|
344
373
|
return None
|
|
345
374
|
|
|
346
375
|
artists_name = [x["name"] for x in track["artists"]]
|
|
347
376
|
matching_artists = [
|
|
348
377
|
x["name"]
|
|
349
378
|
for x in track["artists"]
|
|
350
|
-
if are_strings_similar(
|
|
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
|
|
351
386
|
]
|
|
352
387
|
|
|
353
388
|
if matching_artists:
|
|
@@ -392,14 +427,25 @@ class Spotify:
|
|
|
392
427
|
Optional[MusicInfo]
|
|
393
428
|
The music information if found, otherwise None.
|
|
394
429
|
"""
|
|
395
|
-
if not are_strings_similar(
|
|
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
|
+
):
|
|
396
436
|
return None
|
|
397
437
|
|
|
398
438
|
artists_name = [x["name"] for x in album["artists"]]
|
|
399
439
|
matching_artists = [
|
|
400
440
|
x["name"]
|
|
401
441
|
for x in album["artists"]
|
|
402
|
-
if are_strings_similar(
|
|
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
|
|
403
449
|
]
|
|
404
450
|
|
|
405
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 =
|
|
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(
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
@@ -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,22 +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", "
|
|
28
|
+
self.album_art_priority = ["deezer", "ytmusic", "itunes"]
|
|
26
29
|
self.services = {
|
|
27
30
|
"deezer": Deezer(),
|
|
28
31
|
"itunes": Itunes(),
|
|
29
|
-
"kkbox": KKBox(),
|
|
30
32
|
"ytmusic": MusicYT(),
|
|
31
|
-
"spotify": Spotify(),
|
|
32
33
|
}
|
|
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
|
+
|
|
34
62
|
def __enter__(self) -> "YutipyMusic":
|
|
35
63
|
return self
|
|
36
64
|
|
|
37
65
|
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
|
38
66
|
self.close_sessions()
|
|
39
67
|
|
|
40
|
-
def search(
|
|
68
|
+
def search(
|
|
69
|
+
self,
|
|
70
|
+
artist: str,
|
|
71
|
+
song: str,
|
|
72
|
+
limit: int = 5,
|
|
73
|
+
normalize_non_english: bool = True,
|
|
74
|
+
) -> Optional[MusicInfos]:
|
|
41
75
|
"""
|
|
42
76
|
Searches for a song by artist and title.
|
|
43
77
|
|
|
@@ -48,8 +82,9 @@ class YutipyMusic:
|
|
|
48
82
|
song : str
|
|
49
83
|
The title of the song.
|
|
50
84
|
limit: int, optional
|
|
51
|
-
The number of items to retrieve from all APIs.
|
|
52
|
-
|
|
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``.
|
|
53
88
|
|
|
54
89
|
Returns
|
|
55
90
|
-------
|
|
@@ -61,10 +96,16 @@ class YutipyMusic:
|
|
|
61
96
|
"Artist and song names must be valid strings and can't be empty."
|
|
62
97
|
)
|
|
63
98
|
|
|
99
|
+
self.normalize_non_english = normalize_non_english
|
|
100
|
+
|
|
64
101
|
with ThreadPoolExecutor() as executor:
|
|
65
102
|
futures = {
|
|
66
103
|
executor.submit(
|
|
67
|
-
service.search,
|
|
104
|
+
service.search,
|
|
105
|
+
artist=artist,
|
|
106
|
+
song=song,
|
|
107
|
+
limit=limit,
|
|
108
|
+
normalize_non_english=self.normalize_non_english,
|
|
68
109
|
): name
|
|
69
110
|
for name, service in self.services.items()
|
|
70
111
|
}
|
|
@@ -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
|
|
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
|