spotapi 1.2.7__tar.gz → 1.2.8__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.
Files changed (46) hide show
  1. {spotapi-1.2.7/spotapi.egg-info → spotapi-1.2.8}/PKG-INFO +44 -20
  2. {spotapi-1.2.7 → spotapi-1.2.8}/README.md +21 -12
  3. {spotapi-1.2.7 → spotapi-1.2.8}/setup.py +2 -2
  4. spotapi-1.2.8/spotapi/_tests/client_refresh_test.py +223 -0
  5. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/album.py +3 -2
  6. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/artist.py +109 -2
  7. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/client.py +48 -5
  8. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/data.py +1 -1
  9. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/request.py +60 -40
  10. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/player.py +1 -1
  11. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/playlist.py +63 -3
  12. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/podcast.py +3 -2
  13. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/public.py +1 -1
  14. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/song.py +5 -4
  15. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/data.py +1 -1
  16. {spotapi-1.2.7 → spotapi-1.2.8/spotapi.egg-info}/PKG-INFO +44 -20
  17. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/SOURCES.txt +1 -0
  18. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/requires.txt +1 -1
  19. {spotapi-1.2.7 → spotapi-1.2.8}/LICENSE +0 -0
  20. {spotapi-1.2.7 → spotapi-1.2.8}/setup.cfg +0 -0
  21. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/__init__.py +0 -0
  22. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/_tests/__init__.py +0 -0
  23. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/_tests/annotations_test.py +0 -0
  24. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/creator.py +0 -0
  25. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/exceptions/__init__.py +0 -0
  26. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/exceptions/errors.py +0 -0
  27. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/family.py +0 -0
  28. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/__init__.py +0 -0
  29. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/login.py +0 -0
  30. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/password.py +0 -0
  31. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/__init__.py +0 -0
  32. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/capmonster.py +0 -0
  33. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/capsolver.py +0 -0
  34. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/status.py +0 -0
  35. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/__init__.py +0 -0
  36. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/alias.py +0 -0
  37. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/annotations.py +0 -0
  38. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/interfaces.py +0 -0
  39. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/user.py +0 -0
  40. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/__init__.py +0 -0
  41. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/logger.py +0 -0
  42. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/saver.py +0 -0
  43. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/strings.py +0 -0
  44. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/websocket.py +0 -0
  45. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/dependency_links.txt +0 -0
  46. {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/top_level.txt +0 -0
@@ -1,17 +1,34 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: spotapi
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Summary: A sleek API wrapper for Spotify's private API
5
- Home-page: UNKNOWN
6
5
  Author: Aran
7
- License: UNKNOWN
8
6
  Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
- Platform: UNKNOWN
10
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: requests
10
+ Requires-Dist: colorama
11
+ Requires-Dist: Pillow
12
+ Requires-Dist: readerwriterlock
13
+ Requires-Dist: curl_cffi
14
+ Requires-Dist: typing_extensions
15
+ Requires-Dist: validators
16
+ Requires-Dist: pyotp
17
+ Requires-Dist: beautifulsoup4
11
18
  Provides-Extra: websocket
19
+ Requires-Dist: websockets; extra == "websocket"
12
20
  Provides-Extra: redis
21
+ Requires-Dist: redis; extra == "redis"
13
22
  Provides-Extra: pymongo
14
- License-File: LICENSE
23
+ Requires-Dist: pymongo; extra == "pymongo"
24
+ Dynamic: author
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: keywords
28
+ Dynamic: license-file
29
+ Dynamic: provides-extra
30
+ Dynamic: requires-dist
31
+ Dynamic: summary
15
32
 
16
33
  # Legal Notice
17
34
 
@@ -32,8 +49,8 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
32
49
  5. [Quick Examples](#quick-examples)
33
50
  6. [Import Cookies](#import-cookies)
34
51
  7. [Contributing](#contributing)
35
- 8. [Roadmap](#roadmap)
36
- 9. [License](#license)
52
+ 8. [License](#license)
53
+ 9. **[Extended Functionality & Private Modules](#private-modules)**
37
54
 
38
55
 
39
56
  ## Features
@@ -41,8 +58,9 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
41
58
  - **Public API Access**: Retrieve and manipulate public Spotify data such as playlists, albums, and tracks with ease.
42
59
  - **Private API Access**: Explore private Spotify endpoints to tailor your application to your needs.
43
60
  - **Ready to Use**: **SpotAPI** is designed for immediate integration, allowing you to accomplish tasks with just a few lines of code.
44
- - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. It’s straightforward and hassle free!
61
+ - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. It's straightforward and hassle free!
45
62
  - **Browser-like Requests**: Accurately replicate the HTTP requests Spotify makes in the browser, providing a true to web experience while remaining undetected.
63
+ - **Multi-Language Support**: Set your preferred language for API responses using ISO 639-1 language codes (e.g., 'ko', 'ja', 'zh', 'en').
46
64
 
47
65
  Everything you can do with Spotify, **SpotAPI** can do with just a user’s login credentials.
48
66
 
@@ -119,6 +137,21 @@ data = songs["data"]["searchV2"]["tracksV2"]["items"]
119
137
  for idx, item in enumerate(data):
120
138
  print(idx, item['item']['data']['name'])
121
139
  ```
140
+
141
+ ### With Language Support
142
+ ```py
143
+ from spotapi import Artist, PublicPlaylist, Song
144
+
145
+ # Initialize with Korean language
146
+ artist = Artist(language="ko")
147
+ playlist = PublicPlaylist("37i9dQZF1DXcBWIGoYBM5M", language="ko")
148
+
149
+ # Change language at runtime
150
+ song = Song(language="en")
151
+ song.base.set_language("ja") # Switch to Japanese
152
+
153
+ # Supported languages: ko, ja, zh, en, and any ISO 639-1 code
154
+ ```
122
155
  ### Results
123
156
  ```
124
157
  0 Island In The Sun
@@ -152,17 +185,8 @@ If you prefer not to use a third party CAPTCHA solver, you can import cookies to
152
185
  ## Contributing
153
186
  Contributions are welcome! If you find any issues or have suggestions, please open an issue or submit a pull request.
154
187
 
155
- ## Roadmap
156
- > I'll most likely do these if the project gains some traction
157
-
158
- - [ ] No Captcha For Login (**100 Stars**)
159
- - [x] In Depth Documentation
160
- - [x] Websocket Listener
161
- - [x] Player
162
- - [ ] More wrappers around this project
163
-
164
188
  ## License
165
189
  This project is licensed under the **GPL 3.0** License. See [LICENSE](https://choosealicense.com/licenses/gpl-3.0/) for details.
166
190
 
167
-
168
-
191
+ ## **Private Modules**
192
+ For the full scale account generation and "engagement" modules that can't be posted here, reach out on Telegram: **@aran_xyz**
@@ -17,8 +17,8 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
17
17
  5. [Quick Examples](#quick-examples)
18
18
  6. [Import Cookies](#import-cookies)
19
19
  7. [Contributing](#contributing)
20
- 8. [Roadmap](#roadmap)
21
- 9. [License](#license)
20
+ 8. [License](#license)
21
+ 9. **[Extended Functionality & Private Modules](#private-modules)**
22
22
 
23
23
 
24
24
  ## Features
@@ -26,8 +26,9 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
26
26
  - **Public API Access**: Retrieve and manipulate public Spotify data such as playlists, albums, and tracks with ease.
27
27
  - **Private API Access**: Explore private Spotify endpoints to tailor your application to your needs.
28
28
  - **Ready to Use**: **SpotAPI** is designed for immediate integration, allowing you to accomplish tasks with just a few lines of code.
29
- - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. Its straightforward and hassle free!
29
+ - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. It's straightforward and hassle free!
30
30
  - **Browser-like Requests**: Accurately replicate the HTTP requests Spotify makes in the browser, providing a true to web experience while remaining undetected.
31
+ - **Multi-Language Support**: Set your preferred language for API responses using ISO 639-1 language codes (e.g., 'ko', 'ja', 'zh', 'en').
31
32
 
32
33
  Everything you can do with Spotify, **SpotAPI** can do with just a user’s login credentials.
33
34
 
@@ -104,6 +105,21 @@ data = songs["data"]["searchV2"]["tracksV2"]["items"]
104
105
  for idx, item in enumerate(data):
105
106
  print(idx, item['item']['data']['name'])
106
107
  ```
108
+
109
+ ### With Language Support
110
+ ```py
111
+ from spotapi import Artist, PublicPlaylist, Song
112
+
113
+ # Initialize with Korean language
114
+ artist = Artist(language="ko")
115
+ playlist = PublicPlaylist("37i9dQZF1DXcBWIGoYBM5M", language="ko")
116
+
117
+ # Change language at runtime
118
+ song = Song(language="en")
119
+ song.base.set_language("ja") # Switch to Japanese
120
+
121
+ # Supported languages: ko, ja, zh, en, and any ISO 639-1 code
122
+ ```
107
123
  ### Results
108
124
  ```
109
125
  0 Island In The Sun
@@ -137,15 +153,8 @@ If you prefer not to use a third party CAPTCHA solver, you can import cookies to
137
153
  ## Contributing
138
154
  Contributions are welcome! If you find any issues or have suggestions, please open an issue or submit a pull request.
139
155
 
140
- ## Roadmap
141
- > I'll most likely do these if the project gains some traction
142
-
143
- - [ ] No Captcha For Login (**100 Stars**)
144
- - [x] In Depth Documentation
145
- - [x] Websocket Listener
146
- - [x] Player
147
- - [ ] More wrappers around this project
148
-
149
156
  ## License
150
157
  This project is licensed under the **GPL 3.0** License. See [LICENSE](https://choosealicense.com/licenses/gpl-3.0/) for details.
151
158
 
159
+ ## **Private Modules**
160
+ For the full scale account generation and "engagement" modules that can't be posted here, reach out on Telegram: **@aran_xyz**
@@ -6,7 +6,7 @@ __install_require__ = [
6
6
  "colorama",
7
7
  "Pillow",
8
8
  "readerwriterlock",
9
- "tls_client",
9
+ "curl_cffi",
10
10
  "typing_extensions",
11
11
  "validators",
12
12
  "pyotp",
@@ -57,5 +57,5 @@ setup(
57
57
  ],
58
58
  long_description=long_description,
59
59
  long_description_content_type="text/markdown",
60
- version="1.2.7",
60
+ version="1.2.8",
61
61
  )
@@ -0,0 +1,223 @@
1
+ # type: ignore
2
+ """Unit tests for the hybrid token refresh flow in BaseClient / TLSClient.
3
+
4
+ These tests do not hit the network: the HTTP layer is patched so the
5
+ refresh/retry decision logic can be exercised in isolation.
6
+ """
7
+ import time
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+ from spotapi.client import BaseClient
13
+ from spotapi.http.data import Response
14
+ from spotapi.http.request import TLSClient
15
+ from spotapi.types.alias import _Undefined
16
+
17
+
18
+ def _make_base():
19
+ """A BaseClient whose init-time network methods are stubbed out."""
20
+ client = TLSClient("chrome_120", "", auto_retries=1)
21
+ base = BaseClient(client=client)
22
+ # Pre-populate so _auth_rule does not fall into the first-time init branches
23
+ base.client_token = "ct"
24
+ base.access_token = "at"
25
+ base.client_id = "cid"
26
+ base.client_version = "1.0.0"
27
+ base.device_id = "did"
28
+ base.access_token_expires_at_ms = (time.time() + 600) * 1000 # 10 min out
29
+ return base
30
+
31
+
32
+ def _make_tls_response(status_code, headers=None, text=""):
33
+ mock = MagicMock()
34
+ mock.status_code = status_code
35
+ mock.headers = headers or {}
36
+ mock.text = text
37
+ mock.url = "https://example.com/x"
38
+ mock.json.return_value = None
39
+ return mock
40
+
41
+
42
+ def _make_response(status_code, headers=None):
43
+ raw = _make_tls_response(status_code, headers=headers)
44
+ return Response(raw=raw, status_code=status_code, response=None)
45
+
46
+
47
+ # ---------- _auth_rule proactive refresh ----------
48
+
49
+
50
+ def test_proactive_refresh_fires_when_near_expiry():
51
+ base = _make_base()
52
+ base.access_token_expires_at_ms = (time.time() + 1) * 1000 # inside skew window
53
+ calls = []
54
+ base._get_auth_vars = lambda: calls.append("called")
55
+
56
+ base._auth_rule({})
57
+
58
+ assert calls == ["called"]
59
+
60
+
61
+ def test_proactive_refresh_skipped_when_fresh():
62
+ base = _make_base() # expiry is 10 min out
63
+ calls = []
64
+ base._get_auth_vars = lambda: calls.append("called")
65
+
66
+ base._auth_rule({})
67
+
68
+ assert calls == []
69
+
70
+
71
+ def test_auth_rule_sets_expected_headers():
72
+ base = _make_base()
73
+ kwargs = base._auth_rule({})
74
+
75
+ headers = kwargs["headers"]
76
+ assert headers["Authorization"] == "Bearer at"
77
+ assert headers["Client-Token"] == "ct"
78
+ assert headers["Spotify-App-Version"] == "1.0.0"
79
+
80
+
81
+ # ---------- _handle_auth_failure ----------
82
+
83
+
84
+ def test_handle_401_refreshes_access_token():
85
+ base = _make_base()
86
+ calls = []
87
+
88
+ def fake_refresh():
89
+ calls.append("called")
90
+ base.access_token = "new_at"
91
+
92
+ base._get_auth_vars = fake_refresh
93
+ base.access_token = _Undefined # simulate reset-before-refresh
94
+
95
+ retried = base._handle_auth_failure(_make_response(401))
96
+
97
+ assert retried is True
98
+ assert calls == ["called"]
99
+
100
+
101
+ def test_handle_401_resets_access_token_before_refresh():
102
+ base = _make_base()
103
+ seen = []
104
+ base._get_auth_vars = lambda: seen.append(base.access_token)
105
+
106
+ base._handle_auth_failure(_make_response(401))
107
+
108
+ assert seen == [_Undefined], "access_token must be reset before _get_auth_vars runs"
109
+
110
+
111
+ def test_handle_400_with_client_token_error_refreshes_client_token():
112
+ base = _make_base()
113
+ calls = []
114
+
115
+ def fake_refresh():
116
+ calls.append("called")
117
+ base.client_token = "new_ct"
118
+
119
+ base.get_client_token = fake_refresh
120
+
121
+ retried = base._handle_auth_failure(
122
+ _make_response(400, headers={"Client-Token-Error": "INVALID_CLIENTTOKEN"})
123
+ )
124
+
125
+ assert retried is True
126
+ assert calls == ["called"]
127
+
128
+
129
+ def test_handle_400_without_client_token_error_returns_false():
130
+ base = _make_base()
131
+ base.get_client_token = lambda: pytest.fail("should not refresh")
132
+ base._get_auth_vars = lambda: pytest.fail("should not refresh")
133
+
134
+ retried = base._handle_auth_failure(_make_response(400))
135
+
136
+ assert retried is False
137
+
138
+
139
+ def test_handle_500_returns_false():
140
+ base = _make_base()
141
+ base.get_client_token = lambda: pytest.fail("should not refresh")
142
+ base._get_auth_vars = lambda: pytest.fail("should not refresh")
143
+
144
+ retried = base._handle_auth_failure(_make_response(500))
145
+
146
+ assert retried is False
147
+
148
+
149
+ def test_handle_200_returns_false():
150
+ base = _make_base()
151
+ retried = base._handle_auth_failure(_make_response(200))
152
+ assert retried is False
153
+
154
+
155
+ # ---------- TLSClient._send retry-once ----------
156
+
157
+
158
+ def _patch_send(client, responses):
159
+ """Make build_request return the given TLS responses in sequence."""
160
+ it = iter(responses)
161
+ client.build_request = lambda *args, **kwargs: next(it)
162
+
163
+
164
+ def test_send_retries_once_on_auth_failure():
165
+ client = TLSClient("chrome_120", "", auto_retries=1)
166
+ _patch_send(
167
+ client,
168
+ [
169
+ _make_tls_response(401, headers={"Www-Authenticate": "Bearer"}),
170
+ _make_tls_response(200),
171
+ ],
172
+ )
173
+
174
+ auth_calls = []
175
+ client.authenticate = lambda kwargs: (auth_calls.append(1) or kwargs)
176
+ client.on_auth_failure = lambda resp: True
177
+
178
+ resp = client.get("https://example.com/x", authenticate=True)
179
+
180
+ assert resp.status_code == 200
181
+ assert len(auth_calls) == 2, "authenticate should run once per attempt"
182
+
183
+
184
+ def test_send_does_not_retry_when_handler_returns_false():
185
+ client = TLSClient("chrome_120", "", auto_retries=1)
186
+ _patch_send(client, [_make_tls_response(500)])
187
+
188
+ client.authenticate = lambda kwargs: kwargs
189
+ client.on_auth_failure = lambda resp: False
190
+
191
+ resp = client.get("https://example.com/x", authenticate=True)
192
+
193
+ assert resp.status_code == 500
194
+
195
+
196
+ def test_send_does_not_retry_without_authenticate_flag():
197
+ client = TLSClient("chrome_120", "", auto_retries=1)
198
+ _patch_send(client, [_make_tls_response(401)])
199
+
200
+ client.authenticate = lambda kwargs: kwargs
201
+ client.on_auth_failure = lambda resp: pytest.fail("should not be called")
202
+
203
+ resp = client.get("https://example.com/x", authenticate=False)
204
+
205
+ assert resp.status_code == 401
206
+
207
+
208
+ def test_send_retries_at_most_once_even_if_second_attempt_also_fails():
209
+ client = TLSClient("chrome_120", "", auto_retries=1)
210
+ # If _send retried more than once, StopIteration would fire
211
+ _patch_send(
212
+ client,
213
+ [_make_tls_response(401), _make_tls_response(401)],
214
+ )
215
+
216
+ client.authenticate = lambda kwargs: kwargs
217
+ handler_calls = []
218
+ client.on_auth_failure = lambda resp: (handler_calls.append(1) or True)
219
+
220
+ resp = client.get("https://example.com/x", authenticate=True)
221
+
222
+ assert resp.status_code == 401
223
+ assert len(handler_calls) == 1, "on_auth_failure must only be consulted once"
@@ -33,9 +33,10 @@ class PublicAlbum:
33
33
  album: str,
34
34
  /,
35
35
  *,
36
- client: TLSClient = TLSClient("chrome_120", "", auto_retries=3),
36
+ client: TLSClient = TLSClient("chrome120", "", auto_retries=3),
37
+ language: str = "en",
37
38
  ) -> None:
38
- self.base = BaseClient(client=client)
39
+ self.base = BaseClient(client=client, language=language)
39
40
  self.album_id = album.split("album/")[-1] if "album" in album else album
40
41
  self.album_link = f"https://open.spotify.com/album/{self.album_id}"
41
42
 
@@ -37,13 +37,14 @@ class Artist:
37
37
  self,
38
38
  login: Login | None = None,
39
39
  *,
40
- client: TLSClient = TLSClient("chrome_120", "", auto_retries=3),
40
+ client: TLSClient = TLSClient("chrome120", "", auto_retries=3),
41
+ language: str = "en",
41
42
  ) -> None:
42
43
  if login and not login.logged_in:
43
44
  raise ValueError("Must be logged in")
44
45
 
45
46
  self._login: bool = bool(login)
46
- self.base = BaseClient(client=login.client if (login is not None) else client)
47
+ self.base = BaseClient(client=login.client if (login is not None) else client, language=language)
47
48
 
48
49
  def query_artists(
49
50
  self, query: str, /, limit: int = 10, *, offset: int = 0
@@ -144,6 +145,112 @@ class Artist:
144
145
  ]["artists"]["items"]
145
146
  offset += UPPER_LIMIT
146
147
 
148
+ def get_artist_discography(
149
+ self,
150
+ artist_id: str,
151
+ /,
152
+ *,
153
+ section: Literal["all", "albums", "singles", "compilations"] = "all",
154
+ offset: int = 0,
155
+ limit: int = 50,
156
+ order: Literal["DATE_DESC", "DATE_ASC"] = "DATE_DESC",
157
+ ) -> Mapping[str, Any]:
158
+ """Gets an artist's discography with pagination.
159
+
160
+ Unlike queryArtistOverview (capped per section), the discography operations
161
+ support proper offset/limit/order. Section 'all' merges albums + singles +
162
+ compilations sorted by date.
163
+ """
164
+ if "artist:" in artist_id:
165
+ artist_id = artist_id.split("artist:")[-1]
166
+
167
+ operation_name = {
168
+ "all": "queryArtistDiscographyAll",
169
+ "albums": "queryArtistDiscographyAlbums",
170
+ "singles": "queryArtistDiscographySingles",
171
+ "compilations": "queryArtistDiscographyCompilations",
172
+ }[section]
173
+ url = "https://api-partner.spotify.com/pathfinder/v2/query"
174
+ payload = {
175
+ "variables": {
176
+ "uri": f"spotify:artist:{artist_id}",
177
+ "offset": offset,
178
+ "limit": limit,
179
+ "order": order,
180
+ },
181
+ "operationName": operation_name,
182
+ "extensions": {
183
+ "persistedQuery": {
184
+ "version": 1,
185
+ "sha256Hash": self.base.part_hash(operation_name),
186
+ },
187
+ },
188
+ }
189
+
190
+ resp = self.base.client.post(url, json=payload, authenticate=True)
191
+
192
+ if resp.fail:
193
+ raise ArtistError(
194
+ "Could not get artist discography", error=resp.error.string
195
+ )
196
+
197
+ if not isinstance(resp.response, Mapping):
198
+ raise ArtistError("Invalid JSON response")
199
+
200
+ return resp.response
201
+
202
+ def paginate_artist_discography(
203
+ self,
204
+ artist_id: str,
205
+ /,
206
+ *,
207
+ section: Literal["all", "albums", "singles", "compilations"] = "all",
208
+ order: Literal["DATE_DESC", "DATE_ASC"] = "DATE_DESC",
209
+ ) -> Generator[Mapping[str, Any], None, None]:
210
+ """Generator that fetches an artist's discography in chunks.
211
+
212
+ Note: If total_count <= 50, then there is no need to paginate.
213
+ """
214
+ UPPER_LIMIT: int = 50
215
+
216
+ first = self.get_artist_discography(
217
+ artist_id, section=section, offset=0, limit=UPPER_LIMIT, order=order
218
+ )
219
+ section_data = (
220
+ first.get("data", {})
221
+ .get("artistUnion", {})
222
+ .get("discography", {})
223
+ .get(section, {})
224
+ )
225
+ items = section_data.get("items") or []
226
+ total_count: int = section_data.get("totalCount") or len(items)
227
+
228
+ yield items
229
+
230
+ if total_count <= UPPER_LIMIT:
231
+ return
232
+
233
+ offset = UPPER_LIMIT
234
+ while offset < total_count:
235
+ resp = self.get_artist_discography(
236
+ artist_id,
237
+ section=section,
238
+ offset=offset,
239
+ limit=UPPER_LIMIT,
240
+ order=order,
241
+ )
242
+ page_items = (
243
+ resp.get("data", {})
244
+ .get("artistUnion", {})
245
+ .get("discography", {})
246
+ .get(section, {})
247
+ .get("items")
248
+ ) or []
249
+ if not page_items:
250
+ break
251
+ yield page_items
252
+ offset += UPPER_LIMIT
253
+
147
254
  def _do_follow(
148
255
  self,
149
256
  artist_id: str,
@@ -1,3 +1,4 @@
1
+ import re
1
2
  import time
2
3
  import json
3
4
  import base64
@@ -7,10 +8,10 @@ import requests
7
8
  from typing import Tuple, Literal
8
9
  from collections.abc import Mapping
9
10
  from spotapi.utils.logger import Logger
10
- from spotapi.utils.logger import Logger
11
11
  from spotapi.types.annotations import enforce
12
12
  from spotapi.types.alias import _UStr, _Undefined
13
13
  from spotapi.exceptions import BaseClientError
14
+ from spotapi.http.data import Response
14
15
  from spotapi.http.request import TLSClient
15
16
  from spotapi.utils.strings import extract_js_links, extract_mappings, combine_chunks
16
17
 
@@ -18,9 +19,9 @@ from spotapi.utils.strings import extract_js_links, extract_mappings, combine_ch
18
19
  RECAPTCHA_SITE_KEY: str = "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39"
19
20
  # Fallback hardcoded secret (version 18)
20
21
  _FALLBACK_SECRET: Tuple[Literal[18], bytearray] = (
21
- 18,
22
+ 61,
22
23
  bytearray(
23
- [70, 60, 33, 57, 92, 120, 90, 33, 32, 62, 62, 55, 126, 93, 66, 35, 108, 68]
24
+ [44,55,47,42,70,40,34,114,76,74,50,111,120,97,75,76,94,102,43,69,49,120,118,80,64,78]
24
25
  ),
25
26
  )
26
27
 
@@ -77,16 +78,24 @@ class BaseClient:
77
78
  js_pack: _UStr = _Undefined
78
79
  client_version: _UStr = _Undefined
79
80
  access_token: _UStr = _Undefined
81
+ access_token_expires_at_ms: float = 0
80
82
  client_token: _UStr = _Undefined
81
83
  client_id: _UStr = _Undefined
82
84
  device_id: _UStr = _Undefined
83
85
  raw_hashes: _UStr = _Undefined
86
+ language: str = "en"
87
+
88
+ # Refresh a bit before real expiry to avoid skew-induced 401s
89
+ _REFRESH_SKEW_MS: float = 30_000
84
90
 
85
- def __init__(self, client: TLSClient) -> None:
91
+ def __init__(self, client: TLSClient, language: str = "en") -> None:
86
92
  self.client = client
93
+ self.language = language
87
94
  self.client.authenticate = lambda kwargs: self._auth_rule(kwargs)
95
+ self.client.on_auth_failure = lambda resp: self._handle_auth_failure(resp)
88
96
 
89
- self.browser_version = self.client.client_identifier.split("_")[1]
97
+ match = re.search(r"\d+", self.client.impersonate)
98
+ self.browser_version = match.group()
90
99
  self.client.headers.update(
91
100
  {
92
101
  "Content-Type": "application/json;charset=UTF-8",
@@ -104,6 +113,14 @@ class BaseClient:
104
113
  if self.access_token is _Undefined:
105
114
  self.get_session()
106
115
 
116
+ if (
117
+ self.access_token_expires_at_ms
118
+ and time.time() * 1000 + self._REFRESH_SKEW_MS
119
+ >= self.access_token_expires_at_ms
120
+ ):
121
+ self.access_token = _Undefined
122
+ self._get_auth_vars()
123
+
107
124
  if "headers" not in kwargs:
108
125
  kwargs["headers"] = {}
109
126
 
@@ -112,11 +129,34 @@ class BaseClient:
112
129
  "Authorization": "Bearer " + str(self.access_token),
113
130
  "Client-Token": self.client_token,
114
131
  "Spotify-App-Version": self.client_version,
132
+ "Accept-Language": self.language,
115
133
  }.items()
116
134
  )
117
135
 
118
136
  return kwargs
119
137
 
138
+ def _handle_auth_failure(self, resp: Response) -> bool:
139
+ if resp.status_code == 401:
140
+ self.access_token = _Undefined
141
+ self._get_auth_vars()
142
+ return True
143
+
144
+ if resp.status_code == 400:
145
+ try:
146
+ headers = {k.lower(): v for k, v in resp.raw.headers.items()}
147
+ except Exception:
148
+ headers = {}
149
+ if headers.get("client-token-error") == "INVALID_CLIENTTOKEN":
150
+ self.client_token = _Undefined
151
+ self.get_client_token()
152
+ return True
153
+
154
+ return False
155
+
156
+ def set_language(self, language: str) -> None:
157
+ """Set the language for API requests. Uses ISO 639-1 language codes (e.g., 'ko', 'en', 'ja')."""
158
+ self.language = language
159
+
120
160
  def _get_auth_vars(self) -> None:
121
161
  if self.access_token is _Undefined or self.client_id is _Undefined:
122
162
  totp, version = generate_totp()
@@ -137,6 +177,9 @@ class BaseClient:
137
177
 
138
178
  self.access_token = resp.response["accessToken"]
139
179
  self.client_id = resp.response["clientId"]
180
+ self.access_token_expires_at_ms = float(
181
+ resp.response.get("accessTokenExpirationTimestampMs") or 0
182
+ )
140
183
 
141
184
  def get_session(self) -> None:
142
185
  resp = self.client.get("https://open.spotify.com")
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any, Union
5
5
  from requests import Response as StdResponse
6
- from tls_client.response import Response as TLSResponse
6
+ from curl_cffi.requests import Response as TLSResponse
7
7
 
8
8
  __all__ = ["Response", "Error", "StdResponse", "TLSResponse"]
9
9
 
@@ -1,15 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Type, Dict
4
- from tls_client.settings import ClientIdentifiers
5
- from tls_client.exceptions import TLSClientExeption
6
- from tls_client.response import Response as TLSResponse
7
- from spotapi.exceptions import ParentException, RequestError
8
- from spotapi.http.data import Response
9
- from tls_client import Session
10
- import requests
11
3
  import atexit
12
4
  import json
5
+ from typing import Any, Callable, Dict, Type
6
+
7
+ import requests
8
+ from curl_cffi.requests import Response as TLSResponse
9
+ from curl_cffi.requests import Session
10
+ from curl_cffi.requests.exceptions import RequestException
11
+
12
+ from spotapi.exceptions import ParentException, RequestError
13
+ from spotapi.http.data import Response
14
+
15
+ ClientIdentifiers = str
13
16
 
14
17
  __all__ = [
15
18
  "StdClient",
@@ -110,7 +113,7 @@ class StdClient:
110
113
 
111
114
  class TLSClient(Session):
112
115
  """
113
- TLS-HTTP Client implementation wrapped around the tls_client library.
116
+ TLS-HTTP Client implementation wrapped around the curl_cffi library.
114
117
 
115
118
  This is fully undetected by Spotify.com.
116
119
  """
@@ -123,13 +126,14 @@ class TLSClient(Session):
123
126
  auto_retries: int = 0,
124
127
  auth_rule: Callable[[Dict[Any, Any]], Dict[Any, Any]] | None = None,
125
128
  ) -> None:
126
- super().__init__(client_identifier=profile, random_tls_extension_order=True)
129
+ super().__init__(impersonate=profile)
127
130
 
128
131
  if proxy:
129
132
  self.proxies = {"http": f"http://{proxy}", "https": f"http://{proxy}"}
130
133
 
131
134
  self.auto_retries = auto_retries + 1
132
135
  self.authenticate = auth_rule
136
+ self.on_auth_failure: Callable[[Response], bool] | None = None
133
137
  self.fail_exception: Type[ParentException] | None = None
134
138
  atexit.register(self.close)
135
139
 
@@ -149,8 +153,8 @@ class TLSClient(Session):
149
153
  err = "Unknown"
150
154
  for _ in range(self.auto_retries):
151
155
  try:
152
- response = self.execute_request(method.upper(), url, **kwargs)
153
- except TLSClientExeption as e:
156
+ response = self.request(method.upper(), url, **kwargs)
157
+ except RequestException as e:
154
158
  err = str(e)
155
159
  continue
156
160
  else:
@@ -180,9 +184,6 @@ class TLSClient(Session):
180
184
  if not body:
181
185
  body = None
182
186
 
183
- # Why is status_code a None type...
184
- assert response.status_code is not None, "Status Code is None"
185
-
186
187
  resp = Response(
187
188
  status_code=int(response.status_code), response=body, raw=response
188
189
  )
@@ -195,19 +196,50 @@ class TLSClient(Session):
195
196
 
196
197
  return resp
197
198
 
198
- def get(
199
- self, url: str | bytes, *, authenticate: bool = False, **kwargs
199
+ def _send(
200
+ self,
201
+ method: str,
202
+ url: str | bytes,
203
+ *,
204
+ authenticate: bool,
205
+ danger: bool,
206
+ **kwargs,
200
207
  ) -> Response:
201
- """Routes a GET Request"""
202
208
  if authenticate and self.authenticate is not None:
203
209
  kwargs = self.authenticate(kwargs)
204
210
 
205
- response = self.build_request("GET", url, allow_redirects=True, **kwargs)
206
-
211
+ response = self.build_request(method, url, allow_redirects=True, **kwargs)
207
212
  if response is None:
208
- raise TLSClientExeption("Request kept failing after retries.")
213
+ raise RequestError("Request kept failing after retries.")
214
+
215
+ parsed = self.parse_response(response, method, False)
209
216
 
210
- return self.parse_response(response, "GET", True)
217
+ if (
218
+ authenticate
219
+ and self.on_auth_failure is not None
220
+ and self.authenticate is not None
221
+ and parsed.fail
222
+ and self.on_auth_failure(parsed)
223
+ ):
224
+ kwargs = self.authenticate(kwargs)
225
+ response = self.build_request(method, url, allow_redirects=True, **kwargs)
226
+ if response is None:
227
+ raise RequestError("Request kept failing after retries.")
228
+ parsed = self.parse_response(response, method, False)
229
+
230
+ if danger and self.fail_exception and parsed.fail:
231
+ raise self.fail_exception(
232
+ f"Could not {method} {str(response.url).split('?')[0]}. Status Code: {parsed.status_code}",
233
+ "Request Failed.",
234
+ )
235
+
236
+ return parsed
237
+
238
+ def get(
239
+ self, url: str | bytes, *, authenticate: bool = False, **kwargs
240
+ ) -> Response:
241
+ """Routes a GET Request"""
242
+ return self._send("GET", url, authenticate=authenticate, danger=True, **kwargs)
211
243
 
212
244
  def post(
213
245
  self,
@@ -218,15 +250,9 @@ class TLSClient(Session):
218
250
  **kwargs,
219
251
  ) -> Response:
220
252
  """Routes a POST Request"""
221
- if authenticate and self.authenticate is not None:
222
- kwargs = self.authenticate(kwargs)
223
-
224
- response = self.build_request("POST", url, allow_redirects=True, **kwargs)
225
-
226
- if response is None:
227
- raise TLSClientExeption("Request kept failing after retries.")
228
-
229
- return self.parse_response(response, "POST", danger)
253
+ return self._send(
254
+ "POST", url, authenticate=authenticate, danger=danger, **kwargs
255
+ )
230
256
 
231
257
  def put(
232
258
  self,
@@ -237,12 +263,6 @@ class TLSClient(Session):
237
263
  **kwargs,
238
264
  ) -> Response:
239
265
  """Routes a PUT Request"""
240
- if authenticate and self.authenticate is not None:
241
- kwargs = self.authenticate(kwargs)
242
-
243
- response = self.build_request("PUT", url, allow_redirects=True, **kwargs)
244
-
245
- if response is None:
246
- raise TLSClientExeption("Request kept failing after retries.")
247
-
248
- return self.parse_response(response, "PUT", danger)
266
+ return self._send(
267
+ "PUT", url, authenticate=authenticate, danger=danger, **kwargs
268
+ )
@@ -53,7 +53,7 @@ class Player(PlayerStatus):
53
53
 
54
54
  _origin_device_id = self.r_state.play_origin.device_identifier
55
55
  if _origin_device_id is None:
56
- raise ValueError("Could not get origin device ID.")
56
+ _origin_device_id = "local"
57
57
 
58
58
  self.device_id = _origin_device_id
59
59
  self.transfer_player(self.device_id, self.active_id)
@@ -38,9 +38,10 @@ class PublicPlaylist:
38
38
  playlist: str,
39
39
  /,
40
40
  *,
41
- client: TLSClient = TLSClient("chrome_120", "", auto_retries=3),
41
+ client: TLSClient = TLSClient("chrome120", "", auto_retries=3),
42
+ language: str = "en",
42
43
  ) -> None:
43
- self.base = BaseClient(client=client)
44
+ self.base = BaseClient(client=client, language=language)
44
45
  self.playlist_id = (
45
46
  playlist.split("playlist/")[-1] if "playlist" in playlist else playlist
46
47
  )
@@ -131,6 +132,8 @@ class PrivatePlaylist:
131
132
  self,
132
133
  login: Login,
133
134
  playlist: str | None = None,
135
+ *,
136
+ language: str = "en",
134
137
  ) -> None:
135
138
  if not login.logged_in:
136
139
  raise ValueError("Must be logged in")
@@ -140,7 +143,7 @@ class PrivatePlaylist:
140
143
  playlist.split("playlist/")[-1] if "playlist" in playlist else playlist
141
144
  )
142
145
 
143
- self.base = BaseClient(login.client)
146
+ self.base = BaseClient(login.client, language=language)
144
147
  self.login = login
145
148
  self.user = User(login)
146
149
  # We need to check if a user can use a method
@@ -274,6 +277,63 @@ class PrivatePlaylist:
274
277
 
275
278
  return resp.response
276
279
 
280
+ def get_saved_tracks_info(
281
+ self,
282
+ limit: int = 25,
283
+ *,
284
+ offset: int = 0,
285
+ ) -> Mapping[str, Any]:
286
+ """Gets the playlist information of saved songs"""
287
+ url = "https://api-partner.spotify.com/pathfinder/v1/query"
288
+ params = {
289
+ "operationName": "fetchLibraryTracks",
290
+ "variables": json.dumps(
291
+ {
292
+ "offset": offset,
293
+ "limit": limit,
294
+ }
295
+ ),
296
+ "extensions": json.dumps(
297
+ {
298
+ "persistedQuery": {
299
+ "version": 1,
300
+ "sha256Hash": self.base.part_hash("fetchLibraryTracks"),
301
+ }
302
+ }
303
+ ),
304
+ }
305
+
306
+ resp = self.login.client.post(url, params=params, authenticate=True)
307
+
308
+ if resp.fail:
309
+ raise PlaylistError("Could not get library tracks info", error=resp.error.string)
310
+
311
+ if not isinstance(resp.response, Mapping):
312
+ raise PlaylistError("Invalid JSON")
313
+
314
+ return resp.response
315
+
316
+ def paginate_saved_tracks(self) -> Generator[Mapping[str, Any], None, None]:
317
+ """
318
+ Generator that fetches playlist information in chunks
319
+
320
+ NOTE: If total_tracks <= 343, then there is no need to paginate.
321
+ """
322
+ UPPER_LIMIT: int = 343
323
+ # We need to get the total playlists first
324
+ playlist = self.get_saved_tracks_info(limit=UPPER_LIMIT)
325
+ total_count: int = playlist["data"]["me"]["library"]["tracks"]["totalCount"]
326
+
327
+ yield playlist["data"]["me"]["library"]["tracks"]
328
+
329
+ if total_count <= UPPER_LIMIT:
330
+ return
331
+
332
+ offset = UPPER_LIMIT
333
+ while offset < total_count:
334
+ yield self.get_saved_tracks_info(limit=UPPER_LIMIT, offset=offset)["data"]["me"]["library"]["tracks"]
335
+ offset += UPPER_LIMIT
336
+
277
337
  def _stage_create_playlist(self, name: str) -> str:
278
338
  url = "https://spclient.wg.spotify.com/playlist/v2/playlist"
279
339
  payload = {
@@ -32,9 +32,10 @@ class Podcast:
32
32
  self,
33
33
  podcast: str | None = None,
34
34
  *,
35
- client: TLSClient = TLSClient("chrome_120", "", auto_retries=3),
35
+ client: TLSClient = TLSClient("chrome120", "", auto_retries=3),
36
+ language: str = "en",
36
37
  ) -> None:
37
- self.base = BaseClient(client=client)
38
+ self.base = BaseClient(client=client, language=language)
38
39
  if podcast:
39
40
  self.podcast_id = (
40
41
  podcast.split("show/")[-1] if "show" in podcast else podcast
@@ -44,7 +44,7 @@ class Pooler(Generic[T]):
44
44
 
45
45
 
46
46
  client_pool: Pooler[TLSClient] = Pooler(
47
- factory=lambda: TLSClient("chrome_120", "", auto_retries=3)
47
+ factory=lambda: TLSClient("chrome120", "", auto_retries=3)
48
48
  )
49
49
  GeneratorType: TypeAlias = Generator[Mapping[str, Any], None, None]
50
50
 
@@ -31,10 +31,11 @@ class Song:
31
31
  self,
32
32
  playlist: PrivatePlaylist | None = None,
33
33
  *,
34
- client: TLSClient = TLSClient("chrome_120", "", auto_retries=3),
34
+ client: TLSClient = TLSClient("chrome120", "", auto_retries=3),
35
+ language: str = "en",
35
36
  ) -> None:
36
37
  self.playlist = playlist
37
- self.base = BaseClient(client=playlist.login.client if playlist else client)
38
+ self.base = BaseClient(client=playlist.login.client if playlist else client, language=language)
38
39
 
39
40
  def get_track_info(self, track_id: str) -> Mapping[str, Any]:
40
41
  """
@@ -142,7 +143,7 @@ class Song:
142
143
  url = "https://api-partner.spotify.com/pathfinder/v1/query"
143
144
  payload = {
144
145
  "variables": {
145
- "uris": [f"spotify:track:{song_id}" for song_id in song_ids],
146
+ "playlistItemUris": [f"spotify:track:{song_id}" for song_id in song_ids],
146
147
  "playlistUri": f"spotify:playlist:{self.playlist.playlist_id}",
147
148
  "newPosition": {"moveType": "BOTTOM_OF_PLAYLIST", "fromUid": None},
148
149
  },
@@ -274,7 +275,7 @@ class Song:
274
275
 
275
276
  url = "https://api-partner.spotify.com/pathfinder/v1/query"
276
277
  payload = {
277
- "variables": {"uris": [f"spotify:track:{song_id}"]},
278
+ "variables": {"libraryItemUris": [f"spotify:track:{song_id}"]},
278
279
  "operationName": "addToLibrary",
279
280
  "extensions": {
280
281
  "persistedQuery": {
@@ -30,7 +30,7 @@ __all__ = [
30
30
  class Config:
31
31
  logger: LoggerProtocol
32
32
  solver: CaptchaProtocol | None = field(default=None)
33
- client: TLSClient = field(default=TLSClient("chrome_120", "", auto_retries=3))
33
+ client: TLSClient = field(default=TLSClient("chrome120", "", auto_retries=3))
34
34
 
35
35
  def __str__(self) -> str:
36
36
  return "Config()"
@@ -1,17 +1,34 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: spotapi
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Summary: A sleek API wrapper for Spotify's private API
5
- Home-page: UNKNOWN
6
5
  Author: Aran
7
- License: UNKNOWN
8
6
  Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
- Platform: UNKNOWN
10
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: requests
10
+ Requires-Dist: colorama
11
+ Requires-Dist: Pillow
12
+ Requires-Dist: readerwriterlock
13
+ Requires-Dist: curl_cffi
14
+ Requires-Dist: typing_extensions
15
+ Requires-Dist: validators
16
+ Requires-Dist: pyotp
17
+ Requires-Dist: beautifulsoup4
11
18
  Provides-Extra: websocket
19
+ Requires-Dist: websockets; extra == "websocket"
12
20
  Provides-Extra: redis
21
+ Requires-Dist: redis; extra == "redis"
13
22
  Provides-Extra: pymongo
14
- License-File: LICENSE
23
+ Requires-Dist: pymongo; extra == "pymongo"
24
+ Dynamic: author
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: keywords
28
+ Dynamic: license-file
29
+ Dynamic: provides-extra
30
+ Dynamic: requires-dist
31
+ Dynamic: summary
15
32
 
16
33
  # Legal Notice
17
34
 
@@ -32,8 +49,8 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
32
49
  5. [Quick Examples](#quick-examples)
33
50
  6. [Import Cookies](#import-cookies)
34
51
  7. [Contributing](#contributing)
35
- 8. [Roadmap](#roadmap)
36
- 9. [License](#license)
52
+ 8. [License](#license)
53
+ 9. **[Extended Functionality & Private Modules](#private-modules)**
37
54
 
38
55
 
39
56
  ## Features
@@ -41,8 +58,9 @@ Welcome to SpotAPI! This Python library is designed to interact with the private
41
58
  - **Public API Access**: Retrieve and manipulate public Spotify data such as playlists, albums, and tracks with ease.
42
59
  - **Private API Access**: Explore private Spotify endpoints to tailor your application to your needs.
43
60
  - **Ready to Use**: **SpotAPI** is designed for immediate integration, allowing you to accomplish tasks with just a few lines of code.
44
- - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. It’s straightforward and hassle free!
61
+ - **No API Key Required**: Seamlessly use **SpotAPI** without needing a Spotify API key. It's straightforward and hassle free!
45
62
  - **Browser-like Requests**: Accurately replicate the HTTP requests Spotify makes in the browser, providing a true to web experience while remaining undetected.
63
+ - **Multi-Language Support**: Set your preferred language for API responses using ISO 639-1 language codes (e.g., 'ko', 'ja', 'zh', 'en').
46
64
 
47
65
  Everything you can do with Spotify, **SpotAPI** can do with just a user’s login credentials.
48
66
 
@@ -119,6 +137,21 @@ data = songs["data"]["searchV2"]["tracksV2"]["items"]
119
137
  for idx, item in enumerate(data):
120
138
  print(idx, item['item']['data']['name'])
121
139
  ```
140
+
141
+ ### With Language Support
142
+ ```py
143
+ from spotapi import Artist, PublicPlaylist, Song
144
+
145
+ # Initialize with Korean language
146
+ artist = Artist(language="ko")
147
+ playlist = PublicPlaylist("37i9dQZF1DXcBWIGoYBM5M", language="ko")
148
+
149
+ # Change language at runtime
150
+ song = Song(language="en")
151
+ song.base.set_language("ja") # Switch to Japanese
152
+
153
+ # Supported languages: ko, ja, zh, en, and any ISO 639-1 code
154
+ ```
122
155
  ### Results
123
156
  ```
124
157
  0 Island In The Sun
@@ -152,17 +185,8 @@ If you prefer not to use a third party CAPTCHA solver, you can import cookies to
152
185
  ## Contributing
153
186
  Contributions are welcome! If you find any issues or have suggestions, please open an issue or submit a pull request.
154
187
 
155
- ## Roadmap
156
- > I'll most likely do these if the project gains some traction
157
-
158
- - [ ] No Captcha For Login (**100 Stars**)
159
- - [x] In Depth Documentation
160
- - [x] Websocket Listener
161
- - [x] Player
162
- - [ ] More wrappers around this project
163
-
164
188
  ## License
165
189
  This project is licensed under the **GPL 3.0** License. See [LICENSE](https://choosealicense.com/licenses/gpl-3.0/) for details.
166
190
 
167
-
168
-
191
+ ## **Private Modules**
192
+ For the full scale account generation and "engagement" modules that can't be posted here, reach out on Telegram: **@aran_xyz**
@@ -24,6 +24,7 @@ spotapi.egg-info/requires.txt
24
24
  spotapi.egg-info/top_level.txt
25
25
  spotapi/_tests/__init__.py
26
26
  spotapi/_tests/annotations_test.py
27
+ spotapi/_tests/client_refresh_test.py
27
28
  spotapi/exceptions/__init__.py
28
29
  spotapi/exceptions/errors.py
29
30
  spotapi/http/__init__.py
@@ -2,7 +2,7 @@ requests
2
2
  colorama
3
3
  Pillow
4
4
  readerwriterlock
5
- tls_client
5
+ curl_cffi
6
6
  typing_extensions
7
7
  validators
8
8
  pyotp
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