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/family.py ADDED
@@ -0,0 +1,130 @@
1
+ import uuid
2
+ from spotapi.user import User
3
+ from spotapi.login import Login
4
+ from typing import Mapping, Any, List
5
+ from spotapi.exceptions.errors import FamilyError
6
+ from spotapi.utils.strings import parse_json_string
7
+
8
+
9
+ class JoinFamily(User):
10
+ """
11
+ Wrapper class for joining a family with a user and a host provided.
12
+ """
13
+
14
+ def __new__(cls, user_login: Login, host: "Family", country: str) -> "JoinFamily":
15
+ instance = super(User, cls).__new__(cls)
16
+ return instance
17
+
18
+ def __init__(self, user_login: Login, host: "Family", country: str) -> None:
19
+ super().__init__(user_login)
20
+ self.host = host
21
+ self.country = country
22
+ self.client = user_login.client
23
+
24
+ self.family = self.host.get_family_home()
25
+ self.address = self.family["address"]
26
+ self.invite_token = self.family["inviteToken"]
27
+
28
+ self.session_id = str(uuid.uuid4())
29
+
30
+ def __get_session(self) -> None:
31
+ url = f"https://www.spotify.com/ca-en/family/join/address/{self.invite_token}/"
32
+ resp = self.client.get(url)
33
+
34
+ if resp.fail:
35
+ raise FamilyError("Could not get session", error=resp.error.string)
36
+
37
+ self.csrf = parse_json_string(resp.response, "csrfToken")
38
+
39
+ def __get_autocomplete(self, address: str) -> None:
40
+ url = "https://www.spotify.com/api/mup/addresses/v1/address/autocomplete/"
41
+ payload = {
42
+ "text": address,
43
+ "country": self.country,
44
+ "sessionToken": self.session_id,
45
+ }
46
+ resp = self.client.post(url, headers={"X-Csrf-Token": self.csrf}, json=payload)
47
+
48
+ if resp.fail:
49
+ raise FamilyError("Could not get address", error=resp.error.string)
50
+
51
+ self.addresses = resp.response["addresses"]
52
+ self.csrf = resp.raw.headers.get("X-Csrf-Token")
53
+
54
+ def __try_address(self, dump: dict) -> bool:
55
+ url = "https://www.spotify.com/api/mup/addresses/v1/user/confirm-user-address/"
56
+ payload = {
57
+ "address_google_place_id": dump["address"]["googlePlaceId"],
58
+ "session_token": self.session_id,
59
+ }
60
+ resp = self.client.post(url, headers={"X-Csrf-Token": self.csrf}, json=payload)
61
+
62
+ self.csrf = resp.raw.headers.get("X-Csrf-Token")
63
+ if resp.fail:
64
+ return False
65
+
66
+ return True
67
+
68
+ def __get_address(self) -> str:
69
+ self.__get_session()
70
+ self.__get_autocomplete(self.address)
71
+
72
+ for address in self.addresses:
73
+ if self.__try_address(address):
74
+ return address["address"]["googlePlaceId"]
75
+
76
+ raise FamilyError("Could not get address")
77
+
78
+ def __add_to_family(self, place_id: str) -> None:
79
+ url = "https://www.spotify.com/api/family/v1/family/member/"
80
+ payload = {
81
+ "address": self.address,
82
+ "placeId": place_id,
83
+ "inviteToken": self.invite_token,
84
+ }
85
+ resp = self.client.post(url, headers={"X-Csrf-Token": self.csrf}, json=payload)
86
+
87
+ if resp.fail:
88
+ raise FamilyError("Could not add to family", error=resp.error.string)
89
+
90
+ def add_to_family(self) -> None:
91
+ place_id = self.__get_address()
92
+ self.__add_to_family(place_id)
93
+
94
+
95
+ class Family(User):
96
+ """
97
+ Spotify Family generic methods.
98
+ """
99
+
100
+ def __init__(self, login: Login) -> None:
101
+ super().__init__(login)
102
+
103
+ if not self.has_premium:
104
+ raise ValueError("Must have premium to use this class")
105
+
106
+ self._user_family: Mapping[str, Any] = None
107
+
108
+ def get_family_home(self) -> Mapping[str, Any]:
109
+ url = "https://www.spotify.com/api/family/v1/family/home/"
110
+ resp = self.client.get(url)
111
+
112
+ if resp.fail:
113
+ raise FamilyError("Could not get user plan info", error=resp.error.string)
114
+
115
+ if not isinstance(resp.response, Mapping):
116
+ raise FamilyError("Invalid JSON")
117
+
118
+ self._user_family = resp.response
119
+ return resp.response
120
+
121
+ @property
122
+ def members(self) -> List[Mapping[str, Any]]:
123
+ if self._user_family is None:
124
+ self.get_family_home()
125
+
126
+ return self._user_family["members"]
127
+
128
+ @property
129
+ def enough_space(self) -> bool:
130
+ return len(self.members) < 6
spotapi/login.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Mapping, Optional, Type
5
+ from urllib.parse import urlencode
6
+
7
+ from spotapi.data import Config, SaverProtocol
8
+ from spotapi.exceptions import LoginError
9
+ from spotapi.utils.strings import parse_json_string
10
+
11
+
12
+ class Login:
13
+ """
14
+ Base class for logging in to Spotify.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ cfg: Config,
20
+ password: str,
21
+ *,
22
+ email: Optional[str] = None,
23
+ username: Optional[str] = None,
24
+ ):
25
+ self.solver = cfg.solver
26
+ self.client = cfg.client
27
+ self.logger = cfg.logger
28
+
29
+ self.password = password
30
+ self.identifier_credentials = username or email
31
+
32
+ if not self.identifier_credentials:
33
+ raise ValueError("Must provide an email or username")
34
+
35
+ if not self.solver:
36
+ raise ValueError("Must provide a Captcha solver")
37
+
38
+ self.client.fail_exception = LoginError
39
+ self._authorized = False
40
+
41
+ def save(self, saver: Type[SaverProtocol]) -> None:
42
+ """
43
+ Saves the session with the provided Saver.
44
+ """
45
+ if not self.logged_in:
46
+ raise ValueError("Cannot save session if it is not logged in")
47
+
48
+ saver.save(
49
+ [
50
+ {
51
+ "identifier": self.identifier_credentials,
52
+ "password": self.password,
53
+ "cookies": self.client.cookies.get_dict(),
54
+ }
55
+ ]
56
+ )
57
+
58
+ @classmethod
59
+ def from_cookies(cls, dump: Mapping[str, Any], cfg: Config) -> Login:
60
+ """
61
+ Constructs a Login instance using cookie data and configuration.
62
+ """
63
+ password = dump.get("password")
64
+ cred = dump.get("identifier")
65
+ cookies: Mapping[str, Any] = dump.get("cookies")
66
+
67
+ if not (password and cred and cookies):
68
+ raise ValueError(
69
+ "Invalid dump format: must contain 'password', 'identifier', and 'cookies'"
70
+ )
71
+
72
+ cfg.client.cookies.clear()
73
+ for k, v in cookies.items():
74
+ cfg.client.cookies.set(k, v)
75
+
76
+ instantiated = cls(cfg, password, email=cred, username=cred)
77
+ instantiated.logged_in = True
78
+
79
+ return instantiated
80
+
81
+ @classmethod
82
+ def from_saver(
83
+ cls, saver: Type[SaverProtocol], cfg: Config, identifier: str
84
+ ) -> Login:
85
+ """
86
+ Loads a session from a Saver Class.
87
+
88
+ Note: Kwargs are not used, make sure the defaults for the savers are what you want (or just implement this method yourself).
89
+ """
90
+ dump = saver.load(query={"identifier": identifier})
91
+ return cls.from_cookies(dump, cfg)
92
+
93
+ @property
94
+ def logged_in(self) -> bool:
95
+ return self._authorized
96
+
97
+ @logged_in.setter
98
+ def logged_in(self, value: bool):
99
+ self._authorized = value
100
+
101
+ def __str__(self) -> str:
102
+ return f"Login(password={self.password!r}, identifier_credentials={self.identifier_credentials!r})"
103
+
104
+ def __get_session(self) -> None:
105
+ url = "https://accounts.spotify.com/en/login"
106
+ resp = self.client.get(url)
107
+
108
+ if resp.fail:
109
+ raise LoginError("Could not get session", error=resp.error.string)
110
+
111
+ self.csrf_token = resp.raw.cookies.get("sp_sso_csrf_token")
112
+ self.flow_id = parse_json_string(resp.response, "flowCtx")
113
+
114
+ def __password_payload(self, captcha_key: str) -> str:
115
+ query = {
116
+ "username": self.identifier_credentials,
117
+ "password": self.password,
118
+ "remember": "true",
119
+ "recaptchaToken": captcha_key,
120
+ "continue": "https://accounts.spotify.com/en/status",
121
+ "flowCtx": self.flow_id,
122
+ }
123
+
124
+ return urlencode(query)
125
+
126
+ def __submit_password(self, token: str) -> None:
127
+ payload = self.__password_payload(token)
128
+ url = "https://accounts.spotify.com/login/password"
129
+ headers = {
130
+ "Content-Type": "application/x-www-form-urlencoded",
131
+ "X-Csrf-Token": self.csrf_token,
132
+ }
133
+
134
+ resp = self.client.post(url, data=payload, headers=headers)
135
+
136
+ if resp.fail:
137
+ raise LoginError("Could not submit password", error=resp.error.string)
138
+
139
+ self.csrf_token = resp.raw.cookies.get("sp_sso_csrf_token")
140
+ self.handle_login_error(resp.response)
141
+ self.logged_in = True
142
+
143
+ def handle_login_error(self, json_data: Mapping[str, Any]) -> None:
144
+ if json_data.get("result") == "ok":
145
+ return
146
+
147
+ if json_data.get("result") == "redirect_required":
148
+ self.logger.attempt("Challenge detected, attempting to solve")
149
+ LoginChallenge(self, json_data).defeat()
150
+ self.logger.info("Challenge solved")
151
+ # json_data will still be bad, but we know we are logged in now
152
+ return
153
+
154
+ if not ("error" in json_data):
155
+ raise LoginError(f"Unexpected response format: {json_data}")
156
+
157
+ error_type = json_data["error"]
158
+
159
+ match (error_type):
160
+ case "errorUnknown":
161
+ raise LoginError("ErrorUnknown, Needs retrying")
162
+ case "errorInvalidCredentials":
163
+ raise LoginError(
164
+ "Invalid Credentials", error=f"{str(self)}: {error_type}"
165
+ )
166
+ case _:
167
+ raise LoginError(f"Unforseen Error", error=f"{str(self)}: {error_type}")
168
+
169
+ def login(self) -> None:
170
+ """Logins the user"""
171
+ now = time.time()
172
+ self.__get_session()
173
+
174
+ self.logger.attempt("Solving captcha...")
175
+ captcha_response = self.solver.solve_captcha(
176
+ "https://accounts.spotify.com/en/login",
177
+ "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
178
+ "accounts/login",
179
+ "v3",
180
+ )
181
+
182
+ if not captcha_response:
183
+ raise LoginError("Could not solve captcha")
184
+
185
+ self.logger.info("Solved Captcha", time_taken=f"{int(time.time() - now)}s")
186
+ self.__submit_password(captcha_response)
187
+ self.logger.info(
188
+ "Logged in successfully", time_taken=f"{int(time.time() - now)}s"
189
+ )
190
+
191
+
192
+ class LoginChallenge:
193
+ def __init__(self, login: Login, dump: Mapping[str, Any]) -> None:
194
+ self.l = login
195
+ self.dump = dump
196
+
197
+ self.challenge_url = self.dump["data"]["redirect_url"]
198
+ self.interaction_hash: str = None
199
+ self.interaction_reference: str = None
200
+ self.challenge_session_id: str = None
201
+
202
+ def __get_challenge(self) -> None:
203
+ resp = self.l.client.get(self.challenge_url)
204
+
205
+ if resp.fail:
206
+ raise LoginError("Could not get challenge", error=resp.error.string)
207
+
208
+ def __construct_challenge_payload(self) -> Mapping[str, Any]:
209
+ captcha_response = self.l.solver.solve_captcha(
210
+ self.challenge_url,
211
+ "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
212
+ "accounts/login",
213
+ "v3",
214
+ )
215
+
216
+ if not captcha_response:
217
+ raise LoginError("Could not solve captcha")
218
+
219
+ self.session_id = self.challenge_url.split("c/")[1].split("/")[0]
220
+ challenge_id = self.challenge_url.split(self.session_id + "/")[1].split("/")[0]
221
+
222
+ uri = "https://challenge.spotify.com/api/v1/invoke-challenge-command"
223
+ payload = {
224
+ "session_id": self.session_id,
225
+ "challenge_id": challenge_id,
226
+ "recaptcha_challenge_id": {"solve": {"recaptcha_token": captcha_response}},
227
+ }
228
+ headers = {
229
+ "X-Cloud-Trace-Context": "00000000000000006979d1624aa6b213/2238380859227873585;o=1",
230
+ "Content-Type": "application/json",
231
+ }
232
+
233
+ return {"url": uri, "json": payload, "headers": headers}
234
+
235
+ def __submit_challenge(self) -> None:
236
+ payload = self.__construct_challenge_payload()
237
+ resp = self.l.client.post(**payload)
238
+
239
+ if resp.fail:
240
+ raise LoginError("Could not submit challenge", error=resp.error.string)
241
+
242
+ if not isinstance(resp.response, Mapping):
243
+ raise LoginError("Invalid JSON")
244
+
245
+ self.interaction_hash = resp.response["Completed"]["Hash"]
246
+ self.interaction_reference = resp.response["Completed"]["InteractionReference"]
247
+
248
+ def __complete_challenge(self) -> None:
249
+ # We need to grab the cookies
250
+ url = f"https://accounts.spotify.com/login/challenge-completed?sessionId={self.session_id}&interactRef={self.interaction_reference}&hash={self.interaction_hash}"
251
+
252
+ resp = self.l.client.get(url)
253
+
254
+ if resp.fail:
255
+ raise LoginError("Could not complete challenge", error=resp.error.string)
256
+
257
+ def defeat(self) -> None:
258
+ self.__get_challenge()
259
+ self.__submit_challenge()
260
+ self.__complete_challenge()
spotapi/password.py ADDED
@@ -0,0 +1,76 @@
1
+ from spotapi.utils.strings import parse_json_string
2
+ from spotapi.exceptions import PasswordError
3
+ from spotapi.data.data import Config
4
+ from typing import Optional
5
+ import time
6
+ import uuid
7
+
8
+
9
+ class Password:
10
+ """
11
+ Preforms password recoveries.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ cfg: Config,
17
+ *,
18
+ email: Optional[str] = None,
19
+ username: Optional[str] = None,
20
+ ):
21
+ self.solver = cfg.solver
22
+ self.client = cfg.client
23
+ self.logger = cfg.logger
24
+
25
+ self.identifier_credentials = username or email
26
+
27
+ if not self.identifier_credentials:
28
+ raise ValueError("Must provide an email or username")
29
+
30
+ def __get_session(self) -> None:
31
+ url = "https://accounts.spotify.com/en/password-reset"
32
+ resp = self.client.get(url)
33
+
34
+ if resp.fail:
35
+ raise PasswordError("Could not get session", error=resp.error.string)
36
+
37
+ self.csrf = parse_json_string(resp.response, "csrf")
38
+ self.flowID = str(uuid.uuid4())
39
+
40
+ def __reset_password(self, token: str) -> None:
41
+ payload = {
42
+ "captcha": token,
43
+ "emailOrUsername": self.identifier_credentials,
44
+ "flowId": self.flowID,
45
+ }
46
+ url = "https://accounts.spotify.com/api/password/recovery"
47
+ headers = {
48
+ "X-Csrf-Token": self.csrf,
49
+ }
50
+
51
+ resp = self.client.post(url, data=payload, headers=headers)
52
+
53
+ if resp.fail:
54
+ raise PasswordError("Could not reset password", error=resp.error.string)
55
+
56
+ def reset(self) -> None:
57
+ self.__get_session()
58
+
59
+ now = time.time()
60
+ self.logger.attempt("Solving captcha...")
61
+
62
+ captcha_response = self.solver.solve_captcha(
63
+ "https://accounts.spotify.com/en/password-reset",
64
+ "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
65
+ "password_reset_web/recovery",
66
+ "v3",
67
+ )
68
+
69
+ if not captcha_response:
70
+ raise PasswordError("Could not solve captcha")
71
+
72
+ self.logger.info("Solved Captcha", time_taken=f"{int(time.time() - now)}s")
73
+ self.__reset_password(captcha_response)
74
+ self.logger.info(
75
+ "Successfully reset password", time_taken=f"{int(time.time() - now)}s"
76
+ )