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 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.")
@@ -0,0 +1,2 @@
1
+ from .data import Config
2
+ from .interfaces import CaptchaProtocol, LoggerProtocol, SaverProtocol
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,15 @@
1
+ from .errors import (
2
+ ArtistError,
3
+ BaseClientError,
4
+ CaptchaException,
5
+ LoginError,
6
+ ParentException,
7
+ PlaylistError,
8
+ RequestError,
9
+ SaverError,
10
+ SolverError,
11
+ SongError,
12
+ UserError,
13
+ GeneratorError,
14
+ PasswordError,
15
+ )
@@ -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