spotapi 1.2.1__tar.gz → 1.2.3__tar.gz

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.
Files changed (45) hide show
  1. {spotapi-1.2.1/spotapi.egg-info → spotapi-1.2.3}/PKG-INFO +1 -1
  2. {spotapi-1.2.1 → spotapi-1.2.3}/setup.py +2 -2
  3. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/client.py +102 -89
  4. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/creator.py +2 -1
  5. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/login.py +3 -2
  6. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/password.py +2 -1
  7. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/user.py +2 -2
  8. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/saver.py +2 -4
  9. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/strings.py +40 -2
  10. {spotapi-1.2.1 → spotapi-1.2.3/spotapi.egg-info}/PKG-INFO +1 -1
  11. {spotapi-1.2.1 → spotapi-1.2.3}/LICENSE +0 -0
  12. {spotapi-1.2.1 → spotapi-1.2.3}/README.md +0 -0
  13. {spotapi-1.2.1 → spotapi-1.2.3}/setup.cfg +0 -0
  14. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/__init__.py +0 -0
  15. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/_tests/__init__.py +0 -0
  16. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/_tests/annotations_test.py +0 -0
  17. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/album.py +0 -0
  18. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/artist.py +0 -0
  19. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/exceptions/__init__.py +0 -0
  20. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/exceptions/errors.py +0 -0
  21. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/family.py +0 -0
  22. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/__init__.py +0 -0
  23. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/data.py +0 -0
  24. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/request.py +0 -0
  25. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/player.py +0 -0
  26. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/playlist.py +0 -0
  27. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/podcast.py +0 -0
  28. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/public.py +0 -0
  29. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/__init__.py +0 -0
  30. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/capmonster.py +0 -0
  31. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/capsolver.py +0 -0
  32. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/song.py +0 -0
  33. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/status.py +0 -0
  34. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/__init__.py +0 -0
  35. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/alias.py +0 -0
  36. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/annotations.py +0 -0
  37. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/data.py +0 -0
  38. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/interfaces.py +0 -0
  39. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/__init__.py +0 -0
  40. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/logger.py +0 -0
  41. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/websocket.py +0 -0
  42. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/SOURCES.txt +0 -0
  43. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/dependency_links.txt +0 -0
  44. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/requires.txt +0 -0
  45. {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spotapi
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: A sleek API wrapper for Spotify's private API
5
5
  Home-page: UNKNOWN
6
6
  Author: Aran
@@ -55,5 +55,5 @@ setup(
55
55
  ],
56
56
  long_description=long_description,
57
57
  long_description_content_type="text/markdown",
58
- version="1.2.1",
59
- )
58
+ version="1.2.3",
59
+ )
@@ -1,59 +1,79 @@
1
- import re
2
- import hmac
3
1
  import time
2
+ import json
3
+ import base64
4
+ import pyotp
4
5
  import atexit
5
- import hashlib
6
- from typing import Callable, Tuple
6
+ import requests
7
+ from typing import Tuple, Literal
7
8
  from collections.abc import Mapping
9
+ from spotapi.utils.logger import Logger
10
+ from spotapi.utils.logger import Logger
8
11
  from spotapi.types.annotations import enforce
9
12
  from spotapi.types.alias import _UStr, _Undefined
10
13
  from spotapi.exceptions import BaseClientError
11
14
  from spotapi.http.request import TLSClient
12
- from spotapi.utils.strings import parse_json_string
15
+ from spotapi.utils.strings import extract_js_links, extract_mappings, combine_chunks
16
+
17
+ # Default recaptcha site key, will update on startup if necessary
18
+ RECAPTCHA_SITE_KEY: str = "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39"
19
+ # Fallback hardcoded secret (version 18)
20
+ _FALLBACK_SECRET: Tuple[Literal[18], bytearray] = (
21
+ 18,
22
+ bytearray(
23
+ [70, 60, 33, 57, 92, 120, 90, 33, 32, 62, 62, 55, 126, 93, 66, 35, 108, 68]
24
+ ),
25
+ )
26
+
27
+ # Cache storage for TOTP
28
+ _secret_cache: Tuple[int, bytearray] | None = None
29
+ _cache_expiry: float = -1
30
+ _CACHE_TTL = 15 * 60
13
31
 
