spotapi 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spotapi/__init__.py +17 -0
- spotapi/artist.py +139 -0
- spotapi/client.py +158 -0
- spotapi/creator.py +168 -0
- spotapi/data/__init__.py +2 -0
- spotapi/data/data.py +18 -0
- spotapi/data/interfaces.py +59 -0
- spotapi/exceptions/__init__.py +15 -0
- spotapi/exceptions/errors.py +68 -0
- spotapi/family.py +130 -0
- spotapi/login.py +260 -0
- spotapi/password.py +76 -0
- spotapi/playlist.py +332 -0
- spotapi/solvers/__init__.py +15 -0
- spotapi/solvers/capmonster.py +128 -0
- spotapi/solvers/capsolver.py +130 -0
- spotapi/song.py +234 -0
- spotapi/user.py +114 -0
- spotapi/websocket.py +89 -0
- spotapi-0.0.0.dist-info/LICENSE +674 -0
- spotapi-0.0.0.dist-info/METADATA +23 -0
- spotapi-0.0.0.dist-info/RECORD +24 -0
- spotapi-0.0.0.dist-info/WHEEL +5 -0
- spotapi-0.0.0.dist-info/top_level.txt +1 -0
spotapi/song.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Generator, List, Mapping, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from spotapi.exceptions import SongError
|
|
5
|
+
from spotapi.http.request import TLSClient
|
|
6
|
+
from spotapi.client import BaseClient
|
|
7
|
+
from spotapi.playlist import PrivatePlaylist, PublicPlaylist
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Song(BaseClient):
|
|
11
|
+
"""
|
|
12
|
+
Extends the PrivatePlaylist class with methods that can only be used while logged in.
|
|
13
|
+
These methods interact with songs and tend to be used in the context of a playlist.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
playlist: Optional[PrivatePlaylist] = None,
|
|
19
|
+
*,
|
|
20
|
+
client: Optional[TLSClient] = TLSClient("chrome_120", "", auto_retries=3),
|
|
21
|
+
) -> None:
|
|
22
|
+
self.playlist = playlist
|
|
23
|
+
super().__init__(client=playlist.client if playlist else client)
|
|
24
|
+
|
|
25
|
+
def query_songs(
|
|
26
|
+
self, query: str, /, limit: Optional[int] = 10, *, offset: Optional[int] = 0
|
|
27
|
+
) -> Mapping[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Searches for songs in the Spotify catalog.
|
|
30
|
+
"""
|
|
31
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
32
|
+
params = {
|
|
33
|
+
"operationName": "searchDesktop",
|
|
34
|
+
"variables": json.dumps(
|
|
35
|
+
{
|
|
36
|
+
"searchTerm": query,
|
|
37
|
+
"offset": offset,
|
|
38
|
+
"limit": limit,
|
|
39
|
+
"numberOfTopResults": 5,
|
|
40
|
+
"includeAudiobooks": True,
|
|
41
|
+
"includeArtistHasConcertsField": False,
|
|
42
|
+
"includePreReleases": True,
|
|
43
|
+
"includeLocalConcertsField": False,
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
"extensions": json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"persistedQuery": {
|
|
49
|
+
"version": 1,
|
|
50
|
+
"sha256Hash": self.part_hash("searchDesktop"),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
resp = self.client.post(url, params=params, authenticate=True)
|
|
57
|
+
|
|
58
|
+
if resp.fail:
|
|
59
|
+
raise SongError("Could not get songs", error=resp.error.string)
|
|
60
|
+
|
|
61
|
+
if not isinstance(resp.response, Mapping):
|
|
62
|
+
raise SongError("Invalid JSON")
|
|
63
|
+
|
|
64
|
+
return resp.response
|
|
65
|
+
|
|
66
|
+
def paginate_songs(self, query: str, /) -> Generator[Mapping[str, Any], None, None]:
|
|
67
|
+
"""
|
|
68
|
+
Generator that fetches songs in chunks
|
|
69
|
+
|
|
70
|
+
Note: If total_tracks <= 100, then there is no need to paginate
|
|
71
|
+
"""
|
|
72
|
+
UPPER_LIMIT: int = 100
|
|
73
|
+
# We need to get the total songs first
|
|
74
|
+
songs = self.query_songs(query, limit=UPPER_LIMIT)
|
|
75
|
+
total_count: int = songs["data"]["searchV2"]["tracksV2"]["totalCount"]
|
|
76
|
+
|
|
77
|
+
yield songs["data"]["searchV2"]["tracksV2"]["items"]
|
|
78
|
+
|
|
79
|
+
if total_count <= UPPER_LIMIT:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
offset = UPPER_LIMIT
|
|
83
|
+
while offset < total_count:
|
|
84
|
+
yield self.query_songs(query, limit=UPPER_LIMIT, offset=offset)["data"][
|
|
85
|
+
"searchV2"
|
|
86
|
+
]["tracksV2"]["items"]
|
|
87
|
+
offset += UPPER_LIMIT
|
|
88
|
+
|
|
89
|
+
def add_song_to_playlist(self, song_id: str, /) -> None:
|
|
90
|
+
"""Adds a song to the playlist"""
|
|
91
|
+
if not self.playlist or not hasattr(self.playlist, "playlist_id"):
|
|
92
|
+
raise ValueError("Playlist not set")
|
|
93
|
+
|
|
94
|
+
if "track" in song_id:
|
|
95
|
+
song_id = song_id.split("track/")[1]
|
|
96
|
+
|
|
97
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
98
|
+
payload = {
|
|
99
|
+
"variables": {
|
|
100
|
+
"uris": [f"spotify:track:{song_id}"],
|
|
101
|
+
"playlistUri": f"spotify:playlist:{self.playlist.playlist_id}",
|
|
102
|
+
"newPosition": {"moveType": "BOTTOM_OF_PLAYLIST", "fromUid": None},
|
|
103
|
+
},
|
|
104
|
+
"operationName": "addToPlaylist",
|
|
105
|
+
"extensions": {
|
|
106
|
+
"persistedQuery": {
|
|
107
|
+
"version": 1,
|
|
108
|
+
"sha256Hash": self.part_hash("addToPlaylist"),
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
resp = self.client.post(url, json=payload, authenticate=True)
|
|
113
|
+
|
|
114
|
+
if resp.fail:
|
|
115
|
+
raise SongError("Could not add song to playlist", error=resp.error.string)
|
|
116
|
+
|
|
117
|
+
def __stage_remove_song(self, uids: List[str]) -> None:
|
|
118
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
119
|
+
payload = {
|
|
120
|
+
"variables": {
|
|
121
|
+
"playlistUri": f"spotify:playlist:{self.playlist.playlist_id}",
|
|
122
|
+
"uids": uids,
|
|
123
|
+
},
|
|
124
|
+
"operationName": "removeFromPlaylist",
|
|
125
|
+
"extensions": {
|
|
126
|
+
"persistedQuery": {
|
|
127
|
+
"version": 1,
|
|
128
|
+
"sha256Hash": self.part_hash("removeFromPlaylist"),
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
resp = self.client.post(url, json=payload, authenticate=True)
|
|
134
|
+
|
|
135
|
+
if resp.fail:
|
|
136
|
+
raise SongError(
|
|
137
|
+
"Could not remove song from playlist", error=resp.error.string
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def __parse_playlist_items(
|
|
141
|
+
self,
|
|
142
|
+
items: List[Mapping[str, Any]],
|
|
143
|
+
song_id: Optional[str] = None,
|
|
144
|
+
song_name: Optional[str] = None,
|
|
145
|
+
all_instances: Optional[bool] = False,
|
|
146
|
+
) -> Tuple[List[str], bool]:
|
|
147
|
+
uids: List[str] = []
|
|
148
|
+
for item in items:
|
|
149
|
+
is_song_id = song_id and song_id in item["itemV2"]["data"]["uri"]
|
|
150
|
+
is_song_name = (
|
|
151
|
+
song_name
|
|
152
|
+
and song_name.lower() in str(item["itemV2"]["data"]["name"]).lower()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if is_song_id or is_song_name:
|
|
156
|
+
uids.append(item["uid"])
|
|
157
|
+
|
|
158
|
+
if all_instances:
|
|
159
|
+
return uids, True
|
|
160
|
+
|
|
161
|
+
return uids, False
|
|
162
|
+
|
|
163
|
+
def remove_song_from_playlist(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
uid: Optional[str] = None,
|
|
167
|
+
song_id: Optional[str] = None,
|
|
168
|
+
song_name: Optional[str] = None,
|
|
169
|
+
all_instances: Optional[bool] = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Removes a song from the playlist.
|
|
173
|
+
If all_instances is True, only song_name can be used.
|
|
174
|
+
"""
|
|
175
|
+
if song_id and "track" in song_id:
|
|
176
|
+
song_id = song_id.split("track:")[1]
|
|
177
|
+
|
|
178
|
+
if not (song_id or song_name or uid):
|
|
179
|
+
raise ValueError("Must provide either song_id or song_name or uid")
|
|
180
|
+
|
|
181
|
+
if all_instances and song_id:
|
|
182
|
+
raise ValueError("Cannot provide both song_id and all_instances")
|
|
183
|
+
|
|
184
|
+
if not self.playlist or not hasattr(self.playlist, "playlist_id"):
|
|
185
|
+
raise ValueError("Playlist not set")
|
|
186
|
+
|
|
187
|
+
playlist = PublicPlaylist(self.playlist.playlist_id).paginate_playlist()
|
|
188
|
+
|
|
189
|
+
uids: List[str] = []
|
|
190
|
+
if not uid:
|
|
191
|
+
for playlist_chunk in playlist:
|
|
192
|
+
items = playlist_chunk["items"]
|
|
193
|
+
extended_uids, stop = self.__parse_playlist_items(
|
|
194
|
+
items,
|
|
195
|
+
song_id=song_id,
|
|
196
|
+
song_name=song_name,
|
|
197
|
+
all_instances=all_instances,
|
|
198
|
+
)
|
|
199
|
+
uids.extend(extended_uids)
|
|
200
|
+
|
|
201
|
+
if stop:
|
|
202
|
+
playlist.close()
|
|
203
|
+
break
|
|
204
|
+
else:
|
|
205
|
+
uids.append(uid)
|
|
206
|
+
|
|
207
|
+
if len(uids) == 0:
|
|
208
|
+
raise SongError("Song not found in playlist")
|
|
209
|
+
|
|
210
|
+
self.__stage_remove_song(uids)
|
|
211
|
+
|
|
212
|
+
def like_song(self, song_id: str, /) -> None:
|
|
213
|
+
if not self.playlist or not hasattr(self.playlist, "playlist_id"):
|
|
214
|
+
raise ValueError("Playlist not set")
|
|
215
|
+
|
|
216
|
+
if song_id and "track" in song_id:
|
|
217
|
+
song_id = song_id.split("track:")[1]
|
|
218
|
+
|
|
219
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
220
|
+
payload = {
|
|
221
|
+
"variables": {"uris": [f"spotify:track:{song_id}"]},
|
|
222
|
+
"operationName": "addToLibrary",
|
|
223
|
+
"extensions": {
|
|
224
|
+
"persistedQuery": {
|
|
225
|
+
"version": 1,
|
|
226
|
+
"sha256Hash": self.part_hash("addToLibrary"),
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
resp = self.client.post(url, json=payload, authenticate=True)
|
|
232
|
+
|
|
233
|
+
if resp.fail:
|
|
234
|
+
raise SongError("Could not like song", error=resp.error.string)
|
spotapi/user.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
from spotapi.exceptions import UserError
|
|
6
|
+
from spotapi.login import Login
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class User(Login):
|
|
10
|
+
"""
|
|
11
|
+
Represents a Spotify user.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __new__(cls, login: Login) -> User:
|
|
15
|
+
instance = super(User, cls).__new__(cls)
|
|
16
|
+
instance.__dict__.update(login.__dict__)
|
|
17
|
+
return instance
|
|
18
|
+
|
|
19
|
+
def __init__(self, login: Login) -> None:
|
|
20
|
+
if not login.logged_in:
|
|
21
|
+
raise ValueError("Must be logged in")
|
|
22
|
+
|
|
23
|
+
self._user_plan: Mapping[str, Any] = None
|
|
24
|
+
self._user_info: Mapping[str, Any] = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def has_premium(self) -> bool:
|
|
28
|
+
if self._user_plan is None:
|
|
29
|
+
self.get_plan_info()
|
|
30
|
+
|
|
31
|
+
return self._user_plan["plan"]["name"] != "Spotify Free"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def username(self) -> str:
|
|
35
|
+
if self._user_info is None:
|
|
36
|
+
self._user_info = self.get_user_info()
|
|
37
|
+
|
|
38
|
+
return self._user_info["profile"]["username"]
|
|
39
|
+
|
|
40
|
+
def get_plan_info(self) -> Mapping[str, Any]:
|
|
41
|
+
"""Gets user plan info."""
|
|
42
|
+
url = "https://www.spotify.com/ca-en/api/account/v2/plan/"
|
|
43
|
+
resp = self.client.get(url)
|
|
44
|
+
|
|
45
|
+
if resp.fail:
|
|
46
|
+
raise UserError("Could not get user plan info", error=resp.error.string)
|
|
47
|
+
|
|
48
|
+
if not isinstance(resp.response, Mapping):
|
|
49
|
+
raise UserError("Invalid JSON")
|
|
50
|
+
|
|
51
|
+
self._user_plan = resp.response
|
|
52
|
+
return resp.response
|
|
53
|
+
|
|
54
|
+
def verify_login(self) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
self.get_plan_info()
|
|
57
|
+
except Exception as e:
|
|
58
|
+
if "401" in str(e):
|
|
59
|
+
return False
|
|
60
|
+
else:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def get_user_info(self) -> Mapping[str, Any]:
|
|
64
|
+
"""Gets accounts user info."""
|
|
65
|
+
url = "https://www.spotify.com/api/account-settings/v1/profile"
|
|
66
|
+
resp = self.client.get(url)
|
|
67
|
+
|
|
68
|
+
if resp.fail:
|
|
69
|
+
raise UserError("Could not get user info", error=resp.error.string)
|
|
70
|
+
|
|
71
|
+
if not isinstance(resp.response, Mapping):
|
|
72
|
+
raise UserError("Invalid JSON")
|
|
73
|
+
|
|
74
|
+
self.csrf_token = resp.raw.headers.get("X-Csrf-Token")
|
|
75
|
+
return resp.response
|
|
76
|
+
|
|
77
|
+
def edit_user_info(self, dump: Mapping[str, Any]) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Edits account user account info.
|
|
80
|
+
For this function to work, dump must be the entire profile dump.
|
|
81
|
+
You can get this dump from get_user_info, then change the fields you want.
|
|
82
|
+
"""
|
|
83
|
+
captcha_response = self.solver.solve_captcha(
|
|
84
|
+
"https://www.spotify.com",
|
|
85
|
+
"6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
|
|
86
|
+
"account_settings/profile_update",
|
|
87
|
+
"v3",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if not captcha_response:
|
|
91
|
+
raise UserError("Could not solve captcha")
|
|
92
|
+
|
|
93
|
+
profile_dump = dump["profile"]
|
|
94
|
+
dump = {
|
|
95
|
+
"profile": {
|
|
96
|
+
"email": profile_dump["email"],
|
|
97
|
+
"gender": profile_dump["gender"],
|
|
98
|
+
"birthdate": profile_dump["birthdate"],
|
|
99
|
+
"country": profile_dump["country"],
|
|
100
|
+
},
|
|
101
|
+
"recaptcha_token": captcha_response,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
url = "https://www.spotify.com/api/account-settings/v1/profile"
|
|
105
|
+
|
|
106
|
+
headers = {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"X-Csrf-Token": self.csrf_token,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resp = self.client.put(url, json=dump, headers=headers)
|
|
112
|
+
|
|
113
|
+
if resp.fail:
|
|
114
|
+
raise UserError("Could not edit user info", error=resp.error.string)
|
spotapi/websocket.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from websockets.sync.client import connect
|
|
3
|
+
from spotapi.login import Login
|
|
4
|
+
from spotapi.client import BaseClient
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import threading
|
|
7
|
+
import atexit
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebsocketStreamer(BaseClient, Login):
|
|
13
|
+
"""
|
|
14
|
+
Standard streamer to connect to spotify's websocket API.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __new__(cls, login: Login) -> WebsocketStreamer:
|
|
18
|
+
instance = super().__new__(cls)
|
|
19
|
+
instance.__dict__.update(login.__dict__)
|
|
20
|
+
return instance
|
|
21
|
+
|
|
22
|
+
def __init__(self, login: Login, ignore_init_packet: Optional[bool] = True):
|
|
23
|
+
if not login.logged_in:
|
|
24
|
+
raise ValueError("Must be logged in")
|
|
25
|
+
|
|
26
|
+
super().__init__(client=login.client)
|
|
27
|
+
|
|
28
|
+
self.get_session()
|
|
29
|
+
self.get_client_token()
|
|
30
|
+
|
|
31
|
+
uri = f"wss://gue1-dealer2.spotify.com/?access_token={self.access_token}"
|
|
32
|
+
self.ws = connect(
|
|
33
|
+
uri,
|
|
34
|
+
user_agent_header="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.rlock = threading.Lock()
|
|
38
|
+
self.ws_dump: dict = None
|
|
39
|
+
|
|
40
|
+
if ignore_init_packet:
|
|
41
|
+
self.get_init_packet()
|
|
42
|
+
|
|
43
|
+
threading.Thread(target=self.keep_alive, daemon=True).start()
|
|
44
|
+
atexit.register(self.ws.close)
|
|
45
|
+
|
|
46
|
+
def keep_alive(self) -> None:
|
|
47
|
+
while True:
|
|
48
|
+
# We need to make sure the ws doesn't read the PONG
|
|
49
|
+
with self.rlock:
|
|
50
|
+
self.ws.send('{"type":"ping"}')
|
|
51
|
+
self.ws.recv()
|
|
52
|
+
|
|
53
|
+
time.sleep(60)
|
|
54
|
+
|
|
55
|
+
def get_packet(self) -> dict:
|
|
56
|
+
with self.rlock:
|
|
57
|
+
self.ws_dump = dict(json.loads(self.ws.recv()))
|
|
58
|
+
return self.ws_dump
|
|
59
|
+
|
|
60
|
+
def get_main_packet(self) -> dict:
|
|
61
|
+
"""Eqivalent to get_packet() just asserts that the init packet has been consumed"""
|
|
62
|
+
with self.rlock:
|
|
63
|
+
self.ws_dump = dict(json.loads(self.ws.recv()))
|
|
64
|
+
|
|
65
|
+
is_not_init_packet: bool = (self.ws_dump.get("headers") is None) or (
|
|
66
|
+
dict(self.ws_dump["headers"]).get("Spotify-Connection-Id") is None
|
|
67
|
+
)
|
|
68
|
+
assert (
|
|
69
|
+
not is_not_init_packet
|
|
70
|
+
), "Init packet has not been consumed yet, restart the streamer."
|
|
71
|
+
|
|
72
|
+
return self.ws_dump
|
|
73
|
+
|
|
74
|
+
def get_init_packet(self) -> str:
|
|
75
|
+
"""Gets the Spotify Connection ID in the init packet"""
|
|
76
|
+
packet = self.get_packet()
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
packet.get("headers") is None
|
|
80
|
+
or dict(packet["headers"]).get("Spotify-Connection-Id") is None
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("Invalid init packet")
|
|
83
|
+
|
|
84
|
+
return packet["headers"]["Spotify-Connection-Id"]
|
|
85
|
+
|
|
86
|
+
def get_player_state(self) -> dict:
|
|
87
|
+
self.get_main_packet()
|
|
88
|
+
payload = self.ws_dump["payload"][0]
|
|
89
|
+
return payload["cluster"]["player_state"]
|