spotifygraphqlconnector 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.
- spotifygraphqlconnector-0.1.0/PKG-INFO +312 -0
- spotifygraphqlconnector-0.1.0/README.md +300 -0
- spotifygraphqlconnector-0.1.0/pyproject.toml +64 -0
- spotifygraphqlconnector-0.1.0/src/spotifygraphqlconnector/__init__.py +20 -0
- spotifygraphqlconnector-0.1.0/src/spotifygraphqlconnector/__main__.py +345 -0
- spotifygraphqlconnector-0.1.0/src/spotifygraphqlconnector/connector.py +1170 -0
- spotifygraphqlconnector-0.1.0/src/spotifygraphqlconnector/types.py +101 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spotifygraphqlconnector
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spotify GraphQL Connector for Podcast Data
|
|
5
|
+
Author: Open Podcast
|
|
6
|
+
Requires-Dist: loguru>=0.7.3
|
|
7
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
8
|
+
Requires-Dist: requests>=2.32.5
|
|
9
|
+
Requires-Dist: tenacity>=9.1.4
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Spotify GraphQL Connector
|
|
14
|
+
|
|
15
|
+
An unofficial Python connector for the [Spotify Creators](https://creators.spotify.com)
|
|
16
|
+
GraphQL API - the backend that powers the Spotify Creators dashboard (formerly Anchor).
|
|
17
|
+
|
|
18
|
+
> **Disclaimer:** This connector uses an undocumented, internal API that may change at
|
|
19
|
+
> any time without notice. Use at your own risk.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Does it work for Anchor? For Spotify?
|
|
24
|
+
|
|
25
|
+
**Anchor (primary use case): Supported **
|
|
26
|
+
|
|
27
|
+
Anchor was rebranded to Spotify for Podcasters and then Spotify Creators. The dashboard
|
|
28
|
+
at `creators.spotify.com` is now the only interface for managing Anchor-hosted podcasts.
|
|
29
|
+
This connector talks directly to the GraphQL API behind that dashboard - it is the
|
|
30
|
+
**direct replacement for the [`anchor-connector`](https://github.com/openpodcast/anchor-connector)**, which stopped working when Spotify migrated its backend to GraphQL.
|
|
31
|
+
|
|
32
|
+
Shows hosted on Anchor/Spotify for Podcasters appear as `hostingProvider: "S4P"` and
|
|
33
|
+
have full analytics access across all endpoints.
|
|
34
|
+
|
|
35
|
+
**Non-Anchor Spotify shows: Partial Support**
|
|
36
|
+
|
|
37
|
+
Shows hosted elsewhere (Apple, Megaphone, RSS feeds, etc.) appear as `NonHostedShow`
|
|
38
|
+
with `hostingProvider: "OTHER_THIRD_PARTY"` or `"MEGAPHONE"`. These shows are visible
|
|
39
|
+
in the dashboard and return data from some endpoints (metadata, impressions, geo stats),
|
|
40
|
+
but deep analytics like performance curves and consumption data are only available for
|
|
41
|
+
S4P-hosted shows.
|
|
42
|
+
|
|
43
|
+
**In short:** if you were using `anchor-connector`, replace it with this connector.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **Single credential** - only `sp_dc` and `sp_key` session cookies are required.
|
|
50
|
+
Show URI and station ID are resolved automatically from your account.
|
|
51
|
+
- **Auto-authentication** - performs the full PKCE OAuth 2.0 flow used by the Spotify
|
|
52
|
+
Creators web app and transparently refreshes the bearer token before expiry.
|
|
53
|
+
- **Auto-pagination** - `get_all_episodes()` fetches every page automatically.
|
|
54
|
+
- **Retry logic** - exponential back-off on transient errors (429, 502, 503, 504)
|
|
55
|
+
and automatic token refresh on 401.
|
|
56
|
+
- **Type-safe** - fully annotated with a recursive `JsonDict` type alias; zero `Any`.
|
|
57
|
+
- **Native response shape** - responses are returned exactly as the API sends them
|
|
58
|
+
so consumers can adapt to the real data without a lossy mapping layer.
|
|
59
|
+
- **`uv`-powered** - dependency management and packaging via
|
|
60
|
+
[uv](https://docs.astral.sh/uv/).
|
|
61
|
+
- **24 operations** covering the full Spotify Creators analytics surface.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Requirements
|
|
66
|
+
|
|
67
|
+
- Python 3.11+
|
|
68
|
+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# As a library in your own project
|
|
76
|
+
uv add spotifygraphqlconnector
|
|
77
|
+
|
|
78
|
+
# Clone for development
|
|
79
|
+
git clone https://github.com/openpodcast/spotify-connector-graphql
|
|
80
|
+
cd spotify-connector-graphql/spotifygraphqlconnector
|
|
81
|
+
uv sync --all-groups
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Credentials
|
|
87
|
+
|
|
88
|
+
You need two cookies from an active Spotify session: `sp_dc` and `sp_key`.
|
|
89
|
+
|
|
90
|
+
### How to obtain them
|
|
91
|
+
|
|
92
|
+
1. Open [https://creators.spotify.com](https://creators.spotify.com) in your browser
|
|
93
|
+
and log in.
|
|
94
|
+
2. Open **DevTools** (`F12` / `⌘ ⌥ I`) → **Application** → **Cookies** →
|
|
95
|
+
`https://accounts.spotify.com`.
|
|
96
|
+
3. Copy the values of the `sp_dc` and `sp_key` cookies.
|
|
97
|
+
|
|
98
|
+
These cookies are typically valid for several months. When they expire you will see a
|
|
99
|
+
`CredentialsExpired` exception - just grab fresh cookie values from your browser.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
All configuration is via environment variables:
|
|
106
|
+
|
|
107
|
+
| Variable | Required | Default | Description |
|
|
108
|
+
| ---------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| `SPOTIFY_SP_DC` | **Yes** | - | `sp_dc` cookie from a Spotify session |
|
|
110
|
+
| `SPOTIFY_SP_KEY` | **Yes** | - | `sp_key` cookie from a Spotify session |
|
|
111
|
+
| `SPOTIFY_SHOW_URI` | No | auto | Spotify show URI, e.g. `spotify:show:1HaFboRBVORs2VCpNACYLn`. Auto-resolved from the first S4P-hosted show on your account. |
|
|
112
|
+
| `SPOTIFY_STATION_ID` | No | auto | Numeric Anchor/Spotify station ID. Auto-resolved from show metadata. |
|
|
113
|
+
| `SPOTIFY_EPISODE_URI` | No | auto | Episode URI for per-episode analytics in the CLI. Auto-resolved from the first episode. |
|
|
114
|
+
| `SPOTIFY_COUNTRY_CODE` | No | `US` | ISO 3166-1 alpha-2 country code for the registration endpoint. |
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cp .env.sample .env
|
|
118
|
+
# edit .env and fill in SPOTIFY_SP_DC and SPOTIFY_SP_KEY
|
|
119
|
+
source .env
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
### CLI
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
source .env && uv run spotifygraphqlconnector
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Runs every supported endpoint in sequence and logs the full JSON responses via loguru.
|
|
133
|
+
|
|
134
|
+
### Library
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from spotifygraphqlconnector import SpotifyGraphQLConnector
|
|
138
|
+
|
|
139
|
+
connector = SpotifyGraphQLConnector(
|
|
140
|
+
sp_dc="your_sp_dc_value",
|
|
141
|
+
sp_key="your_sp_key_value",
|
|
142
|
+
# Optional - resolved automatically when omitted:
|
|
143
|
+
# show_uri="spotify:show:1HaFboRBVORs2VCpNACYLn",
|
|
144
|
+
# station_id="6248789",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# --- User / account ---
|
|
148
|
+
user = connector.get_user_metadata()
|
|
149
|
+
shows = connector.get_user_shows() # basic: uri, name, stationId
|
|
150
|
+
shows_rich = connector.get_shows_for_user() # + authorName, category, description
|
|
151
|
+
user_shows = connector.get_user_and_shows()
|
|
152
|
+
reg = connector.get_user_registration(country_code="DE")
|
|
153
|
+
|
|
154
|
+
# --- Show metadata (show_uri auto-resolved) ---
|
|
155
|
+
show_type = connector.get_show_type()
|
|
156
|
+
overview = connector.get_show_overview_stats()
|
|
157
|
+
clips = connector.get_show_clips()
|
|
158
|
+
|
|
159
|
+
# --- Show analytics ---
|
|
160
|
+
plays = connector.get_show_spotify_stats(date_range_window="WINDOW_LAST_THIRTY_DAYS")
|
|
161
|
+
geo = connector.get_show_geo_stats() # country breakdown
|
|
162
|
+
geo_city = connector.get_show_geo_stats(result_geo="GEO_CITY")
|
|
163
|
+
demo = connector.get_show_demographics_stats() # age + gender
|
|
164
|
+
platform = connector.get_show_platform_stats() # app + device
|
|
165
|
+
impressions = connector.get_show_impressions_trend() # daily + total
|
|
166
|
+
sources = connector.get_show_impressions_sources() # by source
|
|
167
|
+
discovery = connector.get_show_audience_discovery() # funnel + audience size
|
|
168
|
+
top_ep = connector.get_show_top_episodes() # all-time plays per episode
|
|
169
|
+
|
|
170
|
+
# --- Episode list (station_id auto-resolved) ---
|
|
171
|
+
page = connector.get_episode_list(current_page=1, page_size=25)
|
|
172
|
+
all_eps = connector.get_all_episodes() # auto-paginates
|
|
173
|
+
|
|
174
|
+
# --- Episode analytics ---
|
|
175
|
+
ep_uri = "spotify:episode:4fndadZdKayBwmsRQJ8rNR"
|
|
176
|
+
meta = connector.get_episode_metadata_for_analytics(ep_uri)
|
|
177
|
+
perf = connector.get_episode_performance_all_time(ep_uri)
|
|
178
|
+
streams = connector.get_episode_streams_and_downloads(ep_uri)
|
|
179
|
+
plays_daily = connector.get_episode_plays_daily(ep_uri)
|
|
180
|
+
impressions = connector.get_episode_impressions_faceted(ep_uri)
|
|
181
|
+
consumption = connector.get_episode_consumption_all_time(ep_uri)
|
|
182
|
+
audience = connector.get_episode_audience_size_all_time(ep_uri)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
All methods return `dict[str, JsonValue]` in the **native Spotify API response shape**.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Supported Endpoints (24)
|
|
190
|
+
|
|
191
|
+
### User / Account
|
|
192
|
+
|
|
193
|
+
| Method | Operation | Description |
|
|
194
|
+
| --------------------------- | ------------------------- | -------------------------------------------------------------------------------- |
|
|
195
|
+
| `get_user_metadata()` | `getUserMetadata` | Authenticated user name and avatar |
|
|
196
|
+
| `get_user_shows()` | `WebGetUserShows` | All shows: uri, name, stationId, permissions |
|
|
197
|
+
| `get_shows_for_user()` | `getShowsForUser` | All shows with rich metadata: authorName, category, description, hostingProvider |
|
|
198
|
+
| `get_user_and_shows()` | `WebGetUserAndShows` | User profile + shows combined |
|
|
199
|
+
| `get_user_registration()` | `WebGetUserRegistration` | TOS / onboarding state |
|
|
200
|
+
| `get_external_partner_id()` | `WebGetExternalPartnerId` | External partner ID (e.g. for mParticle/Braze) |
|
|
201
|
+
|
|
202
|
+
### Show Metadata
|
|
203
|
+
|
|
204
|
+
| Method | Operation | Description |
|
|
205
|
+
| ------------------------------------ | ---------------------------------- | ------------------------------------------------ |
|
|
206
|
+
| `get_show_type()` | `WebGetShowTypeByUri` | Show type: PODCAST, AUDIOBOOK, ... |
|
|
207
|
+
| `get_show_overview_stats()` | `getShowOverviewStatsNRT` | Near-real-time aggregate streams/downloads total |
|
|
208
|
+
| `get_show_clips()` | `getShowClips` | Short-form video clips for a show |
|
|
209
|
+
| `get_monetization_lifecycle_state()` | `WebGetMonetizationLifecycleState` | Monetisation state (S4P shows only) |
|
|
210
|
+
|
|
211
|
+
### Show Analytics
|
|
212
|
+
|
|
213
|
+
All analytics methods accept `date_range_window`:
|
|
214
|
+
`"WINDOW_LAST_SEVEN_DAYS"` · `"WINDOW_LAST_THIRTY_DAYS"` · `"WINDOW_LAST_NINETY_DAYS"` · `"WINDOW_ALL_TIME"`
|
|
215
|
+
|
|
216
|
+
| Method | Operation | Replaces (anchor-connector) |
|
|
217
|
+
| -------------------------------- | ------------------------------------- | ------------------------------------------- |
|
|
218
|
+
| `get_show_spotify_stats()` | `getShowOnSpotifyStats` | `plays()`, `total_plays()` |
|
|
219
|
+
| `get_show_geo_stats()` | `getShowAudienceAllPlatformsGeoStats` | `plays_by_geo()`, `plays_by_geo_city()` |
|
|
220
|
+
| `get_show_demographics_stats()` | `getShowAudienceDemographicsStats` | `plays_by_age_range()`, `plays_by_gender()` |
|
|
221
|
+
| `get_show_platform_stats()` | `getShowAudienceAllPlatformsStats` | `plays_by_app()`, `plays_by_device()` |
|
|
222
|
+
| `get_show_impressions_trend()` | `getShowImpressionsTrendStats` | `impressions()` |
|
|
223
|
+
| `get_show_impressions_sources()` | `getShowImpressionsSourcesStats` | n/a |
|
|
224
|
+
| `get_show_audience_discovery()` | `getShowAudienceDiscoveryStats` | `audience_size()`, `unique_listeners()` |
|
|
225
|
+
| `get_show_top_episodes()` | `getShowTopEpisodes` | `total_plays_by_episode()` ¹ |
|
|
226
|
+
|
|
227
|
+
¹ All-time only - Spotify does not expose a time-range parameter for this endpoint.
|
|
228
|
+
Per-episode plays within a window: call `get_episode_plays_daily(episode_uri)` per episode.
|
|
229
|
+
|
|
230
|
+
### Episode List
|
|
231
|
+
|
|
232
|
+
| Method | Operation | Description |
|
|
233
|
+
| -------------------- | -------------------------- | --------------------------------------------------- |
|
|
234
|
+
| `get_episode_list()` | `WebGetIndexedEpisodeList` | Paginated, searchable episode list with sort/filter |
|
|
235
|
+
| `get_all_episodes()` | `WebGetIndexedEpisodeList` | All episodes auto-paginated |
|
|
236
|
+
|
|
237
|
+
### Episode Analytics
|
|
238
|
+
|
|
239
|
+
| Method | Operation | Replaces (anchor-connector) |
|
|
240
|
+
| -------------------------------------- | -------------------------------- | ----------------------------------------------------------- |
|
|
241
|
+
| `get_episode_metadata_for_analytics()` | `getEpisodeMetadataForAnalytics` | `episode_metadata()` |
|
|
242
|
+
| `get_episode_performance_all_time()` | `getEpisodePerformanceAllTime` | `episode_performance()`, `episode_aggregated_performance()` |
|
|
243
|
+
| `get_episode_streams_and_downloads()` | `getEpisodeStreamsAndDownloads` | `episode_plays()` |
|
|
244
|
+
| `get_episode_plays_daily()` | `getEpisodePlaysDaily` | `episode_plays()` |
|
|
245
|
+
| `get_episode_impressions_faceted()` | `getEpisodeImpressionsFaceted` | n/a |
|
|
246
|
+
| `get_episode_consumption_all_time()` | `getEpisodeConsumptionAllTime` | n/a |
|
|
247
|
+
| `get_episode_audience_size_all_time()` | `getEpisodeAudienceSizeAllTime` | `audience_size()` (episode level) |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Error Handling
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from spotifygraphqlconnector import (
|
|
255
|
+
SpotifyGraphQLConnector,
|
|
256
|
+
CredentialsExpired,
|
|
257
|
+
AuthenticationError,
|
|
258
|
+
MaxRetriesException,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
data = connector.get_show_spotify_stats()
|
|
263
|
+
except CredentialsExpired:
|
|
264
|
+
# sp_dc / sp_key have expired - get fresh values from your browser
|
|
265
|
+
print("Please update SPOTIFY_SP_DC and SPOTIFY_SP_KEY.")
|
|
266
|
+
except AuthenticationError as e:
|
|
267
|
+
# Unexpected auth page structure - Spotify may have changed their flow
|
|
268
|
+
print(f"Auth error: {e}")
|
|
269
|
+
except MaxRetriesException as e:
|
|
270
|
+
# All retry attempts exhausted (network issues, persistent 5xx)
|
|
271
|
+
print(f"Request failed after all retries: {e}")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Development
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
make install # uv sync --all-groups
|
|
280
|
+
make check # lint + typecheck + test (all at once)
|
|
281
|
+
make lint # ruff check
|
|
282
|
+
make typecheck # pyright
|
|
283
|
+
make test # pytest
|
|
284
|
+
make fmt # ruff format + ruff check --fix
|
|
285
|
+
make run # run the CLI (requires .env sourced)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Adding a new operation
|
|
289
|
+
|
|
290
|
+
1. Find the `operationName` and `sha256Hash` in your browser DevTools:
|
|
291
|
+
DevTools → Network → filter by `graph-pq` → click a request → Payload tab.
|
|
292
|
+
2. Add the entry to `OPERATION_HASHES` in `types.py`.
|
|
293
|
+
3. Add a method in `connector.py` calling `self._query("YourOperationName", ...)`.
|
|
294
|
+
4. Add a call in `__main__.py` and a test in `tests/test_connector.py`.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Relationship to other connectors
|
|
299
|
+
|
|
300
|
+
This connector is part of the [OpenPodcast](https://github.com/openpodcast) project:
|
|
301
|
+
|
|
302
|
+
| Connector | API | Auth | Status |
|
|
303
|
+
| --------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------------------------------- |
|
|
304
|
+
| [spotify-connector](https://github.com/openpodcast/spotify-connector) | Spotify REST (`generic.wg.spotify.com`) | PKCE OAuth (`sp_dc` + `sp_key`) | Active |
|
|
305
|
+
| [anchor-connector](https://github.com/openpodcast/anchor-connector) | Anchor REST (`podcasters.spotify.com`) | Session cookie (`anchorpw_s`) | ⚠️ Deprecated - Spotify migrated to GraphQL |
|
|
306
|
+
| **spotify-connector-graphql** (this) | Spotify Creators GraphQL (`creators-graph.spotify.com`) | PKCE OAuth (`sp_dc` + `sp_key`) | ✅ Active - Anchor replacement |
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## License
|
|
311
|
+
|
|
312
|
+
MIT
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Spotify GraphQL Connector
|
|
2
|
+
|
|
3
|
+
An unofficial Python connector for the [Spotify Creators](https://creators.spotify.com)
|
|
4
|
+
GraphQL API - the backend that powers the Spotify Creators dashboard (formerly Anchor).
|
|
5
|
+
|
|
6
|
+
> **Disclaimer:** This connector uses an undocumented, internal API that may change at
|
|
7
|
+
> any time without notice. Use at your own risk.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Does it work for Anchor? For Spotify?
|
|
12
|
+
|
|
13
|
+
**Anchor (primary use case): Supported **
|
|
14
|
+
|
|
15
|
+
Anchor was rebranded to Spotify for Podcasters and then Spotify Creators. The dashboard
|
|
16
|
+
at `creators.spotify.com` is now the only interface for managing Anchor-hosted podcasts.
|
|
17
|
+
This connector talks directly to the GraphQL API behind that dashboard - it is the
|
|
18
|
+
**direct replacement for the [`anchor-connector`](https://github.com/openpodcast/anchor-connector)**, which stopped working when Spotify migrated its backend to GraphQL.
|
|
19
|
+
|
|
20
|
+
Shows hosted on Anchor/Spotify for Podcasters appear as `hostingProvider: "S4P"` and
|
|
21
|
+
have full analytics access across all endpoints.
|
|
22
|
+
|
|
23
|
+
**Non-Anchor Spotify shows: Partial Support**
|
|
24
|
+
|
|
25
|
+
Shows hosted elsewhere (Apple, Megaphone, RSS feeds, etc.) appear as `NonHostedShow`
|
|
26
|
+
with `hostingProvider: "OTHER_THIRD_PARTY"` or `"MEGAPHONE"`. These shows are visible
|
|
27
|
+
in the dashboard and return data from some endpoints (metadata, impressions, geo stats),
|
|
28
|
+
but deep analytics like performance curves and consumption data are only available for
|
|
29
|
+
S4P-hosted shows.
|
|
30
|
+
|
|
31
|
+
**In short:** if you were using `anchor-connector`, replace it with this connector.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Single credential** - only `sp_dc` and `sp_key` session cookies are required.
|
|
38
|
+
Show URI and station ID are resolved automatically from your account.
|
|
39
|
+
- **Auto-authentication** - performs the full PKCE OAuth 2.0 flow used by the Spotify
|
|
40
|
+
Creators web app and transparently refreshes the bearer token before expiry.
|
|
41
|
+
- **Auto-pagination** - `get_all_episodes()` fetches every page automatically.
|
|
42
|
+
- **Retry logic** - exponential back-off on transient errors (429, 502, 503, 504)
|
|
43
|
+
and automatic token refresh on 401.
|
|
44
|
+
- **Type-safe** - fully annotated with a recursive `JsonDict` type alias; zero `Any`.
|
|
45
|
+
- **Native response shape** - responses are returned exactly as the API sends them
|
|
46
|
+
so consumers can adapt to the real data without a lossy mapping layer.
|
|
47
|
+
- **`uv`-powered** - dependency management and packaging via
|
|
48
|
+
[uv](https://docs.astral.sh/uv/).
|
|
49
|
+
- **24 operations** covering the full Spotify Creators analytics surface.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python 3.11+
|
|
56
|
+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# As a library in your own project
|
|
64
|
+
uv add spotifygraphqlconnector
|
|
65
|
+
|
|
66
|
+
# Clone for development
|
|
67
|
+
git clone https://github.com/openpodcast/spotify-connector-graphql
|
|
68
|
+
cd spotify-connector-graphql/spotifygraphqlconnector
|
|
69
|
+
uv sync --all-groups
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Credentials
|
|
75
|
+
|
|
76
|
+
You need two cookies from an active Spotify session: `sp_dc` and `sp_key`.
|
|
77
|
+
|
|
78
|
+
### How to obtain them
|
|
79
|
+
|
|
80
|
+
1. Open [https://creators.spotify.com](https://creators.spotify.com) in your browser
|
|
81
|
+
and log in.
|
|
82
|
+
2. Open **DevTools** (`F12` / `⌘ ⌥ I`) → **Application** → **Cookies** →
|
|
83
|
+
`https://accounts.spotify.com`.
|
|
84
|
+
3. Copy the values of the `sp_dc` and `sp_key` cookies.
|
|
85
|
+
|
|
86
|
+
These cookies are typically valid for several months. When they expire you will see a
|
|
87
|
+
`CredentialsExpired` exception - just grab fresh cookie values from your browser.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
All configuration is via environment variables:
|
|
94
|
+
|
|
95
|
+
| Variable | Required | Default | Description |
|
|
96
|
+
| ---------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
+
| `SPOTIFY_SP_DC` | **Yes** | - | `sp_dc` cookie from a Spotify session |
|
|
98
|
+
| `SPOTIFY_SP_KEY` | **Yes** | - | `sp_key` cookie from a Spotify session |
|
|
99
|
+
| `SPOTIFY_SHOW_URI` | No | auto | Spotify show URI, e.g. `spotify:show:1HaFboRBVORs2VCpNACYLn`. Auto-resolved from the first S4P-hosted show on your account. |
|
|
100
|
+
| `SPOTIFY_STATION_ID` | No | auto | Numeric Anchor/Spotify station ID. Auto-resolved from show metadata. |
|
|
101
|
+
| `SPOTIFY_EPISODE_URI` | No | auto | Episode URI for per-episode analytics in the CLI. Auto-resolved from the first episode. |
|
|
102
|
+
| `SPOTIFY_COUNTRY_CODE` | No | `US` | ISO 3166-1 alpha-2 country code for the registration endpoint. |
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cp .env.sample .env
|
|
106
|
+
# edit .env and fill in SPOTIFY_SP_DC and SPOTIFY_SP_KEY
|
|
107
|
+
source .env
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Usage
|
|
113
|
+
|
|
114
|
+
### CLI
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
source .env && uv run spotifygraphqlconnector
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Runs every supported endpoint in sequence and logs the full JSON responses via loguru.
|
|
121
|
+
|
|
122
|
+
### Library
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from spotifygraphqlconnector import SpotifyGraphQLConnector
|
|
126
|
+
|
|
127
|
+
connector = SpotifyGraphQLConnector(
|
|
128
|
+
sp_dc="your_sp_dc_value",
|
|
129
|
+
sp_key="your_sp_key_value",
|
|
130
|
+
# Optional - resolved automatically when omitted:
|
|
131
|
+
# show_uri="spotify:show:1HaFboRBVORs2VCpNACYLn",
|
|
132
|
+
# station_id="6248789",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# --- User / account ---
|
|
136
|
+
user = connector.get_user_metadata()
|
|
137
|
+
shows = connector.get_user_shows() # basic: uri, name, stationId
|
|
138
|
+
shows_rich = connector.get_shows_for_user() # + authorName, category, description
|
|
139
|
+
user_shows = connector.get_user_and_shows()
|
|
140
|
+
reg = connector.get_user_registration(country_code="DE")
|
|
141
|
+
|
|
142
|
+
# --- Show metadata (show_uri auto-resolved) ---
|
|
143
|
+
show_type = connector.get_show_type()
|
|
144
|
+
overview = connector.get_show_overview_stats()
|
|
145
|
+
clips = connector.get_show_clips()
|
|
146
|
+
|
|
147
|
+
# --- Show analytics ---
|
|
148
|
+
plays = connector.get_show_spotify_stats(date_range_window="WINDOW_LAST_THIRTY_DAYS")
|
|
149
|
+
geo = connector.get_show_geo_stats() # country breakdown
|
|
150
|
+
geo_city = connector.get_show_geo_stats(result_geo="GEO_CITY")
|
|
151
|
+
demo = connector.get_show_demographics_stats() # age + gender
|
|
152
|
+
platform = connector.get_show_platform_stats() # app + device
|
|
153
|
+
impressions = connector.get_show_impressions_trend() # daily + total
|
|
154
|
+
sources = connector.get_show_impressions_sources() # by source
|
|
155
|
+
discovery = connector.get_show_audience_discovery() # funnel + audience size
|
|
156
|
+
top_ep = connector.get_show_top_episodes() # all-time plays per episode
|
|
157
|
+
|
|
158
|
+
# --- Episode list (station_id auto-resolved) ---
|
|
159
|
+
page = connector.get_episode_list(current_page=1, page_size=25)
|
|
160
|
+
all_eps = connector.get_all_episodes() # auto-paginates
|
|
161
|
+
|
|
162
|
+
# --- Episode analytics ---
|
|
163
|
+
ep_uri = "spotify:episode:4fndadZdKayBwmsRQJ8rNR"
|
|
164
|
+
meta = connector.get_episode_metadata_for_analytics(ep_uri)
|
|
165
|
+
perf = connector.get_episode_performance_all_time(ep_uri)
|
|
166
|
+
streams = connector.get_episode_streams_and_downloads(ep_uri)
|
|
167
|
+
plays_daily = connector.get_episode_plays_daily(ep_uri)
|
|
168
|
+
impressions = connector.get_episode_impressions_faceted(ep_uri)
|
|
169
|
+
consumption = connector.get_episode_consumption_all_time(ep_uri)
|
|
170
|
+
audience = connector.get_episode_audience_size_all_time(ep_uri)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
All methods return `dict[str, JsonValue]` in the **native Spotify API response shape**.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Supported Endpoints (24)
|
|
178
|
+
|
|
179
|
+
### User / Account
|
|
180
|
+
|
|
181
|
+
| Method | Operation | Description |
|
|
182
|
+
| --------------------------- | ------------------------- | -------------------------------------------------------------------------------- |
|
|
183
|
+
| `get_user_metadata()` | `getUserMetadata` | Authenticated user name and avatar |
|
|
184
|
+
| `get_user_shows()` | `WebGetUserShows` | All shows: uri, name, stationId, permissions |
|
|
185
|
+
| `get_shows_for_user()` | `getShowsForUser` | All shows with rich metadata: authorName, category, description, hostingProvider |
|
|
186
|
+
| `get_user_and_shows()` | `WebGetUserAndShows` | User profile + shows combined |
|
|
187
|
+
| `get_user_registration()` | `WebGetUserRegistration` | TOS / onboarding state |
|
|
188
|
+
| `get_external_partner_id()` | `WebGetExternalPartnerId` | External partner ID (e.g. for mParticle/Braze) |
|
|
189
|
+
|
|
190
|
+
### Show Metadata
|
|
191
|
+
|
|
192
|
+
| Method | Operation | Description |
|
|
193
|
+
| ------------------------------------ | ---------------------------------- | ------------------------------------------------ |
|
|
194
|
+
| `get_show_type()` | `WebGetShowTypeByUri` | Show type: PODCAST, AUDIOBOOK, ... |
|
|
195
|
+
| `get_show_overview_stats()` | `getShowOverviewStatsNRT` | Near-real-time aggregate streams/downloads total |
|
|
196
|
+
| `get_show_clips()` | `getShowClips` | Short-form video clips for a show |
|
|
197
|
+
| `get_monetization_lifecycle_state()` | `WebGetMonetizationLifecycleState` | Monetisation state (S4P shows only) |
|
|
198
|
+
|
|
199
|
+
### Show Analytics
|
|
200
|
+
|
|
201
|
+
All analytics methods accept `date_range_window`:
|
|
202
|
+
`"WINDOW_LAST_SEVEN_DAYS"` · `"WINDOW_LAST_THIRTY_DAYS"` · `"WINDOW_LAST_NINETY_DAYS"` · `"WINDOW_ALL_TIME"`
|
|
203
|
+
|
|
204
|
+
| Method | Operation | Replaces (anchor-connector) |
|
|
205
|
+
| -------------------------------- | ------------------------------------- | ------------------------------------------- |
|
|
206
|
+
| `get_show_spotify_stats()` | `getShowOnSpotifyStats` | `plays()`, `total_plays()` |
|
|
207
|
+
| `get_show_geo_stats()` | `getShowAudienceAllPlatformsGeoStats` | `plays_by_geo()`, `plays_by_geo_city()` |
|
|
208
|
+
| `get_show_demographics_stats()` | `getShowAudienceDemographicsStats` | `plays_by_age_range()`, `plays_by_gender()` |
|
|
209
|
+
| `get_show_platform_stats()` | `getShowAudienceAllPlatformsStats` | `plays_by_app()`, `plays_by_device()` |
|
|
210
|
+
| `get_show_impressions_trend()` | `getShowImpressionsTrendStats` | `impressions()` |
|
|
211
|
+
| `get_show_impressions_sources()` | `getShowImpressionsSourcesStats` | n/a |
|
|
212
|
+
| `get_show_audience_discovery()` | `getShowAudienceDiscoveryStats` | `audience_size()`, `unique_listeners()` |
|
|
213
|
+
| `get_show_top_episodes()` | `getShowTopEpisodes` | `total_plays_by_episode()` ¹ |
|
|
214
|
+
|
|
215
|
+
¹ All-time only - Spotify does not expose a time-range parameter for this endpoint.
|
|
216
|
+
Per-episode plays within a window: call `get_episode_plays_daily(episode_uri)` per episode.
|
|
217
|
+
|
|
218
|
+
### Episode List
|
|
219
|
+
|
|
220
|
+
| Method | Operation | Description |
|
|
221
|
+
| -------------------- | -------------------------- | --------------------------------------------------- |
|
|
222
|
+
| `get_episode_list()` | `WebGetIndexedEpisodeList` | Paginated, searchable episode list with sort/filter |
|
|
223
|
+
| `get_all_episodes()` | `WebGetIndexedEpisodeList` | All episodes auto-paginated |
|
|
224
|
+
|
|
225
|
+
### Episode Analytics
|
|
226
|
+
|
|
227
|
+
| Method | Operation | Replaces (anchor-connector) |
|
|
228
|
+
| -------------------------------------- | -------------------------------- | ----------------------------------------------------------- |
|
|
229
|
+
| `get_episode_metadata_for_analytics()` | `getEpisodeMetadataForAnalytics` | `episode_metadata()` |
|
|
230
|
+
| `get_episode_performance_all_time()` | `getEpisodePerformanceAllTime` | `episode_performance()`, `episode_aggregated_performance()` |
|
|
231
|
+
| `get_episode_streams_and_downloads()` | `getEpisodeStreamsAndDownloads` | `episode_plays()` |
|
|
232
|
+
| `get_episode_plays_daily()` | `getEpisodePlaysDaily` | `episode_plays()` |
|
|
233
|
+
| `get_episode_impressions_faceted()` | `getEpisodeImpressionsFaceted` | n/a |
|
|
234
|
+
| `get_episode_consumption_all_time()` | `getEpisodeConsumptionAllTime` | n/a |
|
|
235
|
+
| `get_episode_audience_size_all_time()` | `getEpisodeAudienceSizeAllTime` | `audience_size()` (episode level) |
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Error Handling
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from spotifygraphqlconnector import (
|
|
243
|
+
SpotifyGraphQLConnector,
|
|
244
|
+
CredentialsExpired,
|
|
245
|
+
AuthenticationError,
|
|
246
|
+
MaxRetriesException,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
data = connector.get_show_spotify_stats()
|
|
251
|
+
except CredentialsExpired:
|
|
252
|
+
# sp_dc / sp_key have expired - get fresh values from your browser
|
|
253
|
+
print("Please update SPOTIFY_SP_DC and SPOTIFY_SP_KEY.")
|
|
254
|
+
except AuthenticationError as e:
|
|
255
|
+
# Unexpected auth page structure - Spotify may have changed their flow
|
|
256
|
+
print(f"Auth error: {e}")
|
|
257
|
+
except MaxRetriesException as e:
|
|
258
|
+
# All retry attempts exhausted (network issues, persistent 5xx)
|
|
259
|
+
print(f"Request failed after all retries: {e}")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
make install # uv sync --all-groups
|
|
268
|
+
make check # lint + typecheck + test (all at once)
|
|
269
|
+
make lint # ruff check
|
|
270
|
+
make typecheck # pyright
|
|
271
|
+
make test # pytest
|
|
272
|
+
make fmt # ruff format + ruff check --fix
|
|
273
|
+
make run # run the CLI (requires .env sourced)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Adding a new operation
|
|
277
|
+
|
|
278
|
+
1. Find the `operationName` and `sha256Hash` in your browser DevTools:
|
|
279
|
+
DevTools → Network → filter by `graph-pq` → click a request → Payload tab.
|
|
280
|
+
2. Add the entry to `OPERATION_HASHES` in `types.py`.
|
|
281
|
+
3. Add a method in `connector.py` calling `self._query("YourOperationName", ...)`.
|
|
282
|
+
4. Add a call in `__main__.py` and a test in `tests/test_connector.py`.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Relationship to other connectors
|
|
287
|
+
|
|
288
|
+
This connector is part of the [OpenPodcast](https://github.com/openpodcast) project:
|
|
289
|
+
|
|
290
|
+
| Connector | API | Auth | Status |
|
|
291
|
+
| --------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------------------------------- |
|
|
292
|
+
| [spotify-connector](https://github.com/openpodcast/spotify-connector) | Spotify REST (`generic.wg.spotify.com`) | PKCE OAuth (`sp_dc` + `sp_key`) | Active |
|
|
293
|
+
| [anchor-connector](https://github.com/openpodcast/anchor-connector) | Anchor REST (`podcasters.spotify.com`) | Session cookie (`anchorpw_s`) | ⚠️ Deprecated - Spotify migrated to GraphQL |
|
|
294
|
+
| **spotify-connector-graphql** (this) | Spotify Creators GraphQL (`creators-graph.spotify.com`) | PKCE OAuth (`sp_dc` + `sp_key`) | ✅ Active - Anchor replacement |
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spotifygraphqlconnector"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Spotify GraphQL Connector for Podcast Data"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Open Podcast" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"loguru>=0.7.3",
|
|
12
|
+
"pyyaml>=6.0.3",
|
|
13
|
+
"requests>=2.32.5",
|
|
14
|
+
"tenacity>=9.1.4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
spotifygraphqlconnector = "spotifygraphqlconnector.__main__:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.8.24,<0.9.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pyright>=1.1.408",
|
|
27
|
+
"pytest>=9.0.2",
|
|
28
|
+
"ruff>=0.15.6",
|
|
29
|
+
"types-pyyaml>=6.0.12.20250915",
|
|
30
|
+
"types-requests>=2.32.4.20260107",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
target-version = "py311"
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = [
|
|
39
|
+
"E", # pycodestyle errors
|
|
40
|
+
"W", # pycodestyle warnings
|
|
41
|
+
"F", # pyflakes
|
|
42
|
+
"I", # isort
|
|
43
|
+
"UP", # pyupgrade
|
|
44
|
+
"B", # flake8-bugbear
|
|
45
|
+
"C4", # flake8-comprehensions
|
|
46
|
+
"SIM", # flake8-simplify
|
|
47
|
+
]
|
|
48
|
+
ignore = [
|
|
49
|
+
"E501", # line too long - handled by formatter
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.format]
|
|
53
|
+
quote-style = "double"
|
|
54
|
+
indent-style = "space"
|
|
55
|
+
|
|
56
|
+
[tool.pyright]
|
|
57
|
+
include = ["src"]
|
|
58
|
+
pythonVersion = "3.11"
|
|
59
|
+
typeCheckingMode = "standard"
|
|
60
|
+
reportMissingImports = true
|
|
61
|
+
reportMissingTypeStubs = false
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|