14
32
  __all__ = ["BaseClient", "BaseClientError"]
15
33
 
16
- # fmt: off
17
- # Currently the secret is static but could change at any moment.
18
- # From the looks of it there is no easy way to get it dynamically per request due to the vaguity of the javascript variables.
19
- _TOTP_SECRET = bytearray([49, 48, 48, 49, 49, 49, 56, 49, 49, 49, 49, 55, 57, 56, 50, 49, 50, 51, 49, 50, 52, 54, 56, 56, 52, 54, 57, 51, 55, 56, 49,
20
- 51, 50, 54, 52, 52, 50, 56, 49, 57, 57, 52, 55, 57, 50, 51, 54, 53, 51, 53, 57, 49, 49, 51, 54, 52, 49, 48, 54, 50, 50, 49, 51, 49, 48, 55, 51, 48])
21
- # fmt: on
22
-
23
-
24
- def generate_totp(
25
- secret: bytes = _TOTP_SECRET,
26
- algorithm: Callable[[], object] = hashlib.sha1,
27
- digits: int = 6,
28
- counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
29
- ) -> Tuple[str, int]:
30
- counter = counter_factory()
31
- hmac_result = hmac.new(
32
- secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
33
- ).digest()
34
-
35
- offset = hmac_result[-1] & 15
36
- truncated_value = (
37
- (hmac_result[offset] & 127) << 24
38
- | (hmac_result[offset + 1] & 255) << 16
39
- | (hmac_result[offset + 2] & 255) << 8
40
- | (hmac_result[offset + 3] & 255)
41
- )
42
- return (
43
- str(truncated_value % (10**digits)).zfill(digits),
44
- counter * 30_000,
45
- ) # (30 * 1000)
46
34
 
35
+ def get_latest_totp_secret() -> Tuple[int, bytearray]:
36
+ global _secret_cache, _cache_expiry
47
37
 
48
- @enforce
49
- class BaseClient:
50
- """
51
- A base class that all the Spotify classes extend.
52
- This base class contains all the common methods used by the Spotify classes.
38
+ if _secret_cache and time.time() < _cache_expiry:
39
+ return _secret_cache
40
+
41
+ try:
42
+ url = "https://github.com/Thereallo1026/spotify-secrets/blob/main/secrets/secretDict.json?raw=true"
43
+ response = requests.get(url, timeout=5)
44
+ if not response.ok:
45
+ raise BaseClientError(f"Failed to fetch secrets: {response.status_code}")
46
+
47
+ secrets = response.json()
48
+ version = max(secrets, key=int)
49
+ secret_list = secrets[version]
50
+
51
+ if not isinstance(secret_list, list):
52
+ raise BaseClientError(
53
+ f"Expected a list of integers, got {type(secret_list)}"
54
+ )
55
+
56
+ _secret_cache = (version, bytearray(secret_list))
57
+ _cache_expiry = time.time() + _CACHE_TTL
58
+ return _secret_cache
59
+ except Exception as e:
60
+ Logger.error(f"Failed to fetch secrets: {e}. Falling back to default secret.")
61
+ return _FALLBACK_SECRET
53
62
 
54
- NOTE: Should not be used directly. Use the Spotify classes instead.
55
- """
56
63
 
64
+ def generate_totp() -> Tuple[str, int]:
65
+ version, secret_bytes = get_latest_totp_secret()
66
+ transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_bytes)]
67
+ joined = "".join(str(num) for num in transformed)
68
+ hex_str = joined.encode().hex()
69
+ secret = base64.b32encode(bytes.fromhex(hex_str)).decode().rstrip("=")
70
+ totp = pyotp.TOTP(secret).now()
71
+ return totp, version
72
+
73
+
74
+ @enforce
75
+ class BaseClient:
76
+ # There are many Javasript packs, but this one contains all the "xpui" packs which contain further packs that contain the hashes we need
57
77
  js_pack: _UStr = _Undefined
58
78
  client_version: _UStr = _Undefined
59
79
  access_token: _UStr = _Undefined
