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.
- {spotapi-1.2.7/spotapi.egg-info → spotapi-1.2.8}/PKG-INFO +44 -20
- {spotapi-1.2.7 → spotapi-1.2.8}/README.md +21 -12
- {spotapi-1.2.7 → spotapi-1.2.8}/setup.py +2 -2
- spotapi-1.2.8/spotapi/_tests/client_refresh_test.py +223 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/album.py +3 -2
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/artist.py +109 -2
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/client.py +48 -5
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/data.py +1 -1
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/request.py +60 -40
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/player.py +1 -1
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/playlist.py +63 -3
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/podcast.py +3 -2
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/public.py +1 -1
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/song.py +5 -4
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/data.py +1 -1
- {spotapi-1.2.7 → spotapi-1.2.8/spotapi.egg-info}/PKG-INFO +44 -20
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/SOURCES.txt +1 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/requires.txt +1 -1
- {spotapi-1.2.7 → spotapi-1.2.8}/LICENSE +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/setup.cfg +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/_tests/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/_tests/annotations_test.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/creator.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/exceptions/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/exceptions/errors.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/family.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/http/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/login.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/password.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/capmonster.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/solvers/capsolver.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/status.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/alias.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/annotations.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/types/interfaces.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/user.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/__init__.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/logger.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/saver.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/utils/strings.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi/websocket.py +0 -0
- {spotapi-1.2.7 → spotapi-1.2.8}/spotapi.egg-info/dependency_links.txt +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: spotapi
|
|
3
|
-
Version: 1.2.
|
|
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
|
-
|
|
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. [
|
|
36
|
-
9. [
|
|
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
|
|
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. [
|
|
21
|
-
9. [
|
|
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. It
|
|
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
|
-
"
|
|
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.
|
|
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("
|
|
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("
|
|
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
|
-
|
|
22
|
+
61,
|
|
22
23
|
bytearray(
|
|
23
|
-
[70,
|
|
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
|
-
|
|
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
|
|
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
|
|
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__(
|
|
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.
|
|
153
|
-
except
|
|
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
|
|
199
|
-
self,
|
|
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(
|
|
206
|
-
|
|
211
|
+
response = self.build_request(method, url, allow_redirects=True, **kwargs)
|
|
207
212
|
if response is None:
|
|
208
|
-
raise
|
|
213
|
+
raise RequestError("Request kept failing after retries.")
|
|
214
|
+
|
|
215
|
+
parsed = self.parse_response(response, method, False)
|
|
209
216
|
|
|
210
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
"
|
|
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": {"
|
|
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("
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: spotapi
|
|
3
|
-
Version: 1.2.
|
|
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
|
-
|
|
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. [
|
|
36
|
-
9. [
|
|
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
|
|
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
|
|
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
|