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/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from spotapi.artist import Artist
|
|
2
|
+
from spotapi.client import BaseClient
|
|
3
|
+
from spotapi.login import Login
|
|
4
|
+
from spotapi.playlist import PrivatePlaylist, PublicPlaylist
|
|
5
|
+
from spotapi.song import Song
|
|
6
|
+
from spotapi.user import User
|
|
7
|
+
from spotapi.creator import Creator
|
|
8
|
+
from spotapi.password import Password
|
|
9
|
+
from spotapi.solvers import solver_clients, solver_clients_str
|
|
10
|
+
from spotapi.data.data import Config
|
|
11
|
+
from spotapi.utils.logger import Logger, NoopLogger
|
|
12
|
+
from spotapi.utils.saver import *
|
|
13
|
+
from spotapi.websocket import WebsocketStreamer
|
|
14
|
+
from spotapi.family import Family, JoinFamily
|
|
15
|
+
|
|
16
|
+
__author__ = "Aran"
|
|
17
|
+
__license__ = "GPL 3.0"
|
spotapi/artist.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Generator, Literal, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
from spotapi.client import BaseClient
|
|
7
|
+
from spotapi.exceptions import ArtistError
|
|
8
|
+
from spotapi.http.request import TLSClient
|
|
9
|
+
from spotapi.login import Login
|
|
10
|
+
from spotapi.client import BaseClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Artist(BaseClient, Login):
|
|
14
|
+
"""
|
|
15
|
+
A class that represents an artist in the Spotify catalog.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __new__(cls, login: Optional[Login] = None) -> Artist:
|
|
19
|
+
instance = super(Artist, cls).__new__(cls)
|
|
20
|
+
if login:
|
|
21
|
+
instance.__dict__.update(login.__dict__)
|
|
22
|
+
return instance
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
login: Optional[Login] = None,
|
|
27
|
+
*,
|
|
28
|
+
client: Optional[TLSClient] = TLSClient("chrome_120", "", auto_retries=3),
|
|
29
|
+
) -> None:
|
|
30
|
+
if login and not login.logged_in:
|
|
31
|
+
raise ValueError("Must be logged in")
|
|
32
|
+
|
|
33
|
+
self._login: bool = bool(login)
|
|
34
|
+
super().__init__(client=login.client if self._login else client)
|
|
35
|
+
|
|
36
|
+
def query_artists(
|
|
37
|
+
self, query: str, /, limit: Optional[int] = 10, *, offset: Optional[int] = 0
|
|
38
|
+
) -> Mapping[str, Any]:
|
|
39
|
+
"""Searches for an artist in the Spotify catalog"""
|
|
40
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
41
|
+
params = {
|
|
42
|
+
"operationName": "searchArtists",
|
|
43
|
+
"variables": json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"searchTerm": query,
|
|
46
|
+
"offset": offset,
|
|
47
|
+
"limit": limit,
|
|
48
|
+
"numberOfTopResults": 5,
|
|
49
|
+
"includeAudiobooks": True,
|
|
50
|
+
"includePreReleases": False,
|
|
51
|
+
}
|
|
52
|
+
),
|
|
53
|
+
"extensions": json.dumps(
|
|
54
|
+
{
|
|
55
|
+
"persistedQuery": {
|
|
56
|
+
"version": 1,
|
|
57
|
+
"sha256Hash": self.part_hash("searchArtists"),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
resp = self.client.post(url, params=params, authenticate=True)
|
|
64
|
+
|
|
65
|
+
if resp.fail:
|
|
66
|
+
raise ArtistError("Could not get artists", error=resp.error.string)
|
|
67
|
+
|
|
68
|
+
if not isinstance(resp.response, Mapping):
|
|
69
|
+
raise ArtistError("Invalid JSON")
|
|
70
|
+
|
|
71
|
+
return resp.response
|
|
72
|
+
|
|
73
|
+
def paginate_artists(
|
|
74
|
+
self, query: str, /
|
|
75
|
+
) -> Generator[Mapping[str, Any], None, None]:
|
|
76
|
+
"""
|
|
77
|
+
Generator that fetches artists in chunks
|
|
78
|
+
|
|
79
|
+
Note: If total_tracks <= 100, then there is no need to paginate
|
|
80
|
+
"""
|
|
81
|
+
UPPER_LIMIT: int = 100
|
|
82
|
+
|
|
83
|
+
# We need to get the total artists first
|
|
84
|
+
artists = self.query_artists(query, limit=UPPER_LIMIT)
|
|
85
|
+
total_count: int = artists["data"]["searchV2"]["artists"]["totalCount"]
|
|
86
|
+
|
|
87
|
+
yield artists["data"]["searchV2"]["artists"]["items"]
|
|
88
|
+
|
|
89
|
+
if total_count <= UPPER_LIMIT:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
offset = UPPER_LIMIT
|
|
93
|
+
while offset < total_count:
|
|
94
|
+
yield self.query_artists(query, limit=UPPER_LIMIT, offset=offset)["data"][
|
|
95
|
+
"searchV2"
|
|
96
|
+
]["artists"]["items"]
|
|
97
|
+
offset += UPPER_LIMIT
|
|
98
|
+
|
|
99
|
+
def __do_follow(
|
|
100
|
+
self,
|
|
101
|
+
artist_id: str,
|
|
102
|
+
/,
|
|
103
|
+
*,
|
|
104
|
+
action: Optional[Literal["addToLibrary", "removeFromLibrary"]] = "addToLibrary",
|
|
105
|
+
) -> None:
|
|
106
|
+
if not self._login:
|
|
107
|
+
raise ValueError("Must be logged in")
|
|
108
|
+
|
|
109
|
+
if "artist:" in artist_id:
|
|
110
|
+
artist_id = artist_id.split("artist:")[1]
|
|
111
|
+
|
|
112
|
+
url = "https://api-partner.spotify.com/pathfinder/v1/query"
|
|
113
|
+
payload = {
|
|
114
|
+
"variables": {
|
|
115
|
+
"uris": [
|
|
116
|
+
f"spotify:artist:{artist_id}",
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
"operationName": action,
|
|
120
|
+
"extensions": {
|
|
121
|
+
"persistedQuery": {
|
|
122
|
+
"version": 1,
|
|
123
|
+
"sha256Hash": self.part_hash(action),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
resp = self.client.post(url, json=payload, authenticate=True)
|
|
129
|
+
|
|
130
|
+
if resp.fail:
|
|
131
|
+
raise ArtistError("Could not follow artist", error=resp.error.string)
|
|
132
|
+
|
|
133
|
+
def follow(self, artist_id: str, /) -> None:
|
|
134
|
+
"""Follow an artist"""
|
|
135
|
+
return self.__do_follow(artist_id)
|
|
136
|
+
|
|
137
|
+
def unfollow(self, artist_id: str, /) -> None:
|
|
138
|
+
"""Unfollow an artist"""
|
|
139
|
+
return self.__do_follow(artist_id, action="removeFromLibrary")
|
spotapi/client.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import atexit
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
from spotapi.exceptions import BaseClientError
|
|
6
|
+
from spotapi.http.request import TLSClient
|
|
7
|
+
from spotapi.utils.strings import parse_json_string
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseClient:
|
|
11
|
+
"""
|
|
12
|
+
A base class that all the Spotify classes extend.
|
|
13
|
+
This base class contains all the common methods used by the Spotify classes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: TLSClient) -> None:
|
|
17
|
+
self.client = client
|
|
18
|
+
self.client.authenticate = lambda kwargs: self._auth_rule(kwargs)
|
|
19
|
+
|
|
20
|
+
self.js_pack: str = None
|
|
21
|
+
self.client_version: str = None
|
|
22
|
+
self.access_token: str = None
|
|
23
|
+
self.client_token: str = None
|
|
24
|
+
self.client_id: str = None
|
|
25
|
+
self.device_id: str = None
|
|
26
|
+
self.raw_hashes: str = None
|
|
27
|
+
|
|
28
|
+
self.browser_version = self.client.client_identifier.split("_")[1]
|
|
29
|
+
self.client.headers.update(
|
|
30
|
+
{
|
|
31
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
32
|
+
"User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{self.browser_version}.0.0.0 Safari/537.36",
|
|
33
|
+
"Sec-Ch-Ua": f'"Chromium";v="{self.browser_version}", "Not(A:Brand";v="24", "Google Chrome";v="{self.browser_version}"',
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
atexit.register(self.client.close)
|
|
38
|
+
|
|
39
|
+
def _auth_rule(self, kwargs: dict) -> dict:
|
|
40
|
+
if not self.client_token:
|
|
41
|
+
self.get_client_token()
|
|
42
|
+
|
|
43
|
+
if not self.access_token:
|
|
44
|
+
self.get_session()
|
|
45
|
+
|
|
46
|
+
if not ("headers" in kwargs):
|
|
47
|
+
kwargs["headers"] = {}
|
|
48
|
+
|
|
49
|
+
kwargs["headers"].update(
|
|
50
|
+
{
|
|
51
|
+
"Authorization": "Bearer " + self.access_token,
|
|
52
|
+
"Client-Token": self.client_token,
|
|
53
|
+
"Spotify-App-Version": self.client_version,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return kwargs
|
|
58
|
+
|
|
59
|
+
def get_session(self) -> None:
|
|
60
|
+
resp = self.client.get(
|
|
61
|
+
"https://open.spotify.com",
|
|
62
|
+
headers={
|
|
63
|
+
"User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{self.browser_version}.0.0.0 Safari/537.36"
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if resp.fail:
|
|
68
|
+
raise BaseClientError("Could not get session", error=resp.error.string)
|
|
69
|
+
|
|
70
|
+
pattern = r"https:\/\/open\.spotifycdn\.com\/cdn\/build\/web-player\/web-player.*?\.js"
|
|
71
|
+
self.js_pack = re.findall(pattern, resp.response)[1]
|
|
72
|
+
self.access_token = parse_json_string(resp.response, "accessToken")
|
|
73
|
+
self.client_id = parse_json_string(resp.response, "clientId")
|
|
74
|
+
self.device_id = parse_json_string(resp.response, "correlationId")
|
|
75
|
+
|
|
76
|
+
def get_client_token(self) -> None:
|
|
77
|
+
if not (self.client_id and self.device_id):
|
|
78
|
+
self.get_session()
|
|
79
|
+
|
|
80
|
+
if not self.client_version:
|
|
81
|
+
self.get_sha256_hash()
|
|
82
|
+
|
|
83
|
+
url = "https://clienttoken.spotify.com/v1/clienttoken"
|
|
84
|
+
payload = {
|
|
85
|
+
"client_data": {
|
|
86
|
+
"client_version": self.client_version,
|
|
87
|
+
"client_id": self.client_id,
|
|
88
|
+
"js_sdk_data": {
|
|
89
|
+
"device_brand": "unknown",
|
|
90
|
+
"device_model": "unknown",
|
|
91
|
+
"os": "windows",
|
|
92
|
+
"os_version": "NT 10.0",
|
|
93
|
+
"device_id": self.device_id,
|
|
94
|
+
"device_type": "computer",
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
headers = {
|
|
99
|
+
"Authority": "clienttoken.spotify.com",
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
resp = self.client.post(url, json=payload, headers=headers)
|
|
105
|
+
|
|
106
|
+
if resp.fail:
|
|
107
|
+
raise BaseClientError("Could not get client token", error=resp.error.string)
|
|
108
|
+
|
|
109
|
+
if resp.response.get("response_type") != "RESPONSE_GRANTED_TOKEN_RESPONSE":
|
|
110
|
+
raise BaseClientError(
|
|
111
|
+
"Could not get client token", error=resp.response.get("response_type")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not isinstance(resp.response, Mapping):
|
|
115
|
+
raise BaseClientError("Invalid JSON")
|
|
116
|
+
|
|
117
|
+
self.client_token = resp.response["granted_token"]["token"]
|
|
118
|
+
|
|
119
|
+
def part_hash(self, name: str) -> str:
|
|
120
|
+
if not self.raw_hashes:
|
|
121
|
+
self.get_sha256_hash()
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return self.raw_hashes.split(f'"{name}","query","')[1].split('"')[0]
|
|
125
|
+
except IndexError:
|
|
126
|
+
return self.raw_hashes.split(f'"{name}","mutation","')[1].split('"')[0]
|
|
127
|
+
|
|
128
|
+
def get_sha256_hash(self) -> None:
|
|
129
|
+
if not self.js_pack:
|
|
130
|
+
self.get_session()
|
|
131
|
+
|
|
132
|
+
resp = self.client.get(self.js_pack)
|
|
133
|
+
|
|
134
|
+
if resp.fail:
|
|
135
|
+
raise BaseClientError(
|
|
136
|
+
"Could not get playlist hashes", error=resp.error.string
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self.raw_hashes = resp.response
|
|
140
|
+
self.client_version = resp.response.split('clientVersion:"')[1].split('"')[0]
|
|
141
|
+
# Maybe it's static? Let's not take chances.
|
|
142
|
+
self.xpui_route_num = resp.response.split(':"xpui-routes-search"')[0].split(
|
|
143
|
+
","
|
|
144
|
+
)[-1]
|
|
145
|
+
pattern = rf'{self.xpui_route_num}:"([^"]*)"'
|
|
146
|
+
self.xpui_route = re.findall(pattern, resp.response)[-1]
|
|
147
|
+
|
|
148
|
+
resp = self.client.get(
|
|
149
|
+
f"https://open.spotifycdn.com/cdn/build/web-player/xpui-routes-search.{self.xpui_route}.js"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if resp.fail:
|
|
153
|
+
raise BaseClientError("Could not get xpui hashes", error=resp.error.string)
|
|
154
|
+
|
|
155
|
+
self.raw_hashes += resp.response
|
|
156
|
+
|
|
157
|
+
def __str__(self) -> str:
|
|
158
|
+
return f"{self.__class__.__name__}(...)"
|
spotapi/creator.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from spotapi.data import Config
|
|
5
|
+
from spotapi.exceptions import GeneratorError
|
|
6
|
+
from spotapi.http.request import TLSClient
|
|
7
|
+
from spotapi.utils.strings import (
|
|
8
|
+
random_email,
|
|
9
|
+
random_string,
|
|
10
|
+
parse_json_string,
|
|
11
|
+
random_dob,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Creator:
|
|
16
|
+
"""
|
|
17
|
+
Creates a new Spotify account.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
cfg: Config,
|
|
23
|
+
email: Optional[str] = random_email(),
|
|
24
|
+
password: Optional[str] = random_string(10, True),
|
|
25
|
+
) -> None:
|
|
26
|
+
self.email = email
|
|
27
|
+
self.password = password
|
|
28
|
+
self.cfg = cfg
|
|
29
|
+
|
|
30
|
+
self.client = self.cfg.client
|
|
31
|
+
self.submission_id = str(uuid.uuid4())
|
|
32
|
+
|
|
33
|
+
def __get_session(self) -> None:
|
|
34
|
+
url = "https://www.spotify.com/ca-en/signup"
|
|
35
|
+
request = self.client.get(url)
|
|
36
|
+
|
|
37
|
+
if request.fail:
|
|
38
|
+
raise GeneratorError("Could not get session", error=request.error.string)
|
|
39
|
+
|
|
40
|
+
self.api_key = parse_json_string(request.response, "signupServiceAppKey")
|
|
41
|
+
self.installation_id = parse_json_string(request.response, "spT")
|
|
42
|
+
self.csrf_token = parse_json_string(request.response, "csrfToken")
|
|
43
|
+
self.flow_id = parse_json_string(request.response, "flowId")
|
|
44
|
+
|
|
45
|
+
def __process_register(self, captcha_token: str) -> None:
|
|
46
|
+
payload = {
|
|
47
|
+
"account_details": {
|
|
48
|
+
"birthdate": random_dob(),
|
|
49
|
+
"consent_flags": {
|
|
50
|
+
"eula_agreed": True,
|
|
51
|
+
"send_email": True,
|
|
52
|
+
"third_party_email": False,
|
|
53
|
+
},
|
|
54
|
+
"display_name": "Aran",
|
|
55
|
+
"email_and_password_identifier": {
|
|
56
|
+
"email": self.email,
|
|
57
|
+
"password": self.password,
|
|
58
|
+
},
|
|
59
|
+
"gender": 1,
|
|
60
|
+
},
|
|
61
|
+
"callback_uri": f"https://www.spotify.com/signup/challenge?flow_ctx={self.flow_id}%{int(time.time())}&locale=ca-en",
|
|
62
|
+
"client_info": {
|
|
63
|
+
"api_key": self.api_key,
|
|
64
|
+
"app_version": "v2",
|
|
65
|
+
"capabilities": [1],
|
|
66
|
+
"installation_id": self.installation_id,
|
|
67
|
+
"platform": "www",
|
|
68
|
+
},
|
|
69
|
+
"tracking": {
|
|
70
|
+
"creation_flow": "",
|
|
71
|
+
"creation_point": "spotify.com",
|
|
72
|
+
"referrer": "",
|
|
73
|
+
},
|
|
74
|
+
"recaptcha_token": captcha_token,
|
|
75
|
+
"submission_id": self.submission_id,
|
|
76
|
+
"flow_id": self.flow_id,
|
|
77
|
+
}
|
|
78
|
+
url = "https://spclient.wg.spotify.com/signup/public/v2/account/create"
|
|
79
|
+
|
|
80
|
+
request = self.client.post(url, json=payload)
|
|
81
|
+
|
|
82
|
+
if request.fail:
|
|
83
|
+
raise GeneratorError(
|
|
84
|
+
"Could not process registration", error=request.error.string
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if "challenge" in request.response:
|
|
88
|
+
self.cfg.logger.attempt("Encountered Embedded Challenge. Defeating...")
|
|
89
|
+
AccountChallenge(self.client, request.response, self.cfg).defeat_challenge()
|
|
90
|
+
|
|
91
|
+
def register(self) -> None:
|
|
92
|
+
self.__get_session()
|
|
93
|
+
captcha_token = self.cfg.solver.solve_captcha(
|
|
94
|
+
"https://www.spotify.com/ca-en/signup",
|
|
95
|
+
"6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
|
|
96
|
+
"website/signup/submit_email",
|
|
97
|
+
"v3",
|
|
98
|
+
)
|
|
99
|
+
self.__process_register(captcha_token)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AccountChallenge:
|
|
103
|
+
def __init__(self, client: TLSClient, raw_response: str, cfg: Config) -> None:
|
|
104
|
+
self.client = client
|
|
105
|
+
self.raw = raw_response
|
|
106
|
+
self.session_id = parse_json_string(raw_response, "session_id")
|
|
107
|
+
self.cfg = cfg
|
|
108
|
+
|
|
109
|
+
def __get_session(self) -> None:
|
|
110
|
+
url = "https://challenge.spotify.com/api/v1/get-session"
|
|
111
|
+
payload = {"session_id": self.session_id}
|
|
112
|
+
request = self.client.post(url, json=payload)
|
|
113
|
+
|
|
114
|
+
if request.fail:
|
|
115
|
+
raise GeneratorError(
|
|
116
|
+
"Could not get challenge session", error=request.error.string
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self.challenge_url = parse_json_string(request.response, "url")
|
|
120
|
+
|
|
121
|
+
def __submit_challenge(self, token: str) -> None:
|
|
122
|
+
session_id = self.challenge_url.split("c/")[1].split("/")[0]
|
|
123
|
+
challenge_id = self.challenge_url.split(session_id + "/")[1].split("/")[0]
|
|
124
|
+
url = "https://challenge.spotify.com/api/v1/invoke-challenge-command"
|
|
125
|
+
payload = {
|
|
126
|
+
"session_id": session_id,
|
|
127
|
+
"challenge_id": challenge_id,
|
|
128
|
+
"recaptcha_challenge_v1": {"solve": {"recaptcha_token": token}},
|
|
129
|
+
}
|
|
130
|
+
request = self.client.post(
|
|
131
|
+
url,
|
|
132
|
+
json=payload,
|
|
133
|
+
headers={
|
|
134
|
+
"X-Cloud-Trace-Context": "000000000000000004ec7cfe60aa92b5/8088460714428896449;o=1"
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if request.fail:
|
|
139
|
+
raise GeneratorError(
|
|
140
|
+
"Could not submit challenge", error=request.error.string
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def __complete_challenge(self) -> None:
|
|
144
|
+
url = (
|
|
145
|
+
"https://spclient.wg.spotify.com/signup/public/v2/account/complete-creation"
|
|
146
|
+
)
|
|
147
|
+
payload = {"session_id": self.session_id}
|
|
148
|
+
request = self.client.post(url, json=payload)
|
|
149
|
+
|
|
150
|
+
if request.fail:
|
|
151
|
+
raise GeneratorError(
|
|
152
|
+
"Could not complete challenge", error=request.error.string
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if not ("success" in request.response):
|
|
156
|
+
raise GeneratorError("Could not complete challenge", error=request.response)
|
|
157
|
+
|
|
158
|
+
def defeat_challenge(self) -> None:
|
|
159
|
+
self.__get_session()
|
|
160
|
+
token = self.cfg.solver.solve_captcha(
|
|
161
|
+
self.challenge_url,
|
|
162
|
+
"6LeO36obAAAAALSBZrY6RYM1hcAY7RLvpDDcJLy3",
|
|
163
|
+
"challenge",
|
|
164
|
+
"v2",
|
|
165
|
+
)
|
|
166
|
+
self.__submit_challenge(token)
|
|
167
|
+
self.__complete_challenge()
|
|
168
|
+
self.cfg.logger.info("Successfully defeated challenge. Account created.")
|
spotapi/data/__init__.py
ADDED
spotapi/data/data.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Type
|
|
3
|
+
from spotapi.data.interfaces import CaptchaProtocol, LoggerProtocol
|
|
4
|
+
from spotapi.http.request import TLSClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
solver: Type[CaptchaProtocol]
|
|
10
|
+
logger: Type[LoggerProtocol]
|
|
11
|
+
client: TLSClient = field(default=TLSClient("chrome_120", "", auto_retries=3))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SolverConfig:
|
|
16
|
+
api_key: str
|
|
17
|
+
captcha_service: str
|
|
18
|
+
retries: int = field(default=120)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import Any, List, Literal, Mapping, Optional, Protocol
|
|
2
|
+
from typing_extensions import runtime_checkable
|
|
3
|
+
from spotapi.http.request import StdClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class CaptchaProtocol(Protocol):
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
api_key: str,
|
|
11
|
+
client: Optional[StdClient] = StdClient(3),
|
|
12
|
+
*,
|
|
13
|
+
proxy: Optional[str] = None,
|
|
14
|
+
retries: Optional[int] = 120,
|
|
15
|
+
) -> None:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def get_balance(self) -> float | None:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def solve_captcha(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
site_key: str,
|
|
25
|
+
action: str,
|
|
26
|
+
task: Literal["v2", "v3"],
|
|
27
|
+
) -> str:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class LoggerProtocol(Protocol):
|
|
33
|
+
@staticmethod
|
|
34
|
+
def info(s: str, **extra) -> None:
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def attempt(s: str, **extra) -> None:
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def error(s: str, **extra) -> None:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def fatal(s: str, **extra) -> None:
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class SaverProtocol(Protocol):
|
|
52
|
+
def save(self, data: List[Mapping[str, Any]], **kwargs) -> None:
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def load(self, query: Mapping[str, Any], **kwargs) -> Mapping[str, Any]:
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def delete(self, query: Mapping[str, Any], **kwargs) -> None:
|
|
59
|
+
...
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class ParentException(Exception):
|
|
2
|
+
def __init__(self, message: str, error: str = str) -> None:
|
|
3
|
+
super().__init__(message)
|
|
4
|
+
self.error = error
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Runtime exceptions (API errors)
|
|
8
|
+
class CaptchaException(ParentException):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Final exceptions
|
|
13
|
+
class SolverError(ParentException):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Login.py exceptions
|
|
18
|
+
class LoginError(ParentException):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# User.py exceptions
|
|
23
|
+
class UserError(ParentException):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Playlist.py exceptions
|
|
28
|
+
class PlaylistError(ParentException):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Saver.py exceptions
|
|
33
|
+
class SaverError(ParentException):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Song.py exceptions
|
|
38
|
+
class SongError(ParentException):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Artist.py exceptions
|
|
43
|
+
class ArtistError(ParentException):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# client.py exceptions
|
|
48
|
+
class BaseClientError(ParentException):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# request.py exceptions
|
|
53
|
+
class RequestError(ParentException):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# generator.py exceptions
|
|
58
|
+
class GeneratorError(ParentException):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# password.py exceptions
|
|
63
|
+
class PasswordError(ParentException):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FamilyError(ParentException):
|
|
68
|
+
pass
|