deezer-python-gql 0.5.1__tar.gz → 0.7.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,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: deezer-python-gql
3
+ Version: 0.7.0
4
+ Summary: Async typed Python client for Deezer's Pipe GraphQL API.
5
+ Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Platform: any
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: httpx>=0.27.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Provides-Extra: test
17
+ Requires-Dist: codespell==2.4.2; extra == "test"
18
+ Requires-Dist: mypy==1.19.1; extra == "test"
19
+ Requires-Dist: pre-commit==4.5.1; extra == "test"
20
+ Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
+ Requires-Dist: pytest==9.0.2; extra == "test"
22
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
23
+ Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
+ Requires-Dist: ruff==0.15.7; extra == "test"
25
+ Provides-Extra: dev
26
+ Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # deezer-python-gql
30
+
31
+ Async typed Python client for Deezer's Pipe GraphQL API.
32
+
33
+ Built with [ariadne-codegen](https://github.com/mirumee/ariadne-codegen) — all
34
+ client methods and response models are generated from the GraphQL schema and
35
+ `.graphql` query files.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ uv add deezer-python-gql
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ import asyncio
47
+
48
+ from deezer_python_gql import DeezerGQLClient
49
+
50
+ async def main():
51
+ client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
52
+
53
+ # Current user
54
+ me = await client.get_me()
55
+ print(me)
56
+
57
+ # Track with media URLs, lyrics, and contributors
58
+ track = await client.get_track(track_id="3135556")
59
+ print(track.title, track.duration)
60
+
61
+ # Album with paginated track list
62
+ album = await client.get_album(album_id="302127")
63
+ print(album.display_title, album.tracks_count)
64
+
65
+ # Artist with top tracks and discography
66
+ artist = await client.get_artist(artist_id="27")
67
+ print(artist.name, artist.fans_count)
68
+
69
+ # Playlist with tracks
70
+ playlist = await client.get_playlist(playlist_id="53362031")
71
+ print(playlist.title, playlist.estimated_tracks_count)
72
+
73
+ # Unified search across all entity types
74
+ results = await client.search(query="Daft Punk")
75
+ print(len(results.tracks.edges), "tracks found")
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## Available Queries
81
+
82
+ ### Content Retrieval
83
+
84
+ | Method | Description |
85
+ | --------------------------------------------- | --------------------------------------------------------------- |
86
+ | `get_me()` | Current authenticated user |
87
+ | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
88
+ | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
89
+ | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
90
+ | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
91
+ | `get_livestream(livestream_id)` | Livestream (radio station) with streaming URLs and codec info |
92
+ | `get_podcast(podcast_id)` | Podcast with paginated episodes and rights info |
93
+ | `get_podcast_episode(podcast_episode_id)` | Single episode with media URL, codec, and parent podcast ref |
94
+ | `get_audiobook(audiobook_id)` | Audiobook with paginated chapters, contributors, and fallback |
95
+ | `get_audiobook_chapter(audiobook_chapter_id)` | Chapter with media token, estimated sizes, and streaming rights |
96
+
97
+ ### Search & Discovery
98
+
99
+ | Method | Description |
100
+ | ------------------------------------------------ | ------------------------------------------------------------------------------- |
101
+ | `search(query, ...)` | Unified search across tracks, albums, artists, playlists, podcasts, livestreams |
102
+ | `search_flows(query)` | Discover all available Deezer flows via search |
103
+ | `get_similar_tracks(track_id, nb)` | Recommended tracks based on a given track |
104
+ | `get_artist_mix(artist_ids, limit)` | Track mix blended from given artists |
105
+ | `get_track_mix(track_ids, limit)` | Track mix blended around given tracks |
106
+ | `get_flow()` | User's default Flow with tracks |
107
+ | `get_flow_batch()` | 4 batches of Flow tracks in one request (via GraphQL aliases) |
108
+ | `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
109
+ | `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
110
+ | `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
111
+ | `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
112
+ | `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
113
+ | `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
114
+ | `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
115
+ | `get_user_charts()` | Personal top tracks, artists, and albums |
116
+
117
+ ### Library & Favorites
118
+
119
+ | Method | Description |
120
+ | --------------------------------------------- | ----------------------------------------------------------- |
121
+ | `get_favorite_artists(first, after)` | Paginated favorite artists |
122
+ | `get_favorite_albums(first, after)` | Paginated favorite albums |
123
+ | `get_favorite_tracks(first, after)` | Paginated favorite tracks |
124
+ | `get_favorite_playlists(first, after)` | Paginated favorite playlists |
125
+ | `get_favorite_podcasts(first, after)` | Paginated favorite podcasts |
126
+ | `get_favorite_audiobooks()` | Favorite audiobook IDs with dates (via deprecated endpoint) |
127
+ | `get_podcast_episode_bookmarks(first, after)` | Bookmarked podcast episodes with playback position |
128
+ | `get_user_playlists(first, after)` | User's own playlists (not just favorites) |
129
+
130
+ ### Music Together (Collaborative Playlists)
131
+
132
+ | Method | Description |
133
+ | ------------------------------------------ | --------------------------------------------------------- |
134
+ | `get_music_together_groups(first)` | User's Music Together groups |
135
+ | `get_music_together_group(group_id, mood)` | Single group with members, suggested & curated tracklists |
136
+ | `get_music_together_affinity(group_id)` | Group member affinity scores and discovery content |
137
+
138
+ ## Available Mutations
139
+
140
+ ### Favorites Management
141
+
142
+ | Method | Description |
143
+ | ---------------------------------------------- | ----------------------------------------------------------- |
144
+ | `add_artist_to_favorite(artist_id)` | Add artist to favorites |
145
+ | `remove_artist_from_favorite(artist_id)` | Remove artist from favorites |
146
+ | `add_album_to_favorite(album_id)` | Add album to favorites |
147
+ | `remove_album_from_favorite(album_id)` | Remove album from favorites |
148
+ | `add_track_to_favorite(track_id)` | Add track to favorites |
149
+ | `remove_track_from_favorite(track_id)` | Remove track from favorites |
150
+ | `add_playlist_to_favorite(playlist_id)` | Add playlist to favorites |
151
+ | `remove_playlist_from_favorite(playlist_id)` | Remove playlist from favorites |
152
+ | `add_podcast_to_favorite(podcast_id)` | Add podcast to favorites |
153
+ | `remove_podcast_from_favorite(podcast_id)` | Remove podcast from favorites |
154
+ | `add_audiobook_to_favorite(audiobook_id)` | Add audiobook to favorites (deprecated but functional) |
155
+ | `remove_audiobook_from_favorite(audiobook_id)` | Remove audiobook from favorites (deprecated but functional) |
156
+
157
+ ### Playlist Management
158
+
159
+ | Method | Description |
160
+ | ------------------------------------------------ | ------------------------------------------------- |
161
+ | `create_playlist(title, ...)` | Create a new playlist |
162
+ | `update_playlist(playlist_id, ...)` | Update playlist title, description, or visibility |
163
+ | `delete_playlist(playlist_id)` | Delete a playlist |
164
+ | `add_tracks_to_playlist(playlist_id, track_ids)` | Add tracks to a playlist |
165
+ | `remove_tracks_from_playlist(playlist_id, ...)` | Remove tracks from a playlist |
166
+
167
+ ### Podcast Episode Management
168
+
169
+ | Method | Description |
170
+ | ------------------------------------------------ | ------------------------------------------------- |
171
+ | `bookmark_podcast_episode(episode_id, offset)` | Bookmark episode with playback position (seconds) |
172
+ | `unbookmark_podcast_episode(episode_id)` | Remove bookmark from episode |
173
+ | `mark_as_played_podcast_episode(episode_id)` | Mark episode as played |
174
+ | `mark_as_not_played_podcast_episode(episode_id)` | Mark episode as not played |
175
+
176
+ ### Music Together
177
+
178
+ | Method | Description |
179
+ | ------------------------------------------------- | ------------------------------------------- |
180
+ | `music_together_create_group(name, ...)` | Create a new Music Together group |
181
+ | `music_together_join_group(group_id)` | Join an existing group |
182
+ | `music_together_leave_group(group_id)` | Leave a group |
183
+ | `music_together_refresh_suggested_tracklist(...)` | Refresh the suggested tracklist for a group |
184
+ | `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
185
+ | `music_together_generate_group_name()` | Generate a random group name |
186
+
187
+ ### Utilities
188
+
189
+ | Method | Description |
190
+ | -------------------------------- | ---------------------------------------------------------------- |
191
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
192
+
193
+ All methods return fully-typed Pydantic models generated from the GraphQL schema.
194
+
195
+ ## Development
196
+
197
+ Requires **Python 3.12+** and [uv](https://docs.astral.sh/uv/).
198
+
199
+ ```bash
200
+ # Install all dependencies (including codegen tooling)
201
+ make setup
202
+
203
+ # Re-generate the typed client from schema + queries
204
+ make generate
205
+
206
+ # Run linters and type checks
207
+ make lint
208
+
209
+ # Run tests
210
+ make test
211
+ ```
212
+
213
+ ### Adding a new query
214
+
215
+ 1. Create a `.graphql` file in `queries/`.
216
+ 2. Run `make generate` to produce the typed client method and response models.
217
+ 3. Add tests in `tests/`.
218
+
219
+ ## Exploring the API
220
+
221
+ To run ad-hoc GraphQL queries against the live Pipe API during development:
222
+
223
+ 1. Create a `.env` file (already gitignored) with your ARL cookie:
224
+
225
+ ```bash
226
+ echo 'DEEZER_ARL=your_arl_cookie_value' > .env
227
+ ```
228
+
229
+ 2. Run queries:
230
+
231
+ ```bash
232
+ # Run a .graphql file
233
+ uv run python scripts/explore.py queries/get_me.graphql
234
+
235
+ # Run an inline query
236
+ uv run python scripts/explore.py -q '{ me { id } }'
237
+
238
+ # With variables
239
+ uv run python scripts/explore.py -q 'query($id: String!) { track(trackId: $id) { title } }' \
240
+ -v '{"id": "3135556"}'
241
+
242
+ # Via make
243
+ make explore Q=queries/get_me.graphql
244
+ ```
245
+
246
+ The script handles JWT auth automatically — no manual token management needed.
247
+
248
+ ## Authentication
249
+
250
+ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
251
+ handles token acquisition and refresh automatically — you only need to supply a
252
+ valid ARL value.
253
+
254
+ ## License
255
+
256
+ Apache-2.0
@@ -0,0 +1,228 @@
1
+ # deezer-python-gql
2
+
3
+ Async typed Python client for Deezer's Pipe GraphQL API.
4
+
5
+ Built with [ariadne-codegen](https://github.com/mirumee/ariadne-codegen) — all
6
+ client methods and response models are generated from the GraphQL schema and
7
+ `.graphql` query files.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add deezer-python-gql
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```python
18
+ import asyncio
19
+
20
+ from deezer_python_gql import DeezerGQLClient
21
+
22
+ async def main():
23
+ client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
24
+
25
+ # Current user
26
+ me = await client.get_me()
27
+ print(me)
28
+
29
+ # Track with media URLs, lyrics, and contributors
30
+ track = await client.get_track(track_id="3135556")
31
+ print(track.title, track.duration)
32
+
33
+ # Album with paginated track list
34
+ album = await client.get_album(album_id="302127")
35
+ print(album.display_title, album.tracks_count)
36
+
37
+ # Artist with top tracks and discography
38
+ artist = await client.get_artist(artist_id="27")
39
+ print(artist.name, artist.fans_count)
40
+
41
+ # Playlist with tracks
42
+ playlist = await client.get_playlist(playlist_id="53362031")
43
+ print(playlist.title, playlist.estimated_tracks_count)
44
+
45
+ # Unified search across all entity types
46
+ results = await client.search(query="Daft Punk")
47
+ print(len(results.tracks.edges), "tracks found")
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ ## Available Queries
53
+
54
+ ### Content Retrieval
55
+
56
+ | Method | Description |
57
+ | --------------------------------------------- | --------------------------------------------------------------- |
58
+ | `get_me()` | Current authenticated user |
59
+ | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
60
+ | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
61
+ | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
62
+ | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
63
+ | `get_livestream(livestream_id)` | Livestream (radio station) with streaming URLs and codec info |
64
+ | `get_podcast(podcast_id)` | Podcast with paginated episodes and rights info |
65
+ | `get_podcast_episode(podcast_episode_id)` | Single episode with media URL, codec, and parent podcast ref |
66
+ | `get_audiobook(audiobook_id)` | Audiobook with paginated chapters, contributors, and fallback |
67
+ | `get_audiobook_chapter(audiobook_chapter_id)` | Chapter with media token, estimated sizes, and streaming rights |
68
+
69
+ ### Search & Discovery
70
+
71
+ | Method | Description |
72
+ | ------------------------------------------------ | ------------------------------------------------------------------------------- |
73
+ | `search(query, ...)` | Unified search across tracks, albums, artists, playlists, podcasts, livestreams |
74
+ | `search_flows(query)` | Discover all available Deezer flows via search |
75
+ | `get_similar_tracks(track_id, nb)` | Recommended tracks based on a given track |
76
+ | `get_artist_mix(artist_ids, limit)` | Track mix blended from given artists |
77
+ | `get_track_mix(track_ids, limit)` | Track mix blended around given tracks |
78
+ | `get_flow()` | User's default Flow with tracks |
79
+ | `get_flow_batch()` | 4 batches of Flow tracks in one request (via GraphQL aliases) |
80
+ | `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
81
+ | `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
82
+ | `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
83
+ | `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
84
+ | `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
85
+ | `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
86
+ | `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
87
+ | `get_user_charts()` | Personal top tracks, artists, and albums |
88
+
89
+ ### Library & Favorites
90
+
91
+ | Method | Description |
92
+ | --------------------------------------------- | ----------------------------------------------------------- |
93
+ | `get_favorite_artists(first, after)` | Paginated favorite artists |
94
+ | `get_favorite_albums(first, after)` | Paginated favorite albums |
95
+ | `get_favorite_tracks(first, after)` | Paginated favorite tracks |
96
+ | `get_favorite_playlists(first, after)` | Paginated favorite playlists |
97
+ | `get_favorite_podcasts(first, after)` | Paginated favorite podcasts |
98
+ | `get_favorite_audiobooks()` | Favorite audiobook IDs with dates (via deprecated endpoint) |
99
+ | `get_podcast_episode_bookmarks(first, after)` | Bookmarked podcast episodes with playback position |
100
+ | `get_user_playlists(first, after)` | User's own playlists (not just favorites) |
101
+
102
+ ### Music Together (Collaborative Playlists)
103
+
104
+ | Method | Description |
105
+ | ------------------------------------------ | --------------------------------------------------------- |
106
+ | `get_music_together_groups(first)` | User's Music Together groups |
107
+ | `get_music_together_group(group_id, mood)` | Single group with members, suggested & curated tracklists |
108
+ | `get_music_together_affinity(group_id)` | Group member affinity scores and discovery content |
109
+
110
+ ## Available Mutations
111
+
112
+ ### Favorites Management
113
+
114
+ | Method | Description |
115
+ | ---------------------------------------------- | ----------------------------------------------------------- |
116
+ | `add_artist_to_favorite(artist_id)` | Add artist to favorites |
117
+ | `remove_artist_from_favorite(artist_id)` | Remove artist from favorites |
118
+ | `add_album_to_favorite(album_id)` | Add album to favorites |
119
+ | `remove_album_from_favorite(album_id)` | Remove album from favorites |
120
+ | `add_track_to_favorite(track_id)` | Add track to favorites |
121
+ | `remove_track_from_favorite(track_id)` | Remove track from favorites |
122
+ | `add_playlist_to_favorite(playlist_id)` | Add playlist to favorites |
123
+ | `remove_playlist_from_favorite(playlist_id)` | Remove playlist from favorites |
124
+ | `add_podcast_to_favorite(podcast_id)` | Add podcast to favorites |
125
+ | `remove_podcast_from_favorite(podcast_id)` | Remove podcast from favorites |
126
+ | `add_audiobook_to_favorite(audiobook_id)` | Add audiobook to favorites (deprecated but functional) |
127
+ | `remove_audiobook_from_favorite(audiobook_id)` | Remove audiobook from favorites (deprecated but functional) |
128
+
129
+ ### Playlist Management
130
+
131
+ | Method | Description |
132
+ | ------------------------------------------------ | ------------------------------------------------- |
133
+ | `create_playlist(title, ...)` | Create a new playlist |
134
+ | `update_playlist(playlist_id, ...)` | Update playlist title, description, or visibility |
135
+ | `delete_playlist(playlist_id)` | Delete a playlist |
136
+ | `add_tracks_to_playlist(playlist_id, track_ids)` | Add tracks to a playlist |
137
+ | `remove_tracks_from_playlist(playlist_id, ...)` | Remove tracks from a playlist |
138
+
139
+ ### Podcast Episode Management
140
+
141
+ | Method | Description |
142
+ | ------------------------------------------------ | ------------------------------------------------- |
143
+ | `bookmark_podcast_episode(episode_id, offset)` | Bookmark episode with playback position (seconds) |
144
+ | `unbookmark_podcast_episode(episode_id)` | Remove bookmark from episode |
145
+ | `mark_as_played_podcast_episode(episode_id)` | Mark episode as played |
146
+ | `mark_as_not_played_podcast_episode(episode_id)` | Mark episode as not played |
147
+
148
+ ### Music Together
149
+
150
+ | Method | Description |
151
+ | ------------------------------------------------- | ------------------------------------------- |
152
+ | `music_together_create_group(name, ...)` | Create a new Music Together group |
153
+ | `music_together_join_group(group_id)` | Join an existing group |
154
+ | `music_together_leave_group(group_id)` | Leave a group |
155
+ | `music_together_refresh_suggested_tracklist(...)` | Refresh the suggested tracklist for a group |
156
+ | `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
157
+ | `music_together_generate_group_name()` | Generate a random group name |
158
+
159
+ ### Utilities
160
+
161
+ | Method | Description |
162
+ | -------------------------------- | ---------------------------------------------------------------- |
163
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
164
+
165
+ All methods return fully-typed Pydantic models generated from the GraphQL schema.
166
+
167
+ ## Development
168
+
169
+ Requires **Python 3.12+** and [uv](https://docs.astral.sh/uv/).
170
+
171
+ ```bash
172
+ # Install all dependencies (including codegen tooling)
173
+ make setup
174
+
175
+ # Re-generate the typed client from schema + queries
176
+ make generate
177
+
178
+ # Run linters and type checks
179
+ make lint
180
+
181
+ # Run tests
182
+ make test
183
+ ```
184
+
185
+ ### Adding a new query
186
+
187
+ 1. Create a `.graphql` file in `queries/`.
188
+ 2. Run `make generate` to produce the typed client method and response models.
189
+ 3. Add tests in `tests/`.
190
+
191
+ ## Exploring the API
192
+
193
+ To run ad-hoc GraphQL queries against the live Pipe API during development:
194
+
195
+ 1. Create a `.env` file (already gitignored) with your ARL cookie:
196
+
197
+ ```bash
198
+ echo 'DEEZER_ARL=your_arl_cookie_value' > .env
199
+ ```
200
+
201
+ 2. Run queries:
202
+
203
+ ```bash
204
+ # Run a .graphql file
205
+ uv run python scripts/explore.py queries/get_me.graphql
206
+
207
+ # Run an inline query
208
+ uv run python scripts/explore.py -q '{ me { id } }'
209
+
210
+ # With variables
211
+ uv run python scripts/explore.py -q 'query($id: String!) { track(trackId: $id) { title } }' \
212
+ -v '{"id": "3135556"}'
213
+
214
+ # Via make
215
+ make explore Q=queries/get_me.graphql
216
+ ```
217
+
218
+ The script handles JWT auth automatically — no manual token management needed.
219
+
220
+ ## Authentication
221
+
222
+ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
223
+ handles token acquisition and refresh automatically — you only need to supply a
224
+ valid ARL value.
225
+
226
+ ## License
227
+
228
+ Apache-2.0
@@ -257,3 +257,35 @@ class DeezerBaseClient:
257
257
  logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
258
258
 
259
259
  return self._jwt
260
+
261
+ async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
262
+ """Check which album IDs are also valid audiobooks on Deezer.
263
+
264
+ Uses GraphQL aliases to batch-check many IDs in a single request.
265
+ Returns the subset of IDs that are audiobooks (i.e., the audiobook
266
+ query returns non-null for them).
267
+
268
+ :param album_ids: List of Deezer album/audiobook IDs to check.
269
+ """
270
+ if not album_ids:
271
+ return set()
272
+
273
+ # Query displayTitle alongside id — querying only { id } echoes back the
274
+ # input without validating that the ID is actually an audiobook.
275
+ parts = [
276
+ f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
277
+ for i, aid in enumerate(album_ids)
278
+ ]
279
+ query = "{ " + " ".join(parts) + " }"
280
+
281
+ resp = await self.execute(query)
282
+ data = self.get_data(resp)
283
+
284
+ audiobook_ids: set[str] = set()
285
+ for i, aid in enumerate(album_ids):
286
+ node = data.get(f"a{i}")
287
+ # The API echoes back {id} for any valid album, so we must check
288
+ # a real audiobook field like displayTitle to distinguish.
289
+ if node is not None and node.get("displayTitle") is not None:
290
+ audiobook_ids.add(aid)
291
+ return audiobook_ids