songstats-sdk 0.1.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.
- songstats_sdk-0.1.0/.gitignore +26 -0
- songstats_sdk-0.1.0/CHANGELOG.md +22 -0
- songstats_sdk-0.1.0/LICENSE +21 -0
- songstats_sdk-0.1.0/PKG-INFO +136 -0
- songstats_sdk-0.1.0/README.md +111 -0
- songstats_sdk-0.1.0/docs/enterprise_routes_audit.md +107 -0
- songstats_sdk-0.1.0/pyproject.toml +45 -0
- songstats_sdk-0.1.0/src/songstats_sdk/__init__.py +11 -0
- songstats_sdk-0.1.0/src/songstats_sdk/client.py +48 -0
- songstats_sdk-0.1.0/src/songstats_sdk/exceptions.py +24 -0
- songstats_sdk-0.1.0/src/songstats_sdk/http.py +106 -0
- songstats_sdk-0.1.0/src/songstats_sdk/py.typed +0 -0
- songstats_sdk-0.1.0/src/songstats_sdk/resources/__init__.py +11 -0
- songstats_sdk-0.1.0/src/songstats_sdk/resources/base.py +53 -0
- songstats_sdk-0.1.0/src/songstats_sdk/resources/entities.py +162 -0
- songstats_sdk-0.1.0/src/songstats_sdk/resources/info.py +19 -0
- songstats_sdk-0.1.0/src/songstats_sdk/resources/tracks.py +80 -0
- songstats_sdk-0.1.0/src/songstats_sdk/version.py +2 -0
- songstats_sdk-0.1.0/tests/conftest.py +10 -0
- songstats_sdk-0.1.0/tests/test_sdk.py +102 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python bytecode and caches
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Test and type-check caches
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
|
|
16
|
+
# Packaging artifacts
|
|
17
|
+
build/
|
|
18
|
+
dist/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Coverage artifacts
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# macOS
|
|
26
|
+
.DS_Store
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-02-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Initial standalone Python SDK repo for Songstats Enterprise API (`/enterprise/v1`)
|
|
10
|
+
- Full resource coverage:
|
|
11
|
+
- `info`
|
|
12
|
+
- `tracks`
|
|
13
|
+
- `artists`
|
|
14
|
+
- `collaborators`
|
|
15
|
+
- `labels`
|
|
16
|
+
- Shared HTTP client with:
|
|
17
|
+
- `apikey` header auth
|
|
18
|
+
- JSON response decoding
|
|
19
|
+
- retry/backoff on transport errors and retryable status codes
|
|
20
|
+
- Structured exception types for API and transport failures
|
|
21
|
+
- Route coverage audit doc mapping Rails routes to SDK methods
|
|
22
|
+
- Test suite covering route mapping, header auth, validation, and error handling
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Songstats
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: songstats-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Songstats Enterprise API
|
|
5
|
+
Project-URL: Homepage, https://songstats.com
|
|
6
|
+
Project-URL: Documentation, https://docs.songstats.com
|
|
7
|
+
Author: Songstats
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,music,sdk,songstats
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx<1.0.0,>=0.21.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Songstats Python SDK
|
|
27
|
+
|
|
28
|
+
Official Python client for the **Songstats Enterprise API**.
|
|
29
|
+
|
|
30
|
+
📚 API Documentation: https://docs.songstats.com
|
|
31
|
+
🔑 API Key Access: Please contact api@songstats.com
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Python >= 3.10
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
Install from PyPI:
|
|
44
|
+
|
|
45
|
+
pip install songstats-sdk
|
|
46
|
+
|
|
47
|
+
For local development:
|
|
48
|
+
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from songstats_sdk import SongstatsClient
|
|
57
|
+
|
|
58
|
+
client = SongstatsClient(api_key="YOUR_API_KEY")
|
|
59
|
+
|
|
60
|
+
# API status
|
|
61
|
+
status = client.info.status()
|
|
62
|
+
|
|
63
|
+
# Track information
|
|
64
|
+
track = client.tracks.info(songstats_track_id="abcd1234")
|
|
65
|
+
|
|
66
|
+
# Artist statistics
|
|
67
|
+
artist_stats = client.artists.stats(
|
|
68
|
+
songstats_artist_id="abcd1234",
|
|
69
|
+
source="spotify",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Authentication
|
|
76
|
+
|
|
77
|
+
All requests include your API key in the `apikey` header.
|
|
78
|
+
|
|
79
|
+
You can generate an API key in your Songstats Enterprise dashboard.
|
|
80
|
+
|
|
81
|
+
We recommend storing your key securely in environment variables:
|
|
82
|
+
|
|
83
|
+
export SONGSTATS_API_KEY=your_key_here
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Available Resource Clients
|
|
88
|
+
|
|
89
|
+
- `client.info`
|
|
90
|
+
- `client.tracks`
|
|
91
|
+
- `client.artists`
|
|
92
|
+
- `client.collaborators`
|
|
93
|
+
- `client.labels`
|
|
94
|
+
|
|
95
|
+
Info endpoints:
|
|
96
|
+
- `client.info.sources()` -> `/sources`
|
|
97
|
+
- `client.info.status()` -> `/status`
|
|
98
|
+
- `client.info.definitions()` -> `/definitions`
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Error Handling
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from songstats_sdk import SongstatsAPIError, SongstatsTransportError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
client.tracks.info(songstats_track_id="invalid")
|
|
109
|
+
except SongstatsAPIError as exc:
|
|
110
|
+
print(f"API error: {exc}")
|
|
111
|
+
except SongstatsTransportError as exc:
|
|
112
|
+
print(f"Transport error: {exc}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
To work on the SDK locally:
|
|
120
|
+
|
|
121
|
+
git clone https://github.com/songstats/songstats-python-sdk.git
|
|
122
|
+
cd songstats-python-sdk
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Versioning
|
|
129
|
+
|
|
130
|
+
This SDK follows Semantic Versioning (SemVer).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Songstats Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the **Songstats Enterprise API**.
|
|
4
|
+
|
|
5
|
+
📚 API Documentation: https://docs.songstats.com
|
|
6
|
+
🔑 API Key Access: Please contact api@songstats.com
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Python >= 3.10
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Install from PyPI:
|
|
19
|
+
|
|
20
|
+
pip install songstats-sdk
|
|
21
|
+
|
|
22
|
+
For local development:
|
|
23
|
+
|
|
24
|
+
pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from songstats_sdk import SongstatsClient
|
|
32
|
+
|
|
33
|
+
client = SongstatsClient(api_key="YOUR_API_KEY")
|
|
34
|
+
|
|
35
|
+
# API status
|
|
36
|
+
status = client.info.status()
|
|
37
|
+
|
|
38
|
+
# Track information
|
|
39
|
+
track = client.tracks.info(songstats_track_id="abcd1234")
|
|
40
|
+
|
|
41
|
+
# Artist statistics
|
|
42
|
+
artist_stats = client.artists.stats(
|
|
43
|
+
songstats_artist_id="abcd1234",
|
|
44
|
+
source="spotify",
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Authentication
|
|
51
|
+
|
|
52
|
+
All requests include your API key in the `apikey` header.
|
|
53
|
+
|
|
54
|
+
You can generate an API key in your Songstats Enterprise dashboard.
|
|
55
|
+
|
|
56
|
+
We recommend storing your key securely in environment variables:
|
|
57
|
+
|
|
58
|
+
export SONGSTATS_API_KEY=your_key_here
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Available Resource Clients
|
|
63
|
+
|
|
64
|
+
- `client.info`
|
|
65
|
+
- `client.tracks`
|
|
66
|
+
- `client.artists`
|
|
67
|
+
- `client.collaborators`
|
|
68
|
+
- `client.labels`
|
|
69
|
+
|
|
70
|
+
Info endpoints:
|
|
71
|
+
- `client.info.sources()` -> `/sources`
|
|
72
|
+
- `client.info.status()` -> `/status`
|
|
73
|
+
- `client.info.definitions()` -> `/definitions`
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Error Handling
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from songstats_sdk import SongstatsAPIError, SongstatsTransportError
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
client.tracks.info(songstats_track_id="invalid")
|
|
84
|
+
except SongstatsAPIError as exc:
|
|
85
|
+
print(f"API error: {exc}")
|
|
86
|
+
except SongstatsTransportError as exc:
|
|
87
|
+
print(f"Transport error: {exc}")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
To work on the SDK locally:
|
|
95
|
+
|
|
96
|
+
git clone https://github.com/songstats/songstats-python-sdk.git
|
|
97
|
+
cd songstats-python-sdk
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Versioning
|
|
104
|
+
|
|
105
|
+
This SDK follows Semantic Versioning (SemVer).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Enterprise Routes Audit (Songstats Rails -> Python SDK)
|
|
2
|
+
|
|
3
|
+
Audited against:
|
|
4
|
+
|
|
5
|
+
- `/Users/Oskar/1001tl/config/routes.rb`
|
|
6
|
+
- `/Users/Oskar/1001tl/app/controllers/enterprise/v1/*.rb`
|
|
7
|
+
- `/Users/Oskar/1001tl/app/helpers/enterprise_helper.rb`
|
|
8
|
+
|
|
9
|
+
Authentication observed in Rails: `apikey` request header.
|
|
10
|
+
|
|
11
|
+
## `/enterprise/v1/info`
|
|
12
|
+
|
|
13
|
+
| HTTP | Route | SDK Method |
|
|
14
|
+
| ---- | --------------- | ---------------------------- |
|
|
15
|
+
| GET | `/sources` | `client.info.sources()` |
|
|
16
|
+
| GET | `/status` | `client.info.status()` |
|
|
17
|
+
| GET | `/uptime_check` | `client.info.uptime_check()` |
|
|
18
|
+
| GET | `/definitions` | `client.info.definitions()` |
|
|
19
|
+
|
|
20
|
+
## `/enterprise/v1/tracks`
|
|
21
|
+
|
|
22
|
+
| HTTP | Route | SDK Method |
|
|
23
|
+
| ------ | ----------------------------------- | ------------------------------------------------------- |
|
|
24
|
+
| GET | `/info` | `client.tracks.info(...)` |
|
|
25
|
+
| GET | `/stats` | `client.tracks.stats(...)` |
|
|
26
|
+
| GET | `/historic_stats` | `client.tracks.historic_stats(...)` |
|
|
27
|
+
| GET | `/search` | `client.tracks.search(q=..., ...)` |
|
|
28
|
+
| GET | `/activities` | `client.tracks.activities(...)` |
|
|
29
|
+
| GET | `/comments` | `client.tracks.comments(...)` |
|
|
30
|
+
| GET | `/songshare` | `client.tracks.songshare(...)` |
|
|
31
|
+
| GET | `/locations` | `client.tracks.locations(...)` |
|
|
32
|
+
| POST | `/link_request` | `client.tracks.add_link_request(link=..., ...)` |
|
|
33
|
+
| DELETE | `/link_request` | `client.tracks.remove_link_request(link=..., ...)` |
|
|
34
|
+
| POST | `/add_to_member_relevant_list` | `client.tracks.add_to_member_relevant_list(...)` |
|
|
35
|
+
| DELETE | `/remove_from_member_relevant_list` | `client.tracks.remove_from_member_relevant_list(...)` |
|
|
36
|
+
|
|
37
|
+
## `/enterprise/v1/artists`
|
|
38
|
+
|
|
39
|
+
| HTTP | Route | SDK Method |
|
|
40
|
+
| ------ | ----------------------------------- | ----------------------------------------------------------- |
|
|
41
|
+
| GET | `/info` | `client.artists.info(...)` |
|
|
42
|
+
| GET | `/stats` | `client.artists.stats(...)` |
|
|
43
|
+
| GET | `/historic_stats` | `client.artists.historic_stats(...)` |
|
|
44
|
+
| GET | `/audience` | `client.artists.audience(...)` |
|
|
45
|
+
| GET | `/audience/details` | `client.artists.audience_details(country_code=..., ...)` |
|
|
46
|
+
| GET | `/catalog` | `client.artists.catalog(...)` |
|
|
47
|
+
| GET | `/search` | `client.artists.search(q=..., ...)` |
|
|
48
|
+
| GET | `/activities` | `client.artists.activities(...)` |
|
|
49
|
+
| GET | `/songshare` | `client.artists.songshare(...)` |
|
|
50
|
+
| GET | `/top_tracks` | `client.artists.top_tracks(...)` |
|
|
51
|
+
| GET | `/top_playlists` | `client.artists.top_playlists(...)` |
|
|
52
|
+
| GET | `/top_curators` | `client.artists.top_curators(...)` |
|
|
53
|
+
| GET | `/top_commentors` | `client.artists.top_commentors(...)` |
|
|
54
|
+
| POST | `/link_request` | `client.artists.add_link_request(link=..., ...)` |
|
|
55
|
+
| DELETE | `/link_request` | `client.artists.remove_link_request(link=..., ...)` |
|
|
56
|
+
| POST | `/track_request` | `client.artists.add_track_request(...)` |
|
|
57
|
+
| DELETE | `/track_request` | `client.artists.remove_track_request(...)` |
|
|
58
|
+
| POST | `/add_to_member_relevant_list` | `client.artists.add_to_member_relevant_list(...)` |
|
|
59
|
+
| DELETE | `/remove_from_member_relevant_list` | `client.artists.remove_from_member_relevant_list(...)` |
|
|
60
|
+
|
|
61
|
+
## `/enterprise/v1/collaborators`
|
|
62
|
+
|
|
63
|
+
| HTTP | Route | SDK Method |
|
|
64
|
+
| ------ | ----------------------------------- | ----------------------------------------------------------------- |
|
|
65
|
+
| GET | `/info` | `client.collaborators.info(...)` |
|
|
66
|
+
| GET | `/stats` | `client.collaborators.stats(...)` |
|
|
67
|
+
| GET | `/historic_stats` | `client.collaborators.historic_stats(...)` |
|
|
68
|
+
| GET | `/audience` | `client.collaborators.audience(...)` |
|
|
69
|
+
| GET | `/audience/details` | `client.collaborators.audience_details(country_code=..., ...)` |
|
|
70
|
+
| GET | `/catalog` | `client.collaborators.catalog(...)` |
|
|
71
|
+
| GET | `/search` | `client.collaborators.search(q=..., ...)` |
|
|
72
|
+
| GET | `/activities` | `client.collaborators.activities(...)` |
|
|
73
|
+
| GET | `/songshare` | `client.collaborators.songshare(...)` |
|
|
74
|
+
| GET | `/top_tracks` | `client.collaborators.top_tracks(...)` |
|
|
75
|
+
| GET | `/top_playlists` | `client.collaborators.top_playlists(...)` |
|
|
76
|
+
| GET | `/top_curators` | `client.collaborators.top_curators(...)` |
|
|
77
|
+
| GET | `/top_commentors` | `client.collaborators.top_commentors(...)` |
|
|
78
|
+
| POST | `/link_request` | `client.collaborators.add_link_request(link=..., ...)` |
|
|
79
|
+
| DELETE | `/link_request` | `client.collaborators.remove_link_request(link=..., ...)` |
|
|
80
|
+
| POST | `/track_request` | `client.collaborators.add_track_request(...)` |
|
|
81
|
+
| DELETE | `/track_request` | `client.collaborators.remove_track_request(...)` |
|
|
82
|
+
| POST | `/add_to_member_relevant_list` | `client.collaborators.add_to_member_relevant_list(...)` |
|
|
83
|
+
| DELETE | `/remove_from_member_relevant_list` | `client.collaborators.remove_from_member_relevant_list(...)` |
|
|
84
|
+
|
|
85
|
+
## `/enterprise/v1/labels`
|
|
86
|
+
|
|
87
|
+
| HTTP | Route | SDK Method |
|
|
88
|
+
| ------ | ----------------------------------- | ---------------------------------------------------------- |
|
|
89
|
+
| GET | `/info` | `client.labels.info(...)` |
|
|
90
|
+
| GET | `/stats` | `client.labels.stats(...)` |
|
|
91
|
+
| GET | `/historic_stats` | `client.labels.historic_stats(...)` |
|
|
92
|
+
| GET | `/audience` | `client.labels.audience(...)` |
|
|
93
|
+
| GET | `/audience/details` | `client.labels.audience_details(country_code=..., ...)` |
|
|
94
|
+
| GET | `/catalog` | `client.labels.catalog(...)` |
|
|
95
|
+
| GET | `/search` | `client.labels.search(q=..., ...)` |
|
|
96
|
+
| GET | `/activities` | `client.labels.activities(...)` |
|
|
97
|
+
| GET | `/songshare` | `client.labels.songshare(...)` |
|
|
98
|
+
| GET | `/top_tracks` | `client.labels.top_tracks(...)` |
|
|
99
|
+
| GET | `/top_playlists` | `client.labels.top_playlists(...)` |
|
|
100
|
+
| GET | `/top_curators` | `client.labels.top_curators(...)` |
|
|
101
|
+
| GET | `/top_commentors` | `client.labels.top_commentors(...)` |
|
|
102
|
+
| POST | `/link_request` | `client.labels.add_link_request(link=..., ...)` |
|
|
103
|
+
| DELETE | `/link_request` | `client.labels.remove_link_request(link=..., ...)` |
|
|
104
|
+
| POST | `/track_request` | `client.labels.add_track_request(...)` |
|
|
105
|
+
| DELETE | `/track_request` | `client.labels.remove_track_request(...)` |
|
|
106
|
+
| POST | `/add_to_member_relevant_list` | `client.labels.add_to_member_relevant_list(...)` |
|
|
107
|
+
| DELETE | `/remove_from_member_relevant_list` | `client.labels.remove_from_member_relevant_list(...)` |
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "songstats-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Songstats Enterprise API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Songstats" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["songstats", "sdk", "api", "music"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27.0,<1.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0.0,<9.0.0",
|
|
33
|
+
"respx>=0.21.0,<1.0.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://songstats.com"
|
|
38
|
+
Documentation = "https://docs.songstats.com"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/songstats_sdk"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
addopts = "-q"
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .client import SongstatsClient
|
|
2
|
+
from .exceptions import SongstatsAPIError, SongstatsError, SongstatsTransportError
|
|
3
|
+
from .version import VERSION
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"SongstatsAPIError",
|
|
7
|
+
"SongstatsClient",
|
|
8
|
+
"SongstatsError",
|
|
9
|
+
"SongstatsTransportError",
|
|
10
|
+
"VERSION",
|
|
11
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .http import SongstatsHTTPClient
|
|
6
|
+
from .resources import (
|
|
7
|
+
ArtistsAPI,
|
|
8
|
+
CollaboratorsAPI,
|
|
9
|
+
InfoAPI,
|
|
10
|
+
LabelsAPI,
|
|
11
|
+
TracksAPI,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SongstatsClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
api_key: str,
|
|
20
|
+
base_url: str = "https://data.songstats.com",
|
|
21
|
+
timeout: float = 30.0,
|
|
22
|
+
max_retries: int = 2,
|
|
23
|
+
user_agent: str | None = None,
|
|
24
|
+
httpx_client: httpx.Client | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._http = SongstatsHTTPClient(
|
|
27
|
+
api_key=api_key,
|
|
28
|
+
base_url=base_url,
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
max_retries=max_retries,
|
|
31
|
+
user_agent=user_agent,
|
|
32
|
+
httpx_client=httpx_client,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self.info = InfoAPI(self._http)
|
|
36
|
+
self.tracks = TracksAPI(self._http)
|
|
37
|
+
self.artists = ArtistsAPI(self._http)
|
|
38
|
+
self.collaborators = CollaboratorsAPI(self._http)
|
|
39
|
+
self.labels = LabelsAPI(self._http)
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
self._http.close()
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "SongstatsClient":
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
48
|
+
self.close()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SongstatsError(Exception):
|
|
7
|
+
"""Base exception for SDK failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SongstatsTransportError(SongstatsError):
|
|
11
|
+
"""Raised for network/transport failures before an HTTP response is received."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SongstatsAPIError(SongstatsError):
|
|
15
|
+
"""Raised when the Songstats API responds with a non-2xx status code."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status_code: int, payload: Any = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.payload = payload
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return f"Songstats API error ({self.status_code}): {self.message}"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .exceptions import SongstatsAPIError, SongstatsTransportError
|
|
9
|
+
from .version import VERSION
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://data.songstats.com"
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
13
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SongstatsHTTPClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
api_key: str,
|
|
21
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
22
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
23
|
+
max_retries: int = 2,
|
|
24
|
+
user_agent: str | None = None,
|
|
25
|
+
httpx_client: httpx.Client | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
if not api_key:
|
|
28
|
+
raise ValueError("api_key is required")
|
|
29
|
+
if max_retries < 0:
|
|
30
|
+
raise ValueError("max_retries must be >= 0")
|
|
31
|
+
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
self.max_retries = max_retries
|
|
34
|
+
self._owns_client = httpx_client is None
|
|
35
|
+
self._client = httpx_client or httpx.Client(
|
|
36
|
+
base_url=self.base_url,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
headers={
|
|
39
|
+
"apikey": api_key,
|
|
40
|
+
"accept": "application/json",
|
|
41
|
+
"user-agent": user_agent or f"songstats-python-sdk/{VERSION}",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
if self._owns_client:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
params: Mapping[str, Any] | None = None,
|
|
55
|
+
json: Mapping[str, Any] | None = None,
|
|
56
|
+
) -> Any:
|
|
57
|
+
endpoint = f"/enterprise/v1/{path.lstrip('/')}"
|
|
58
|
+
last_transport_error: Exception | None = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(self.max_retries + 1):
|
|
61
|
+
try:
|
|
62
|
+
response = self._client.request(method=method, url=endpoint, params=params, json=json)
|
|
63
|
+
except httpx.RequestError as exc:
|
|
64
|
+
last_transport_error = exc
|
|
65
|
+
if attempt < self.max_retries:
|
|
66
|
+
time.sleep(0.2 * (2**attempt))
|
|
67
|
+
continue
|
|
68
|
+
raise SongstatsTransportError(str(exc)) from exc
|
|
69
|
+
|
|
70
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
71
|
+
time.sleep(0.2 * (2**attempt))
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if response.is_success:
|
|
75
|
+
return _decode_json_response(response)
|
|
76
|
+
|
|
77
|
+
raise _build_api_error(response)
|
|
78
|
+
|
|
79
|
+
if last_transport_error is not None:
|
|
80
|
+
raise SongstatsTransportError(str(last_transport_error)) from last_transport_error
|
|
81
|
+
|
|
82
|
+
raise SongstatsTransportError("Request failed without response")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_json_response(response: httpx.Response) -> Any:
|
|
86
|
+
if not response.text:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return response.json()
|
|
90
|
+
except ValueError:
|
|
91
|
+
return {"raw": response.text}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_api_error(response: httpx.Response) -> SongstatsAPIError:
|
|
95
|
+
payload: Any
|
|
96
|
+
message = response.reason_phrase
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
payload = response.json()
|
|
100
|
+
except ValueError:
|
|
101
|
+
payload = {"raw": response.text}
|
|
102
|
+
else:
|
|
103
|
+
if isinstance(payload, dict):
|
|
104
|
+
message = str(payload.get("message") or payload.get("error") or message)
|
|
105
|
+
|
|
106
|
+
return SongstatsAPIError(message=message, status_code=response.status_code, payload=payload)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
from ..http import SongstatsHTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ResourceAPI:
|
|
9
|
+
def __init__(self, http_client: SongstatsHTTPClient) -> None:
|
|
10
|
+
self._http = http_client
|
|
11
|
+
|
|
12
|
+
def _get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
13
|
+
return self._http.request("GET", path, params=_normalize_params(params))
|
|
14
|
+
|
|
15
|
+
def _post(
|
|
16
|
+
self,
|
|
17
|
+
path: str,
|
|
18
|
+
*,
|
|
19
|
+
params: Mapping[str, Any] | None = None,
|
|
20
|
+
json: Mapping[str, Any] | None = None,
|
|
21
|
+
) -> Any:
|
|
22
|
+
clean_params = _normalize_params(params)
|
|
23
|
+
clean_json = _normalize_params(json)
|
|
24
|
+
return self._http.request("POST", path, params=clean_params, json=clean_json)
|
|
25
|
+
|
|
26
|
+
def _delete(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
27
|
+
return self._http.request("DELETE", path, params=_normalize_params(params))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
31
|
+
if not params:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
normalized: dict[str, Any] = {}
|
|
35
|
+
for key, value in params.items():
|
|
36
|
+
if value is None:
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
normalized[key] = "true" if value else "false"
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(value, (list, tuple, set)):
|
|
42
|
+
normalized[key] = ",".join(str(item) for item in value)
|
|
43
|
+
continue
|
|
44
|
+
normalized[key] = value
|
|
45
|
+
|
|
46
|
+
return normalized or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_any_identifier(params: Mapping[str, Any], identifier_keys: Iterable[str]) -> None:
|
|
50
|
+
if any(params.get(key) not in (None, "") for key in identifier_keys):
|
|
51
|
+
return
|
|
52
|
+
joined = ", ".join(identifier_keys)
|
|
53
|
+
raise ValueError(f"One identifier is required. Supported keys: {joined}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .base import ResourceAPI, require_any_identifier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntityAPI(ResourceAPI):
|
|
9
|
+
def __init__(self, http_client, *, resource: str, identifier_keys: tuple[str, ...]) -> None:
|
|
10
|
+
super().__init__(http_client)
|
|
11
|
+
self._resource = resource
|
|
12
|
+
self._identifier_keys = identifier_keys
|
|
13
|
+
|
|
14
|
+
def info(self, **params: Any) -> Any:
|
|
15
|
+
return self._get(f"{self._resource}/info", params=self._with_identifier(params))
|
|
16
|
+
|
|
17
|
+
def stats(self, **params: Any) -> Any:
|
|
18
|
+
return self._get(f"{self._resource}/stats", params=self._with_identifier(params))
|
|
19
|
+
|
|
20
|
+
def historic_stats(self, **params: Any) -> Any:
|
|
21
|
+
return self._get(f"{self._resource}/historic_stats", params=self._with_identifier(params))
|
|
22
|
+
|
|
23
|
+
def audience(self, **params: Any) -> Any:
|
|
24
|
+
return self._get(f"{self._resource}/audience", params=self._with_identifier(params))
|
|
25
|
+
|
|
26
|
+
def audience_details(self, *, country_code: str, **params: Any) -> Any:
|
|
27
|
+
if not country_code:
|
|
28
|
+
raise ValueError("country_code is required")
|
|
29
|
+
|
|
30
|
+
query = self._with_identifier(params)
|
|
31
|
+
query["country_code"] = country_code
|
|
32
|
+
return self._get(f"{self._resource}/audience/details", params=query)
|
|
33
|
+
|
|
34
|
+
def catalog(self, **params: Any) -> Any:
|
|
35
|
+
return self._get(f"{self._resource}/catalog", params=self._with_identifier(params))
|
|
36
|
+
|
|
37
|
+
def search(self, *, q: str, **params: Any) -> Any:
|
|
38
|
+
if not q:
|
|
39
|
+
raise ValueError("q is required")
|
|
40
|
+
|
|
41
|
+
query = {"q": q}
|
|
42
|
+
query.update(params)
|
|
43
|
+
return self._get(f"{self._resource}/search", params=query)
|
|
44
|
+
|
|
45
|
+
def activities(self, **params: Any) -> Any:
|
|
46
|
+
return self._get(f"{self._resource}/activities", params=self._with_identifier(params))
|
|
47
|
+
|
|
48
|
+
def songshare(self, **params: Any) -> Any:
|
|
49
|
+
return self._get(f"{self._resource}/songshare", params=self._with_identifier(params))
|
|
50
|
+
|
|
51
|
+
def top_tracks(self, **params: Any) -> Any:
|
|
52
|
+
return self._get(f"{self._resource}/top_tracks", params=self._with_identifier(params))
|
|
53
|
+
|
|
54
|
+
def top_playlists(self, **params: Any) -> Any:
|
|
55
|
+
return self._get(f"{self._resource}/top_playlists", params=self._with_identifier(params))
|
|
56
|
+
|
|
57
|
+
def top_curators(self, **params: Any) -> Any:
|
|
58
|
+
return self._get(f"{self._resource}/top_curators", params=self._with_identifier(params))
|
|
59
|
+
|
|
60
|
+
def top_commentors(self, **params: Any) -> Any:
|
|
61
|
+
return self._get(f"{self._resource}/top_commentors", params=self._with_identifier(params))
|
|
62
|
+
|
|
63
|
+
def add_link_request(self, *, link: str, **params: Any) -> Any:
|
|
64
|
+
if not link:
|
|
65
|
+
raise ValueError("link is required")
|
|
66
|
+
|
|
67
|
+
query = self._with_identifier(params)
|
|
68
|
+
query["link"] = link
|
|
69
|
+
return self._post(f"{self._resource}/link_request", params=query)
|
|
70
|
+
|
|
71
|
+
def remove_link_request(self, *, link: str, **params: Any) -> Any:
|
|
72
|
+
if not link:
|
|
73
|
+
raise ValueError("link is required")
|
|
74
|
+
|
|
75
|
+
query = self._with_identifier(params)
|
|
76
|
+
query["link"] = link
|
|
77
|
+
return self._delete(f"{self._resource}/link_request", params=query)
|
|
78
|
+
|
|
79
|
+
def add_track_request(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
link: str | None = None,
|
|
83
|
+
spotify_track_id: str | None = None,
|
|
84
|
+
isrc: str | None = None,
|
|
85
|
+
**params: Any,
|
|
86
|
+
) -> Any:
|
|
87
|
+
if not any([link, spotify_track_id, isrc]):
|
|
88
|
+
raise ValueError("One of link, spotify_track_id, or isrc is required")
|
|
89
|
+
|
|
90
|
+
query = self._with_identifier(params)
|
|
91
|
+
query.update({
|
|
92
|
+
"link": link,
|
|
93
|
+
"spotify_track_id": spotify_track_id,
|
|
94
|
+
"isrc": isrc,
|
|
95
|
+
})
|
|
96
|
+
return self._post(f"{self._resource}/track_request", params=query)
|
|
97
|
+
|
|
98
|
+
def remove_track_request(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
songstats_track_id: str | None = None,
|
|
102
|
+
spotify_track_id: str | None = None,
|
|
103
|
+
**params: Any,
|
|
104
|
+
) -> Any:
|
|
105
|
+
if not songstats_track_id and not spotify_track_id:
|
|
106
|
+
raise ValueError("songstats_track_id or spotify_track_id is required")
|
|
107
|
+
|
|
108
|
+
query = self._with_identifier(params)
|
|
109
|
+
query.update({
|
|
110
|
+
"songstats_track_id": songstats_track_id,
|
|
111
|
+
"spotify_track_id": spotify_track_id,
|
|
112
|
+
})
|
|
113
|
+
return self._delete(f"{self._resource}/track_request", params=query)
|
|
114
|
+
|
|
115
|
+
def add_to_member_relevant_list(self, **params: Any) -> Any:
|
|
116
|
+
return self._post(
|
|
117
|
+
f"{self._resource}/add_to_member_relevant_list",
|
|
118
|
+
params=self._with_identifier(params),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def remove_from_member_relevant_list(self, **params: Any) -> Any:
|
|
122
|
+
return self._delete(
|
|
123
|
+
f"{self._resource}/remove_from_member_relevant_list",
|
|
124
|
+
params=self._with_identifier(params),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _with_identifier(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
query = dict(params)
|
|
129
|
+
require_any_identifier(query, self._identifier_keys)
|
|
130
|
+
return query
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ArtistsAPI(EntityAPI):
|
|
134
|
+
def __init__(self, http_client) -> None:
|
|
135
|
+
super().__init__(
|
|
136
|
+
http_client,
|
|
137
|
+
resource="artists",
|
|
138
|
+
identifier_keys=("songstats_artist_id", "spotify_artist_id", "apple_music_artist_id"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class CollaboratorsAPI(EntityAPI):
|
|
143
|
+
def __init__(self, http_client) -> None:
|
|
144
|
+
super().__init__(
|
|
145
|
+
http_client,
|
|
146
|
+
resource="collaborators",
|
|
147
|
+
identifier_keys=(
|
|
148
|
+
"songstats_collaborator_id",
|
|
149
|
+
"spotify_artist_id",
|
|
150
|
+
"apple_music_artist_id",
|
|
151
|
+
"tidal_artist_id",
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class LabelsAPI(EntityAPI):
|
|
157
|
+
def __init__(self, http_client) -> None:
|
|
158
|
+
super().__init__(
|
|
159
|
+
http_client,
|
|
160
|
+
resource="labels",
|
|
161
|
+
identifier_keys=("songstats_label_id", "beatport_label_id"),
|
|
162
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .base import ResourceAPI
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InfoAPI(ResourceAPI):
|
|
9
|
+
def sources(self) -> Any:
|
|
10
|
+
return self._get("sources")
|
|
11
|
+
|
|
12
|
+
def status(self) -> Any:
|
|
13
|
+
return self._get("status")
|
|
14
|
+
|
|
15
|
+
def uptime_check(self) -> Any:
|
|
16
|
+
return self._get("uptime_check")
|
|
17
|
+
|
|
18
|
+
def definitions(self) -> Any:
|
|
19
|
+
return self._get("definitions")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .base import ResourceAPI, require_any_identifier
|
|
6
|
+
|
|
7
|
+
_TRACK_IDENTIFIER_KEYS = (
|
|
8
|
+
"songstats_track_id",
|
|
9
|
+
"spotify_track_id",
|
|
10
|
+
"apple_music_track_id",
|
|
11
|
+
"isrc",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TracksAPI(ResourceAPI):
|
|
16
|
+
def info(self, **params: Any) -> Any:
|
|
17
|
+
query = _require_track_identifier(params)
|
|
18
|
+
return self._get("tracks/info", params=query)
|
|
19
|
+
|
|
20
|
+
def stats(self, **params: Any) -> Any:
|
|
21
|
+
query = _require_track_identifier(params)
|
|
22
|
+
return self._get("tracks/stats", params=query)
|
|
23
|
+
|
|
24
|
+
def historic_stats(self, **params: Any) -> Any:
|
|
25
|
+
query = _require_track_identifier(params)
|
|
26
|
+
return self._get("tracks/historic_stats", params=query)
|
|
27
|
+
|
|
28
|
+
def activities(self, **params: Any) -> Any:
|
|
29
|
+
query = _require_track_identifier(params)
|
|
30
|
+
return self._get("tracks/activities", params=query)
|
|
31
|
+
|
|
32
|
+
def comments(self, **params: Any) -> Any:
|
|
33
|
+
query = _require_track_identifier(params)
|
|
34
|
+
return self._get("tracks/comments", params=query)
|
|
35
|
+
|
|
36
|
+
def songshare(self, **params: Any) -> Any:
|
|
37
|
+
query = _require_track_identifier(params)
|
|
38
|
+
return self._get("tracks/songshare", params=query)
|
|
39
|
+
|
|
40
|
+
def locations(self, **params: Any) -> Any:
|
|
41
|
+
query = _require_track_identifier(params)
|
|
42
|
+
return self._get("tracks/locations", params=query)
|
|
43
|
+
|
|
44
|
+
def search(self, *, q: str, **params: Any) -> Any:
|
|
45
|
+
if not q:
|
|
46
|
+
raise ValueError("q is required")
|
|
47
|
+
|
|
48
|
+
query = {"q": q}
|
|
49
|
+
query.update(params)
|
|
50
|
+
return self._get("tracks/search", params=query)
|
|
51
|
+
|
|
52
|
+
def add_link_request(self, *, link: str, **params: Any) -> Any:
|
|
53
|
+
if not link:
|
|
54
|
+
raise ValueError("link is required")
|
|
55
|
+
|
|
56
|
+
query = _require_track_identifier(params)
|
|
57
|
+
query["link"] = link
|
|
58
|
+
return self._post("tracks/link_request", params=query)
|
|
59
|
+
|
|
60
|
+
def remove_link_request(self, *, link: str, **params: Any) -> Any:
|
|
61
|
+
if not link:
|
|
62
|
+
raise ValueError("link is required")
|
|
63
|
+
|
|
64
|
+
query = _require_track_identifier(params)
|
|
65
|
+
query["link"] = link
|
|
66
|
+
return self._delete("tracks/link_request", params=query)
|
|
67
|
+
|
|
68
|
+
def add_to_member_relevant_list(self, **params: Any) -> Any:
|
|
69
|
+
query = _require_track_identifier(params)
|
|
70
|
+
return self._post("tracks/add_to_member_relevant_list", params=query)
|
|
71
|
+
|
|
72
|
+
def remove_from_member_relevant_list(self, **params: Any) -> Any:
|
|
73
|
+
query = _require_track_identifier(params)
|
|
74
|
+
return self._delete("tracks/remove_from_member_relevant_list", params=query)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _require_track_identifier(params: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
query = dict(params)
|
|
79
|
+
require_any_identifier(query, _TRACK_IDENTIFIER_KEYS)
|
|
80
|
+
return query
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from songstats_sdk import SongstatsAPIError, SongstatsClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@respx.mock
|
|
11
|
+
def test_info_status_sends_apikey_header() -> None:
|
|
12
|
+
route = respx.get("https://data.songstats.com/enterprise/v1/status").mock(
|
|
13
|
+
return_value=httpx.Response(200, json={"result": "success"})
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
client = SongstatsClient(api_key="test_key")
|
|
17
|
+
data = client.info.status()
|
|
18
|
+
|
|
19
|
+
assert data["result"] == "success"
|
|
20
|
+
assert route.called
|
|
21
|
+
assert route.calls.last.request.headers["apikey"] == "test_key"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@respx.mock
|
|
25
|
+
def test_info_sources_and_definitions_routes() -> None:
|
|
26
|
+
sources = respx.get("https://data.songstats.com/enterprise/v1/sources").mock(
|
|
27
|
+
return_value=httpx.Response(200, json={"result": "success", "sources": []})
|
|
28
|
+
)
|
|
29
|
+
definitions = respx.get("https://data.songstats.com/enterprise/v1/definitions").mock(
|
|
30
|
+
return_value=httpx.Response(200, json={"result": "success", "definitions": {}})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
client = SongstatsClient(api_key="test_key")
|
|
34
|
+
client.info.sources()
|
|
35
|
+
client.info.definitions()
|
|
36
|
+
|
|
37
|
+
assert sources.called
|
|
38
|
+
assert definitions.called
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@respx.mock
|
|
42
|
+
def test_tracks_info_hits_expected_route_and_params() -> None:
|
|
43
|
+
route = respx.get("https://data.songstats.com/enterprise/v1/tracks/info").mock(
|
|
44
|
+
return_value=httpx.Response(200, json={"result": "success"})
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
client = SongstatsClient(api_key="test_key")
|
|
48
|
+
client.tracks.info(songstats_track_id="abcd1234", with_links=True)
|
|
49
|
+
|
|
50
|
+
request = route.calls.last.request
|
|
51
|
+
assert request.url.params["songstats_track_id"] == "abcd1234"
|
|
52
|
+
assert request.url.params["with_links"] == "true"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@respx.mock
|
|
56
|
+
def test_collaborators_top_curators_is_mapped() -> None:
|
|
57
|
+
route = respx.get("https://data.songstats.com/enterprise/v1/collaborators/top_curators").mock(
|
|
58
|
+
return_value=httpx.Response(200, json={"result": "success"})
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
client = SongstatsClient(api_key="test_key")
|
|
62
|
+
client.collaborators.top_curators(songstats_collaborator_id="collab1234", source="spotify")
|
|
63
|
+
|
|
64
|
+
request = route.calls.last.request
|
|
65
|
+
assert request.url.params["songstats_collaborator_id"] == "collab1234"
|
|
66
|
+
assert request.url.params["source"] == "spotify"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@respx.mock
|
|
70
|
+
def test_api_error_raises_songstats_api_error() -> None:
|
|
71
|
+
respx.get("https://data.songstats.com/enterprise/v1/status").mock(
|
|
72
|
+
return_value=httpx.Response(401, json={"result": "error", "message": "Invalid Api Key"})
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
client = SongstatsClient(api_key="bad_key")
|
|
76
|
+
|
|
77
|
+
with pytest.raises(SongstatsAPIError) as exc:
|
|
78
|
+
client.info.status()
|
|
79
|
+
|
|
80
|
+
assert exc.value.status_code == 401
|
|
81
|
+
assert "Invalid Api Key" in str(exc.value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_identifier_validation() -> None:
|
|
85
|
+
client = SongstatsClient(api_key="test_key")
|
|
86
|
+
|
|
87
|
+
with pytest.raises(ValueError):
|
|
88
|
+
client.labels.info()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@respx.mock
|
|
92
|
+
def test_artists_search_route() -> None:
|
|
93
|
+
route = respx.get("https://data.songstats.com/enterprise/v1/artists/search").mock(
|
|
94
|
+
return_value=httpx.Response(200, json={"result": "success", "results": []})
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
client = SongstatsClient(api_key="test_key")
|
|
98
|
+
client.artists.search(q="fred again", limit=10)
|
|
99
|
+
|
|
100
|
+
request = route.calls.last.request
|
|
101
|
+
assert request.url.params["q"] == "fred again"
|
|
102
|
+
assert request.url.params["limit"] == "10"
|