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.
- spotapi-0.2.0/PKG-INFO +56 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/setup.py +7 -1
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/__init__.py +5 -1
- spotapi-0.2.0/spotapi/http/__init__.py +2 -0
- spotapi-0.2.0/spotapi/http/data.py +42 -0
- spotapi-0.2.0/spotapi/http/request.py +190 -0
- spotapi-0.2.0/spotapi/utils/__init__.py +3 -0
- spotapi-0.2.0/spotapi/utils/logger.py +92 -0
- spotapi-0.2.0/spotapi/utils/saver.py +320 -0
- spotapi-0.2.0/spotapi/utils/strings.py +53 -0
- spotapi-0.2.0/spotapi.egg-info/PKG-INFO +56 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/SOURCES.txt +8 -1
- spotapi-0.0.0/PKG-INFO +0 -13
- spotapi-0.0.0/spotapi.egg-info/PKG-INFO +0 -13
- {spotapi-0.0.0 → spotapi-0.2.0}/LICENSE +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/README.md +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/setup.cfg +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/artist.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/client.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/creator.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/__init__.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/data.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/data/interfaces.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/exceptions/__init__.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/exceptions/errors.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/family.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/login.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/password.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/playlist.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/__init__.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/capmonster.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/solvers/capsolver.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/song.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/user.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi/websocket.py +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/dependency_links.txt +0 -0
- {spotapi-0.0.0 → spotapi-0.2.0}/spotapi.egg-info/requires.txt +0 -0
- {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,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,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
|
|
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
|