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.
@@ -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"]