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/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"]