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/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
|
+
)
|