spotapi 0.0.0__tar.gz → 0.2.0__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 (38) hide show
  1. spotapi-0.2.0/PKG-INFO +56 -0
  2. {spotapi-0.0.0 → spotapi-0.2.0}/setup.py +7 -1
  3. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/__init__.py +5 -1
  4. spotapi-0.2.0/spotapi/http/__init__.py +2 -0
  5. spotapi-0.2.0/spotapi/http/data.py +42 -0
  6. spotapi-0.2.0/spotapi/http/request.py +190 -0
  7. spotapi-0.2.0/spotapi/utils/__init__.py +3 -0
  8. spotapi-0.2.0/spotapi/utils/logger.py +92 -0
  9. spotapi-0.2.0/spotapi/utils/saver.py +320 -0
  10. spotapi-0.2.0/spotapi/utils/strings.py +53 -0
  11. spotapi-0.2.0/spotapi.egg-info/PKG-INFO +56 -0
  12. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/SOURCES.txt +8 -1
  13. spotapi-0.0.0/PKG-INFO +0 -13
  14. spotapi-0.0.0/spotapi.egg-info/PKG-INFO +0 -13
  15. {spotapi-0.0.0 → spotapi-0.2.0}/LICENSE +0 -0
  16. {spotapi-0.0.0 → spotapi-0.2.0}/README.md +0 -0
  17. {spotapi-0.0.0 → spotapi-0.2.0}/setup.cfg +0 -0
  18. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/artist.py +0 -0
  19. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/client.py +0 -0
  20. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/creator.py +0 -0
  21. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/__init__.py +0 -0
  22. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/data.py +0 -0
  23. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/interfaces.py +0 -0
  24. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/exceptions/__init__.py +0 -0
  25. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/exceptions/errors.py +0 -0
  26. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/family.py +0 -0
  27. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/login.py +0 -0
  28. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/password.py +0 -0
  29. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/playlist.py +0 -0
  30. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/__init__.py +0 -0
  31. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/capmonster.py +0 -0
  32. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/capsolver.py +0 -0
  33. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/song.py +0 -0
  34. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/user.py +0 -0
  35. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/websocket.py +0 -0
  36. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/dependency_links.txt +0 -0
  37. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/requires.txt +0 -0
  38. {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/top_level.txt +0 -0
spotapi-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.1
2
+ Name: spotapi
3
+ Version: 0.2.0
4
+ Summary: A sleek API wrapper for Spotify's private API
5
+ Home-page: UNKNOWN
6
+ Author: Aran
7
+ License: UNKNOWN
8
+ Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
+ Platform: UNKNOWN
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+
13
+ # SpotAPI
14
+
15
+ Welcome to SpotAPI! This Python library is designed to interact with the private and public Spotify APIs, emulating the requests typically made through a web browser. This wrapper provides a convenient way to access Spotify’s rich set of features programmatically.
16
+
17
+ **Note**: This project is intended solely for educational purposes and should be used responsibly. Accessing private endpoints and scraping data without proper authorization may violate Spotify's terms of service
18
+
19
+ ## Features
20
+ - **Public API Access**: Fetch and manipulate public Spotify data such as playlists, albums, and tracks.
21
+ - **Private API Access**: Experiment with less commonly used Spotify endpoints.
22
+ - **Browser-like Requests**: Mimics the HTTP requests Spotify makes in the browser perfectly, providing an accurate representation of what you’d see on the web interface.
23
+
24
+ ## Quick Example
25
+ ```py
26
+ from spotapi import (
27
+ Login,
28
+ Config,
29
+ NoopLogger,
30
+ solver_clients,
31
+ PrivatePlaylist,
32
+ MongoSaver
33
+ )
34
+
35
+ cfg = Config(
36
+ solver=solver_clients.Capsolver("YOUR_API_KEY", proxy="YOUR_PROXY"),
37
+ logger=NoopLogger(),
38
+ # You can add a proxy by passing a custom TLSClient
39
+ )
40
+
41
+ instance = Login(cfg, "YOUR_PASSWORD", email="YOUR_EMAIL")
42
+ # Now we have a valid Login instance to pass around
43
+ instance.login()
44
+
45
+ # Do whatever you want now
46
+ playlist = PrivatePlaylist(instance, "SOME_PLAYLIST")
47
+ playlist.create_playlist("Spotapi Showcase!")
48
+
49
+ # Save the session
50
+ instance.save(MongoSaver())
51
+ ```
52
+
53
+ [GPL 3.0](https://choosealicense.com/licenses/gpl-3.0/)
54
+
55
+
56
+
@@ -14,6 +14,9 @@ __install_require__ = [
14
14
  "websockets",
15
15
  ]
16
16
 
17
+ with open("README.md", "r") as f:
18
+ long_description = f.read()
19
+
17
20
  setup(
18
21
  name="spotapi",
19
22
  author="Aran",
@@ -47,4 +50,7 @@ setup(
47
50
  "Spotify Playlist",
48
51
  "Spotify Tracks",
49
52
  ],
50
- )
53
+ long_description=long_description,
54
+ long_description_content_type="text/markdown",
55
+ version="0.2.0",
56
+ )
@@ -12,6 +12,10 @@ from spotapi.utils.logger import Logger, NoopLogger
12
12
  from spotapi.utils.saver import *
13
13
  from spotapi.websocket import WebsocketStreamer
14
14
  from spotapi.family import Family, JoinFamily
15
+ from spotapi.http import *
16
+ from spotapi.exceptions import *
17
+ from spotapi.utils import *
18
+
15
19
 
16
20
  __author__ = "Aran"
17
- __license__ = "GPL 3.0"
21
+ __license__ = "GPL 3.0"
@@ -0,0 +1,2 @@
1
+ from spotapi.http.request import TLSClient, StdClient
2
+ from spotapi.http.data import Response, Error
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Union
5
+
6
+ from tls_client.response import Response as TLSResponse
7
+
8
+
9
+ # Dataclass needs to be here to avoid circular imports
10
+ @dataclass
11
+ class Response:
12
+ raw: TLSResponse
13
+ status_code: int
14
+ response: Any
15
+
16
+ error: Error = field(init=False)
17
+ success: bool = field(init=False)
18
+ fail: bool = field(init=False)
19
+
20
+ def __post_init__(self) -> None:
21
+ self.error = Error(
22
+ self.status_code,
23
+ self.response,
24
+ f"Status Code: {self.status_code}, Response: {self.response}",
25
+ )
26
+ self.success = self.error.is_success
27
+ self.fail = not self.success
28
+
29
+
30
+ @dataclass
31
+ class Error:
32
+ status_code: int
33
+ response: Union[str, dict]
34
+ string: str
35
+
36
+ @property
37
+ def is_success(self) -> bool:
38
+ return 200 <= self.status_code <= 302
39
+
40
+ @property
41
+ def is_fail(self) -> bool:
42
+ return not self.is_success
@@ -0,0 +1,190 @@
1
+ import atexit
2
+ import json
3
+ from typing import Any, Callable, Type, Union
4
+
5
+ import requests
6
+ from tls_client import Session
7
+ from tls_client.exceptions import TLSClientExeption
8
+ from tls_client.response import Response as TLSResponse
9
+
10
+ from spotapi.exceptions import ParentException, RequestError
11
+ from spotapi.http.data import Response
12
+
13
+
14
+ class StdClient(requests.Session):
15
+ def __init__(
16
+ self, auto_retries: int = 0, auth_rule: Callable[[dict[Any, Any]], dict] = None
17
+ ) -> None:
18
+ super().__init__()
19
+ self.auto_retries = auto_retries + 1
20
+ self.authenticate = auth_rule
21
+ atexit.register(self.close)
22
+
23
+ def __call__(
24
+ self, method: str, url: str, **kwargs
25
+ ) -> Union[requests.Response, None]:
26
+ return self.build_request(method, url, kwargs)
27
+
28
+ def build_request(
29
+ self, method: str, url: str, **kwargs
30
+ ) -> Union[requests.Response, None]:
31
+ err = "Unknown"
32
+ for _ in range(self.auto_retries):
33
+ try:
34
+ response = super().request(method.upper(), url, **kwargs)
35
+ except Exception as err:
36
+ err = err
37
+ continue
38
+ else:
39
+ return response
40
+ raise RequestError("Failed to complete request.", error=err)
41
+
42
+ def parse_response(self, response: requests.Response) -> Response:
43
+ body: Union[str, dict, None] = response.text
44
+ headers = {key.lower(): value for key, value in response.headers.items()}
45
+
46
+ if "application/json" in headers.get("content-type", ""):
47
+ try:
48
+ body = response.json()
49
+ except ValueError:
50
+ pass
51
+
52
+ return Response(status_code=response.status_code, response=body, raw=response)
53
+
54
+ def request(
55
+ self, method: str, url: str, *, authenticate: bool = False, **kwargs
56
+ ) -> Union[Response, None]:
57
+ if authenticate and self.authenticate:
58
+ kwargs = self.authenticate(kwargs)
59
+
60
+ response = self.build_request(method, url, **kwargs)
61
+
62
+ if response is not None:
63
+ return self.parse_response(response)
64
+
65
+ def post(
66
+ self, url: str, *, authenticate: bool = False, **kwargs
67
+ ) -> Union[Response, None]:
68
+ return self.request("POST", url, authenticate=authenticate, **kwargs)
69
+
70
+ def get(
71
+ self, url: str, *, authenticate: bool = False, **kwargs
72
+ ) -> Union[Response, None]:
73
+ return self.request("GET", url, authenticate=authenticate, **kwargs)
74
+
75
+ def put(
76
+ self, url: str, *, authenticate: bool = False, **kwargs
77
+ ) -> Union[Response, None]:
78
+ return self.request("PUT", url, authenticate=authenticate, **kwargs)
79
+
80
+
81
+ class TLSClient(Session):
82
+ def __init__(
83
+ self,
84
+ profile: str,
85
+ proxy: str,
86
+ *,
87
+ auto_retries: int = 0,
88
+ auth_rule: Callable[[dict[Any, Any]], dict] = None,
89
+ ) -> None:
90
+ super().__init__(client_identifier=profile, random_tls_extension_order=True)
91
+
92
+ if proxy:
93
+ self.proxies = {"http": f"http://{proxy}", "https": f"http://{proxy}"}
94
+
95
+ self.auto_retries = auto_retries + 1
96
+ self.authenticate = auth_rule
97
+ self.fail_exception: Type[ParentException] = None
98
+ atexit.register(self.close)
99
+
100
+ def __call__(self, method: str, url: str, **kwargs) -> Union[TLSResponse, None]:
101
+ return self.build_request(method, url, kwargs)
102
+
103
+ def build_request(
104
+ self, method: str, url: str, **kwargs
105
+ ) -> Union[TLSResponse, None]:
106
+ err = "Unknown"
107
+ for _ in range(self.auto_retries):
108
+ try:
109
+ response = self.execute_request(method.upper(), url, **kwargs)
110
+ except TLSClientExeption as err:
111
+ err = err
112
+ continue
113
+ else:
114
+ return response
115
+
116
+ raise RequestError("Failed to complete request.", error=err)
117
+
118
+ def parse_response(
119
+ self, response: TLSResponse, method: str, danger: bool
120
+ ) -> Response:
121
+ body: Union[str, dict, None] = response.text
122
+ headers = {key.lower(): value for key, value in response.headers.items()}
123
+
124
+ # Spotify doesn't set content-type for some reason?
125
+ json_encoded = "application/json" in headers.get("content-type", "")
126
+ is_dict = True
127
+
128
+ try:
129
+ json.loads(body)
130
+ except json.JSONDecodeError:
131
+ is_dict = False
132
+
133
+ if json_encoded or is_dict:
134
+ json_formatted = response.json()
135
+ body = json_formatted if isinstance(json_formatted, dict) else body
136
+
137
+ if not body:
138
+ body = None
139
+
140
+ resp = Response(status_code=response.status_code, response=body, raw=response)
141
+
142
+ if danger and self.fail_exception and resp.fail:
143
+ raise self.fail_exception(
144
+ f"Could not {method} {str(response.url).split('?')[0]}. Status Code: {resp.status_code}",
145
+ "Request Failed.",
146
+ )
147
+
148
+ return resp
149
+
150
+ def get(
151
+ self, url: str, *, authenticate: bool = False, **kwargs
152
+ ) -> Union[Response, None]:
153
+ """Routes a GET Request"""
154
+ if authenticate:
155
+ kwargs = self.authenticate(kwargs)
156
+
157
+ response = self.build_request("GET", url, allow_redirects=True, **kwargs)
158
+
159
+ if response is None:
160
+ return
161
+
162
+ return self.parse_response(response, "GET", True)
163
+
164
+ def post(
165
+ self, url: str, *, authenticate: bool = False, danger: bool = False, **kwargs
166
+ ) -> Union[Response, None]:
167
+ """Routes a POST Request"""
168
+ if authenticate:
169
+ kwargs = self.authenticate(kwargs)
170
+
171
+ response = self.build_request("POST", url, allow_redirects=True, **kwargs)
172
+
173
+ if response is None:
174
+ raise TLSClientExeption("Request kept failing after retries.")
175
+
176
+ return self.parse_response(response, "POST", danger)
177
+
178
+ def put(
179
+ self, url: str, *, authenticate: bool = False, danger: bool = False, **kwargs
180
+ ) -> Union[Response, None]:
181
+ """Routes a PUT Request"""
182
+ if authenticate:
183
+ kwargs = self.authenticate(kwargs)
184
+
185
+ response = self.build_request("PUT", url, allow_redirects=True, **kwargs)
186
+
187
+ if response is None:
188
+ raise TLSClientExeption("Request kept failing after retries.")
189
+
190
+ return self.parse_response(response, "PUT", danger)
@@ -0,0 +1,3 @@
1
+ from spotapi.utils.logger import Logger, NoopLogger
2
+ from spotapi.utils.saver import *
3
+ from spotapi.utils.strings import *
@@ -0,0 +1,92 @@
1
+ import os
2
+ import time
3
+ from threading import Lock
4
+ from datetime import datetime
5
+ from colorama import Fore, Style, init
6
+ from spotapi.data import LoggerProtocol
7
+
8
+ os.system("")
9
+ init(autoreset=True)
10
+
11
+ # By convention we use the thread lock to ensure that we don't interfere with prints.
12
+ # If there are multiple instances of your program, you may need to use a custom logger implementation that doesn't rely on staticmethods
13
+ LOCK = Lock()
14
+
15
+
16
+ class Logger(LoggerProtocol):
17
+ """
18
+ A simple Stdout logger that can be used internally.
19
+ """
20
+
21
+ @staticmethod
22
+ def __fmt_time() -> str:
23
+ t = datetime.now().strftime("%H:%M:%S")
24
+ return f"[{Style.BRIGHT}{Fore.LIGHTCYAN_EX}{str(t)}{Style.RESET_ALL}]"
25
+
26
+ @staticmethod
27
+ def error(s: str, **extra) -> None:
28
+ with LOCK:
29
+ fields = [
30
+ f"{Style.BRIGHT}{Fore.LIGHTBLUE_EX}{k}={Fore.LIGHTRED_EX}{v}{Style.RESET_ALL}"
31
+ for k, v in extra.items()
32
+ ]
33
+ print(
34
+ f"{Logger.__fmt_time()} {Style.BRIGHT}{Style.RESET_ALL}{Fore.LIGHTRED_EX}{s} "
35
+ + " ".join(fields)
36
+ )
37
+
38
+ @staticmethod
39
+ def debug(s: str, **extra) -> None:
40
+ with LOCK:
41
+ fields = [
42
+ f"{Style.BRIGHT}{Fore.LIGHTBLUE_EX}{k}={Fore.LIGHTYELLOW_EX}{v}{Style.RESET_ALL}"
43
+ for k, v in extra.items()
44
+ ]
45
+ print(
46
+ f"{Logger.__fmt_time()} {Style.BRIGHT}{Style.RESET_ALL}{Fore.LIGHTYELLOW_EX}{s} "
47
+ + " ".join(fields)
48
+ )
49
+
50
+ @staticmethod
51
+ def info(s: str, **extra) -> None:
52
+ with LOCK:
53
+ fields = [
54
+ f"{Style.BRIGHT}{Fore.LIGHTBLUE_EX}{k}={Fore.LIGHTMAGENTA_EX}{v}{Style.RESET_ALL}"
55
+ for k, v in extra.items()
56
+ ]
57
+ print(
58
+ f"{Logger.__fmt_time()} {Style.BRIGHT}{Style.RESET_ALL}{Fore.LIGHTMAGENTA_EX}{s} "
59
+ + " ".join(fields)
60
+ )
61
+
62
+ @staticmethod
63
+ def fatal(s: str, **extra) -> None:
64
+ with LOCK:
65
+ fields = [
66
+ f"{Style.BRIGHT}{Fore.LIGHTBLUE_EX}{k}={Fore.LIGHTRED_EX}{v}{Style.RESET_ALL}"
67
+ for k, v in extra.items()
68
+ ]
69
+ print(
70
+ f"{Logger.__fmt_time()} {Style.BRIGHT}{Style.RESET_ALL}{Fore.LIGHTRED_EX}{s} "
71
+ + " ".join(fields)
72
+ )
73
+ time.sleep(5)
74
+ os._exit(1)
75
+
76
+
77
+ class NoopLogger(LoggerProtocol):
78
+ @staticmethod
79
+ def error(s: str, **extra) -> None:
80
+ ...
81
+
82
+ @staticmethod
83
+ def info(s: str, **extra) -> None:
84
+ ...
85
+
86
+ @staticmethod
87
+ def fatal(s: str, **extra) -> None:
88
+ ...
89
+
90
+ @staticmethod
91
+ def attempt(s: str, **extra) -> None:
92
+ ...
@@ -0,0 +1,320 @@
1
+ """
2
+ Saver.py contains a few saver implementations using the SaverProtocol interface.
3
+ These are popular savers that are used for session storing, but if you need a different saver, you can implement it yourself quite easily.
4
+ """
5
+
6
+ import atexit
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ from typing import Any, List, Mapping, Optional
11
+
12
+ import pymongo
13
+ import redis
14
+ from readerwriterlock import rwlock
15
+
16
+ from spotapi.data.interfaces import SaverProtocol
17
+ from spotapi.exceptions import SaverError
18
+
19
+
20
+ class JSONSaver(SaverProtocol):
21
+ """
22
+ CRUD methods for JSON files
23
+ """
24
+
25
+ def __init__(self, path: Optional[str] = "sessions.json") -> None:
26
+ self.path = path
27
+
28
+ self.rwlock = rwlock.RWLockFairD()
29
+ self.rlock = self.rwlock.gen_rlock()
30
+ self.wlock = self.rwlock.gen_wlock()
31
+
32
+ def save(self, data: List[Mapping[str, Any]], **kwargs) -> None:
33
+ """
34
+ Save data to a JSON file
35
+
36
+ Kwargs
37
+ -------
38
+ overwrite (bool, optional): Defaults to False.
39
+ Overwrites the entire file instead of appending.
40
+ """
41
+ with self.wlock:
42
+ if len(data) == 0:
43
+ raise ValueError("No data to save")
44
+
45
+ if not os.path.exists(self.path):
46
+ open(self.path, "w").close()
47
+
48
+ if kwargs.get("overwrite", False):
49
+ current = []
50
+ else:
51
+ with open(self.path, "r") as f:
52
+ file_content = f.read()
53
+ current = json.loads(file_content) if file_content.strip() else []
54
+
55
+ current.extend(data)
56
+
57
+ with open(self.path, "w") as f:
58
+ json.dump(current, f, indent=4)
59
+
60
+ def load(self, query: Mapping[str, Any], **kwargs) -> Mapping[str, Any]:
61
+ """
62
+ Load data from a JSON file given a query
63
+
64
+ Kwargs
65
+ -------
66
+ allow_collisions (bool, optional): Defaults to False.
67
+ Raises an error if the query returns more than one result.
68
+ """
69
+ with self.rlock:
70
+ if not query:
71
+ raise ValueError("Query dictionary cannot be empty")
72
+
73
+ with open(self.path, "r") as f:
74
+ data = json.load(f)
75
+
76
+ allow_collisions = kwargs.get("allow_collisions", False)
77
+ matches: List[Mapping[str, Any]] = []
78
+
79
+ for item in data:
80
+ if all(item[key] == query[key] for key in query):
81
+ matches.append(item)
82
+ # Save time by checking for collisions each iteration
83
+ if allow_collisions and len(matches) > 1:
84
+ raise SaverError("Collision found")
85
+
86
+ if len(matches) >= 1:
87
+ return matches[0]
88
+
89
+ raise SaverError("Item not found")
90
+
91
+ def delete(self, query: Mapping[str, Any], **kwargs) -> None:
92
+ """
93
+ Delete data from a JSON file given a query
94
+
95
+ Kwargs
96
+ -------
97
+ all_instances (bool, optional): Defaults to True.
98
+ Deletes all instances of the query.
99
+
100
+ clear_all (bool, optional): Defaults to False.
101
+ Deletes all data in the file.
102
+ """
103
+ with self.wlock:
104
+ if not query:
105
+ raise ValueError("Query dictionary cannot be empty")
106
+
107
+ delete_all_instances = kwargs.get("all_instances", True)
108
+ clear_all = kwargs.get("clear_all", False)
109
+
110
+ if clear_all:
111
+ with open(self.path, "w") as f:
112
+ return json.dump([], f)
113
+
114
+ with open(self.path, "r") as f:
115
+ data = json.load(f)
116
+
117
+ assert isinstance(data, list), "JSON must be an array"
118
+
119
+ # Copy the list to avoid modifying the original
120
+ for item in data.copy():
121
+ if all(item[key] == query[key] for key in query):
122
+ data.remove(item)
123
+ if not delete_all_instances:
124
+ break
125
+
126
+ with open(self.path, "w") as f:
127
+ json.dump(data, f, indent=4)
128
+
129
+
130
+ class SqliteSaver(SaverProtocol):
131
+ """
132
+ CRUD methods for SQLite3 files
133
+ """
134
+
135
+ def __init__(self, path: str = "sessions.db") -> None:
136
+ self.path = path
137
+ self.conn = sqlite3.connect(self.path, check_same_thread=False)
138
+ self.cursor = self.conn.cursor()
139
+
140
+ # Create table
141
+ self.cursor.execute(
142
+ """
143
+ CREATE TABLE IF NOT EXISTS sessions (
144
+ identifier TEXT PRIMARY KEY NOT NULL,
145
+ password TEXT NOT NULL,
146
+ cookies TEXT
147
+ )
148
+ """
149
+ )
150
+
151
+ # Cleanup
152
+ atexit.register(self.cursor.close)
153
+ atexit.register(self.conn.close)
154
+
155
+ # Sqlite may not behave as intended under multi-threaded environments
156
+ self.rwlock = rwlock.RWLockFairD()
157
+ self.rlock = self.rwlock.gen_rlock()
158
+ self.wlock = self.rwlock.gen_wlock()
159
+
160
+ def save(self, data: List[Mapping[str, Any]], **kwargs) -> None:
161
+ """
162
+ Saves data to a SQLite3 database
163
+
164
+ Kwargs
165
+ -------
166
+ overwrite (bool, optional): Defaults to False.
167
+ Overwrites the entire database instead of appending.
168
+ """
169
+ with self.wlock:
170
+ try:
171
+ if len(data) == 0:
172
+ raise ValueError("No data to save")
173
+
174
+ if kwargs.get("overwrite", False):
175
+ self.cursor.execute("DELETE FROM sessions")
176
+ self.conn.commit()
177
+
178
+ for item in data:
179
+ self.cursor.execute(
180
+ "INSERT INTO sessions VALUES (?, ?, ?)",
181
+ (
182
+ item["identifier"],
183
+ item["password"],
184
+ json.dumps(item["cookies"]),
185
+ ),
186
+ )
187
+
188
+ self.conn.commit()
189
+ except Exception as e:
190
+ self.conn.rollback()
191
+ raise SaverError(e)
192
+
193
+ def load(self, query: Mapping[str, Any], **kwargs) -> Mapping[str, Any]:
194
+ """
195
+ Loads data from a SQLite3 database given a query
196
+ """
197
+
198
+ with self.rlock:
199
+ if not query:
200
+ raise ValueError("Query dictionary cannot be empty")
201
+
202
+ # Turn dictionary into sql query
203
+ sql = "SELECT * FROM sessions WHERE "
204
+ params = []
205
+
206
+ for key, value in query.items():
207
+ sql += f"{key} = ? AND "
208
+ params.append(value)
209
+
210
+ self.cursor.execute(sql[:-5], tuple(params))
211
+ result = self.cursor.fetchall()
212
+
213
+ if len(result) == 0:
214
+ raise SaverError("Item not found")
215
+
216
+ return result[0]
217
+
218
+ def delete(self, query: Mapping[str, Any], **kwargs) -> None:
219
+ """
220
+ Deletes data from a SQLite3 database given a query
221
+ """
222
+ with self.wlock:
223
+ if not query:
224
+ raise ValueError("Query dictionary cannot be empty")
225
+
226
+ # Turn dictionary into sql query
227
+ sql = "DELETE FROM sessions WHERE "
228
+ params = []
229
+
230
+ for key, value in query.items():
231
+ sql += f"{key} = ? AND "
232
+ params.append(value)
233
+
234
+ self.cursor.execute(sql[:-5], tuple(params))
235
+ self.conn.commit()
236
+
237
+
238
+ class MongoSaver(SaverProtocol):
239
+ """
240
+ CRUD methods for MongoDB
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ host: Optional[str] = "mongodb://localhost:27017/",
246
+ database_name: Optional[str] = "spotify",
247
+ collection: Optional[str] = "sessions",
248
+ ) -> None:
249
+ self.conn = pymongo.MongoClient(host)
250
+ self.database = self.conn[database_name]
251
+ self.collection = self.database[collection]
252
+
253
+ atexit.register(self.conn.close)
254
+
255
+ def save(self, data: List[Mapping[str, Any]], **kwargs) -> None:
256
+ if len(data) == 0:
257
+ raise ValueError("No data to save")
258
+
259
+ self.collection.insert_many(data)
260
+
261
+ def load(self, query: Mapping[str, Any], **kwargs) -> Mapping[str, Any]:
262
+ if not query:
263
+ raise ValueError("Query dictionary cannot be empty")
264
+
265
+ result = self.collection.find_one(query)
266
+
267
+ if result is None:
268
+ raise SaverError("Item not found")
269
+
270
+ return result
271
+
272
+ def delete(self, query: Mapping[str, Any], **kwargs) -> None:
273
+ if not query:
274
+ raise ValueError("Query dictionary cannot be empty")
275
+
276
+ self.collection.delete_one(query)
277
+
278
+
279
+ class RedisSaver(SaverProtocol):
280
+ def __init__(
281
+ self, host: Optional[str] = "localhost", port: int = 6379, db: int = 0
282
+ ) -> None:
283
+ self.client = redis.StrictRedis(host=host, port=port, db=db)
284
+ atexit.register(self.client.close)
285
+
286
+ def save(self, data: List[Mapping[str, Any]], **kwargs) -> None:
287
+ if len(data) == 0:
288
+ raise ValueError("No data to save")
289
+
290
+ for item in data:
291
+ self.client.set(item["identifier"], json.dumps(item))
292
+
293
+ def load(self, query: Mapping[str, Any], **kwargs) -> Mapping[str, Any]:
294
+ """
295
+ Loads data from a Redis database given a query.
296
+
297
+ Due to the nature of Redis, the query must be a singular identifier.
298
+ """
299
+ if not query:
300
+ raise ValueError("Query dictionary cannot be empty")
301
+
302
+ identifier = query.get("identifier")
303
+ if not identifier:
304
+ raise ValueError("Identifier is required for Redis lookup")
305
+
306
+ result = self.client.get(identifier)
307
+ if not result:
308
+ raise SaverError("Item not found")
309
+
310
+ return json.loads(result)
311
+
312
+ def delete(self, query: Mapping[str, Any], **kwargs) -> None:
313
+ if not query:
314
+ raise ValueError("Query dictionary cannot be empty")
315
+
316
+ identifier = query.get("identifier")
317
+ if not identifier:
318
+ raise ValueError("Identifier is required for Redis lookup")
319
+
320
+ self.client.delete(identifier)
@@ -0,0 +1,53 @@
1
+ import string
2
+ import random
3
+ from typing import Optional
4
+
5
+
6
+ def parse_json_string(b: str, s: str) -> str:
7
+ start_index = b.find(f'{s}":"')
8
+ if start_index == -1:
9
+ raise ValueError(f'Substring "{s}":" not found in JSON string')
10
+
11
+ value_start_index = start_index + len(s) + 3
12
+ value_end_index = b.find('"', value_start_index)
13
+ if value_end_index == -1:
14
+ raise ValueError(f'Closing double quote not found after "{s}":"')
15
+
16
+ return b[value_start_index:value_end_index]
17
+
18
+
19
+ def random_string(length: int, /, strong: Optional[bool] = False) -> str:
20
+ letters = string.ascii_letters
21
+ rnd = "".join(random.choice(letters) for _ in range(length))
22
+
23
+ if strong:
24
+ rnd += random.choice(string.digits)
25
+ rnd += random.choice("@$%&*!?")
26
+
27
+ return rnd
28
+
29
+
30
+ def random_domain() -> str:
31
+ domains = [
32
+ "gmail.com",
33
+ "outlook.com",
34
+ "yahoo.com",
35
+ "hotmail.com",
36
+ "aol.com",
37
+ "comcast.net",
38
+ "icloud.com",
39
+ "msn.com",
40
+ "live.com",
41
+ "protonmail.com",
42
+ "yandex.com",
43
+ "tutanota.com",
44
+ ]
45
+ return random.choice(domains)
46
+
47
+
48
+ def random_email() -> str:
49
+ return f"{random_string(10)}@{random_domain()}"
50
+
51
+
52
+ def random_dob() -> str:
53
+ return f"{random.randint(1950, 2000)}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.1
2
+ Name: spotapi
3
+ Version: 0.2.0
4
+ Summary: A sleek API wrapper for Spotify's private API
5
+ Home-page: UNKNOWN
6
+ Author: Aran
7
+ License: UNKNOWN
8
+ Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
+ Platform: UNKNOWN
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+
13
+ # SpotAPI
14
+
15
+ Welcome to SpotAPI! This Python library is designed to interact with the private and public Spotify APIs, emulating the requests typically made through a web browser. This wrapper provides a convenient way to access Spotify’s rich set of features programmatically.
16
+
17
+ **Note**: This project is intended solely for educational purposes and should be used responsibly. Accessing private endpoints and scraping data without proper authorization may violate Spotify's terms of service
18
+
19
+ ## Features
20
+ - **Public API Access**: Fetch and manipulate public Spotify data such as playlists, albums, and tracks.
21
+ - **Private API Access**: Experiment with less commonly used Spotify endpoints.
22
+ - **Browser-like Requests**: Mimics the HTTP requests Spotify makes in the browser perfectly, providing an accurate representation of what you’d see on the web interface.
23
+
24
+ ## Quick Example
25
+ ```py
26
+ from spotapi import (
27
+ Login,
28
+ Config,
29
+ NoopLogger,
30
+ solver_clients,
31
+ PrivatePlaylist,
32
+ MongoSaver
33
+ )
34
+
35
+ cfg = Config(
36
+ solver=solver_clients.Capsolver("YOUR_API_KEY", proxy="YOUR_PROXY"),
37
+ logger=NoopLogger(),
38
+ # You can add a proxy by passing a custom TLSClient
39
+ )
40
+
41
+ instance = Login(cfg, "YOUR_PASSWORD", email="YOUR_EMAIL")
42
+ # Now we have a valid Login instance to pass around
43
+ instance.login()
44
+
45
+ # Do whatever you want now
46
+ playlist = PrivatePlaylist(instance, "SOME_PLAYLIST")
47
+ playlist.create_playlist("Spotapi Showcase!")
48
+
49
+ # Save the session
50
+ instance.save(MongoSaver())
51
+ ```
52
+
53
+ [GPL 3.0](https://choosealicense.com/licenses/gpl-3.0/)
54
+
55
+
56
+
@@ -22,6 +22,13 @@ spotapi/data/data.py
22
22
  spotapi/data/interfaces.py
23
23
  spotapi/exceptions/__init__.py
24
24
  spotapi/exceptions/errors.py
25
+ spotapi/http/__init__.py
26
+ spotapi/http/data.py
27
+ spotapi/http/request.py
25
28
  spotapi/solvers/__init__.py
26
29
  spotapi/solvers/capmonster.py
27
- spotapi/solvers/capsolver.py
30
+ spotapi/solvers/capsolver.py
31
+ spotapi/utils/__init__.py
32
+ spotapi/utils/logger.py
33
+ spotapi/utils/saver.py
34
+ spotapi/utils/strings.py
spotapi-0.0.0/PKG-INFO DELETED
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: spotapi
3
- Version: 0.0.0
4
- Summary: A sleek API wrapper for Spotify's private API
5
- Home-page: UNKNOWN
6
- Author: Aran
7
- License: UNKNOWN
8
- Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
- Platform: UNKNOWN
10
- License-File: LICENSE
11
-
12
- UNKNOWN
13
-
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: spotapi
3
- Version: 0.0.0
4
- Summary: A sleek API wrapper for Spotify's private API
5
- Home-page: UNKNOWN
6
- Author: Aran
7
- License: UNKNOWN
8
- Keywords: Spotify,API,Spotify API,Spotify Private API,Follow,Like,Creator,Music,Music API,Streaming,Music Data,Track,Playlist,Album,Artist,Music Search,Music Metadata,SpotAPI,Python Spotify Wrapper,Music Automation,Web Scraping,Python Music API,Spotify Integration,Spotify Playlist,Spotify Tracks
9
- Platform: UNKNOWN
10
- License-File: LICENSE
11
-
12
- UNKNOWN
13
-
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