@@ -87,7 +107,6 @@ class BaseClient:
87
107
  if "headers" not in kwargs:
88
108
  kwargs["headers"] = {}
89
109
 
90
- assert self.access_token is not _Undefined, "Access token is _Undefined"
91
110
  kwargs["headers"].update(
92
111
  {
93
112
  "Authorization": "Bearer " + str(self.access_token),
@@ -100,49 +119,63 @@ class BaseClient:
100
119
 
101
120
  def _get_auth_vars(self) -> None:
102
121
  if self.access_token is _Undefined or self.client_id is _Undefined:
103
- totp, _ = generate_totp()
122
+ totp, version = generate_totp()
123
+ totp, version = generate_totp()
104
124
  query = {
105
125
  "reason": "init",
106
126
  "productType": "web-player",
107
127
  "totp": totp,
108
- "totpVer": 9,
128
+ "totpVer": version,
129
+ "totpVer": version,
109
130
  "totpServer": totp,
110
131
  }
111
- resp = self.client.get(
112
- "https://open.spotify.com/api/token", params=query
113
- )
132
+ resp = self.client.get("https://open.spotify.com/api/token", params=query)
133
+ resp = self.client.get("https://open.spotify.com/api/token", params=query)
114
134
 
115
135
  if resp.fail:
116
- raise BaseClientError(
117
- "Could not get session auth tokens", error=resp.error.string
118
- )
136
+ raise BaseClientError("Could not get session auth tokens", error=resp.error.string)
119
137
 
120
138
  self.access_token = resp.response["accessToken"]
121
139
  self.client_id = resp.response["clientId"]
122
140
 
123
141
  def get_session(self) -> None:
124
142
  resp = self.client.get("https://open.spotify.com")
125
-
126
143
  if resp.fail:
127
144
  raise BaseClientError("Could not get session", error=resp.error.string)
128
145
 
129
- try:
130
- pattern = r"https:\/\/open\.spotifycdn\.com\/cdn\/build\/web-player\/web-player.*?\.js"
131
- self.js_pack = re.findall(pattern, resp.response)[1]
132
- except IndexError:
133
- pattern = r"https:\/\/open-exp.spotifycdn\.com\/cdn\/build\/web-player\/web-player.*?\.js"
134
- self.js_pack = re.findall(pattern, resp.response)[1]
146
+ _all_js_packs = extract_js_links(resp.response)
147
+ self.js_pack = next(
148
+ (
149
+ link
150
+ for link in _all_js_packs
151
+ if link.startswith(
152
+ "https://open.spotifycdn.com/cdn/build/web-player/web-player"
153
+ )
154
+ and link.endswith(".js")
155
+ ),
156
+ "",
157
+ )
158
+
159
+ self._raw_app_server_config = resp.response.split(
160
+ '<script id="appServerConfig" type="text/plain">'
161
+ )[1].split("</script>")[0]
162
+ self.server_cfg = json.loads(
163
+ base64.b64decode(self._raw_app_server_config).decode("utf-8")
164
+ )
135
165
 
166
+ _recaptcha_key = self.server_cfg["recaptchaWebPlayerFraudSiteKey"]
167
+ if _recaptcha_key:
168
+ global RECAPTCHA_SITE_KEY
169
+ RECAPTCHA_SITE_KEY = _recaptcha_key
170
+
171
+ self.client_version = self.server_cfg["clientVersion"]
136
172
  self.device_id = self.client.cookies.get("sp_t") or ""
137
173
  self._get_auth_vars()
138
174
 
139
175
  def get_client_token(self) -> None:
140
- if not (self.client_id and self.device_id):
176
+ if not (self.client_id and self.device_id and self.client_version):
141
177
  self.get_session()
142
178
 
143
- if not self.client_version:
144
- self.get_sha256_hash()
145
-
146
179
  url = "https://clienttoken.spotify.com/v1/clienttoken"
147
180
  payload = {
148
181
  "client_data": {
@@ -199,44 +232,24 @@ class BaseClient:
199
232
  raise ValueError("Could not get playlist hashes")
200
233
 
201
234
  resp = self.client.get(str(self.js_pack))
202
-
203
235
  if resp.fail:
204
236
  raise BaseClientError(
205
- "Could not get playlist hashes", error=resp.error.string
237
+ "Could not get general hashes", error=resp.error.string
206
238
  )
207
239
 
208
- assert isinstance(resp.response, str), "Invalid HTML response"
209
-
210
240
  self.raw_hashes = resp.response
211
- self.client_version = resp.response.split('clientVersion:"')[1].split('"')[0]
212
-
213
- # Maybe it's static? Let's not take chances.
214
- self.xpui_route_num = resp.response.split(':"xpui-routes-search"')[0].split(
215
- ","
216
- )[-1]
217
- self.xpui_route_tracks_num = resp.response.split(':"xpui-routes-track-v2"')[
218
- 0
219
- ].split(",")[-1]
220
-
221
- xpui_route_pattern = rf'{self.xpui_route_num}:"([^"]*)"'
222
- self.xpui_route = re.findall(xpui_route_pattern, resp.response)[-1]
223
-
224
- xpui_route_tracks_pattern = rf'{self.xpui_route_tracks_num}:"([^"]*)"'
225
- self.xpui_route_tracks = re.findall(xpui_route_tracks_pattern, resp.response)[
226
- -1
227
- ]
228
-
229
- urls = (
230
- f"https://open.spotifycdn.com/cdn/build/web-player/xpui-routes-search.{self.xpui_route}.js",
231
- f"https://open.spotifycdn.com/cdn/build/web-player/xpui-routes-track-v2.{self.xpui_route_tracks}.js",
241
+
242
+ str_mapping, hash_mapping = extract_mappings(str(self.raw_hashes))
243
+ urls = map(
244
+ lambda s: f"https://open.spotifycdn.com/cdn/build/web-player/{s}",
245
+ combine_chunks(str_mapping, hash_mapping),
232
246
  )
233
247
 
234
248
  for url in urls:
235
249
  resp = self.client.get(url)
236
-
237
250
  if resp.fail:
238
251
  raise BaseClientError(
239
- "Could not get xpui hashes", error=resp.error.string
252
+ "Could not get general hashes", error=resp.error.string
240
253
  )
241
254
 
242
255
  self.raw_hashes += resp.response
@@ -1,6 +1,7 @@
1
1
  import time
2
2
  import uuid
3
3
  import json
4
+ from spotapi.client import RECAPTCHA_SITE_KEY
4
5
  from spotapi.types.annotations import enforce
5
6
  from spotapi.types import Config
6
7
  from spotapi.exceptions import GeneratorError
@@ -121,7 +122,7 @@ class Creator:
121
122
 
122
123
  captcha_token = self.cfg.solver.solve_captcha(
123
124
  "https://www.spotify.com/ca-en/signup",
124
- "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
125
+ RECAPTCHA_SITE_KEY,
125
126
  "website/signup/submit_email",
126
127
  "v3",
127
128
  )
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from collections.abc import Mapping
6
6
  from spotapi.types.annotations import enforce
7
7
  from urllib.parse import urlencode, quote
8
+ from spotapi.client import RECAPTCHA_SITE_KEY
8
9
  from spotapi.types import Config, SaverProtocol
9
10
  from spotapi.exceptions import LoginError
10
11
  from spotapi.utils.strings import parse_json_string
@@ -266,8 +267,8 @@ class Login:
266
267
  raise LoginError("Solver not set")
267
268
 
268
269
  captcha_response = self.solver.solve_captcha(
269
- "https://accounts.spotify.com/en/login",
270
- "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
270
+ "https://accounts.spotify.com",
271
+ RECAPTCHA_SITE_KEY,
271
272
  "accounts/login",
272
273
  "v3",
273
274
  )
@@ -4,6 +4,7 @@ from spotapi.utils.strings import parse_json_string
4
4
  from spotapi.types.annotations import enforce
5
5
  from spotapi.exceptions import PasswordError
6
6
  from spotapi.types.data import Config
7
+ from spotapi.client import RECAPTCHA_SITE_KEY
7
8
 
8
9
  __all__ = ["Password", "PasswordError"]
9
10
 
@@ -83,7 +84,7 @@ class Password:
83
84
 
84
85
  captcha_response = self.solver.solve_captcha(
85
86
  "https://accounts.spotify.com/en/password-reset",
86
- "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
87
+ RECAPTCHA_SITE_KEY,
87
88
  "password_reset_web/recovery",
88
89
  "v3",
89
90
  )
@@ -5,7 +5,7 @@ from collections.abc import Mapping
5
5
  from spotapi.types.annotations import enforce
6
6
  from spotapi import utils
7
7
  from spotapi.exceptions import UserError
8
- from spotapi.login import Login
8
+ from spotapi.login import Login, RECAPTCHA_SITE_KEY
9
9
 
10
10
  __all__ = ["User", "UserError"]
11
11
 
@@ -93,7 +93,7 @@ class User:
93
93
 
94
94
  captcha_response = self.login.solver.solve_captcha(
95
95
  "https://www.spotify.com",
96
- "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39",
96
+ RECAPTCHA_SITE_KEY,
97
97
  "account_settings/profile_update",
98
98
  "v3",
99
99
  )
@@ -6,13 +6,11 @@ These are popular savers that are used for session storing, but if you need a di
6
6
  import atexit
7
7
  import json
8
8
  import os
9
- import sqlite3
10
- from typing import Any, List, Mapping
11
-
12
9
  import pymongo
13
10
  import redis
11
+ import sqlite3
12
+ from typing import Any, List, Mapping
14
13
  from readerwriterlock import rwlock
15
-
16
14
  from spotapi.types.interfaces import SaverProtocol
17
15
  from spotapi.exceptions import SaverError
18
16
 
@@ -1,8 +1,11 @@
1
- from typing import Optional
1
+ from typing import List, Tuple, Dict
2
+ from bs4 import BeautifulSoup
2
3
  import string
3
4
  import random
4
5
  import base64
6
+ import ast
5
7
  import os
8
+ import re
6
9
 
7
10
 
8
11
  __all__ = [
@@ -17,6 +20,41 @@ __all__ = [
17
20
  ]
18
21
 
19
22
 
23
+ def extract_mappings(js_code: str) -> Tuple[Dict[int, str], Dict[int, str]]:
24
+ pattern = r"\{\d+:\"[^\"]+\"(?:,\d+:\"[^\"]+\")*\}"
25
+ matches = re.findall(pattern, js_code)
26
+
27
+ if len(matches) < 2:
28
+ raise ValueError("Could not find both mappings in the JS code.")
29
+
30
+ mapping1 = ast.literal_eval(matches[3])
31
+ mapping2 = ast.literal_eval(matches[4])
32
+
33
+ return mapping1, mapping2
34
+
35
+
36
+ def combine_chunks(name_map: Dict[int, str], hash_map: Dict[int, str]) -> List[str]:
37
+ combined: List[str] = []
38
+ for key in name_map:
39
+ if key in hash_map:
40
+ filename = f"{name_map[key]}.{hash_map[key]}.js"
41
+ combined.append(filename)
42
+ return combined
43
+
44
+
45
+ def extract_js_links(html_content: str) -> List[str]:
46
+ """Extracts all JavaScript links from a given HTML content."""
47
+ soup = BeautifulSoup(html_content, "html.parser")
48
+ js_links = []
49
+
50
+ for script_tag in soup.find_all("script", src=True):
51
+ src = script_tag["src"]
52
+ if src.endswith(".js"):
53
+ js_links.append(str(src))
54
+
55
+ return js_links
56
+
57
+
20
58
  def random_b64_string(length: int) -> str:
21
59
  """Used by Spotify internally"""
22
60
 
@@ -51,7 +89,7 @@ def parse_json_string(b: str, s: str) -> str:
51
89
  return b[value_start_index:value_end_index]
52
90
 
53
91
 
54
- def random_string(length: int, /, strong: Optional[bool] = False) -> str:
92
+ def random_string(length: int, /, strong: bool = False) -> str:
55
93
  letters = string.ascii_letters
56
94
  rnd = "".join(random.choice(letters) for _ in range(length))
57
95
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spotapi
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: A sleek API wrapper for Spotify's private API
5
5
  Home-page: UNKNOWN
6
6
  Author: Aran
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes