Skip to content

Commit 5efcfb1

Browse files
committed
feat: add check_audiobook_ids, podcast batch episodes, and music together group query
1 parent 32eb1e6 commit 5efcfb1

16 files changed

Lines changed: 376 additions & 8 deletions

CLAUDE.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Always run `make lint` after a code change to ensure the new code adheres to the
6868
deezer-python-gql/
6969
├── deezer_python_gql/ # Package source
7070
│ ├── __init__.py # Public API — exports DeezerGQLClient
71-
│ ├── base_client.py # Hand-written: ARL→JWT auth, execute(), get_data()
71+
│ ├── base_client.py # Hand-written: ARL→JWT auth, execute(), get_data(), check_audiobook_ids()
7272
│ ├── py.typed # PEP 561 marker for type checkers
7373
│ └── generated/ # AUTO-GENERATED by ariadne-codegen — never edit manually
7474
│ ├── __init__.py
@@ -324,6 +324,16 @@ async with httpx.AsyncClient() as http:
324324

325325
If no `http_client` is provided, the base client creates (and closes) a throwaway `httpx.AsyncClient` per request. This works but is inefficient for high-volume usage like library syncs.
326326

327+
### Audiobook ID Checking
328+
329+
The `check_audiobook_ids(album_ids)` method on `DeezerBaseClient` batch-checks whether album IDs are actually audiobooks. It builds a single aliased GraphQL query:
330+
331+
```graphql
332+
{ a0: audiobook(audiobookId: "123") { id displayTitle } a1: audiobook(audiobookId: "456") { id displayTitle } }
333+
```
334+
335+
**Important**: Querying only `{ id }` is insufficient — the API echoes back the input ID for *any* valid album, regardless of whether it's an audiobook. The `displayTitle` field returns `null` (with `AudiobookNotFoundError`) for non-audiobooks, so the method checks `displayTitle is not None` to distinguish real audiobooks.
336+
327337
## Key Configuration
328338

329339
- **Python**: 3.12+ required (tested on 3.12, 3.13)
@@ -410,12 +420,13 @@ Use comments only to explain "why", not "what". Reserve inline comments for comp
410420

411421
Tests are organized into four layers:
412422

413-
| Layer | Tests | What's validated |
414-
| ---------------------- | ----- | ---------------------------------------------------------------------------------- |
415-
| **Client setup** | 3 | Import, instantiation with ARL, presence of all generated methods |
416-
| **Auth flow** (mocked) | 5 | JWT acquisition, token reuse, refresh on expiry, cookie domain, text/plain parsing |
417-
| **Error handling** | 5 | HTTP errors, invalid JSON, missing data key, GraphQL errors, success path |
418-
| **Model smoke tests** | 61 | One per query/mutation — fixture parses correctly with key fields accessible |
423+
| Layer | Tests | What's validated |
424+
| ------------------------- | ----- | ---------------------------------------------------------------------------------- |
425+
| **Client setup** | 3 | Import, instantiation with ARL, presence of all generated methods |
426+
| **Auth flow** (mocked) | 5 | JWT acquisition, token reuse, refresh on expiry, cookie domain, text/plain parsing |
427+
| **Error handling** | 5 | HTTP errors, invalid JSON, missing data key, GraphQL errors, success path |
428+
| **Model smoke tests** | 62 | One per query/mutation — fixture parses correctly with key fields accessible |
429+
| **Audiobook ID checking** | 2 | Batch alias query returns correct subset; empty input returns empty set |
419430

420431
### Auth Flow Tests
421432

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ asyncio.run(main())
156156
| `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
157157
| `music_together_generate_group_name()` | Generate a random group name |
158158

159+
### Utilities
160+
161+
| Method | Description |
162+
| -------------------------------- | ---------------------------------------------------------------- |
163+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
164+
159165
All methods return fully-typed Pydantic models generated from the GraphQL schema.
160166

161167
## Development

deezer_python_gql/base_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,35 @@ async def _ensure_jwt(self) -> str:
257257
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
258258

259259
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

deezer_python_gql/generated/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@
299299
GetMusicTogetherGroup,
300300
GetMusicTogetherGroupMusicTogetherGroup,
301301
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklist,
302+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracks,
303+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdges,
304+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdgesNode,
305+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksPageInfo,
302306
GetMusicTogetherGroupMusicTogetherGroupMembers,
303307
GetMusicTogetherGroupMusicTogetherGroupMembersEdges,
304308
GetMusicTogetherGroupMusicTogetherGroupMembersEdgesAffinity,
@@ -365,6 +369,10 @@
365369
GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksEdgesNodeEpisodePodcast,
366370
GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksPageInfo,
367371
)
372+
from .get_podcast_episodes_by_ids import (
373+
GetPodcastEpisodesByIds,
374+
GetPodcastEpisodesByIdsPodcastEpisodesByIds,
375+
)
368376
from .get_recently_played import (
369377
GetRecentlyPlayed,
370378
GetRecentlyPlayedMe,
@@ -812,6 +820,10 @@
812820
"GetMusicTogetherGroup",
813821
"GetMusicTogetherGroupMusicTogetherGroup",
814822
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklist",
823+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracks",
824+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdges",
825+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdgesNode",
826+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksPageInfo",
815827
"GetMusicTogetherGroupMusicTogetherGroupMembers",
816828
"GetMusicTogetherGroupMusicTogetherGroupMembersEdges",
817829
"GetMusicTogetherGroupMusicTogetherGroupMembersEdgesAffinity",
@@ -859,6 +871,8 @@
859871
"GetPodcastEpisodePodcastEpisodePodcast",
860872
"GetPodcastEpisodePodcastEpisodeUrlDeezerUrl",
861873
"GetPodcastEpisodePodcastEpisodeUrlUrl",
874+
"GetPodcastEpisodesByIds",
875+
"GetPodcastEpisodesByIdsPodcastEpisodesByIds",
862876
"GetPodcastPodcast",
863877
"GetPodcastPodcastEpisodes",
864878
"GetPodcastPodcastEpisodesEdges",

deezer_python_gql/generated/base_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,35 @@ async def _ensure_jwt(self) -> str:
259259
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
260260

261261
return self._jwt
262+
263+
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
264+
"""Check which album IDs are also valid audiobooks on Deezer.
265+
266+
Uses GraphQL aliases to batch-check many IDs in a single request.
267+
Returns the subset of IDs that are audiobooks (i.e., the audiobook
268+
query returns non-null for them).
269+
270+
:param album_ids: List of Deezer album/audiobook IDs to check.
271+
"""
272+
if not album_ids:
273+
return set()
274+
275+
# Query displayTitle alongside id — querying only { id } echoes back the
276+
# input without validating that the ID is actually an audiobook.
277+
parts = [
278+
f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
279+
for i, aid in enumerate(album_ids)
280+
]
281+
query = "{ " + " ".join(parts) + " }"
282+
283+
resp = await self.execute(query)
284+
data = self.get_data(resp)
285+
286+
audiobook_ids: set[str] = set()
287+
for i, aid in enumerate(album_ids):
288+
node = data.get(f"a{i}")
289+
# The API echoes back {id} for any valid album, so we must check
290+
# a real audiobook field like displayTitle to distinguish.
291+
if node is not None and node.get("displayTitle") is not None:
292+
audiobook_ids.add(aid)
293+
return audiobook_ids

deezer_python_gql/generated/client.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@
8383
GetPodcastEpisodeBookmarks,
8484
GetPodcastEpisodeBookmarksMe,
8585
)
86+
from .get_podcast_episodes_by_ids import (
87+
GetPodcastEpisodesByIds,
88+
GetPodcastEpisodesByIdsPodcastEpisodesByIds,
89+
)
8690
from .get_recently_played import GetRecentlyPlayed, GetRecentlyPlayedMe
8791
from .get_recommendations import GetRecommendations, GetRecommendationsMe
8892
from .get_similar_tracks import GetSimilarTracks, GetSimilarTracksTrack
@@ -2069,6 +2073,17 @@ async def get_music_together_group(
20692073
}
20702074
curatedTracklist {
20712075
...PlaylistFields
2076+
tracks(first: $tracksFirst, after: $tracksAfter) {
2077+
edges {
2078+
cursor
2079+
node {
2080+
...TrackFields
2081+
}
2082+
}
2083+
pageInfo {
2084+
...PageInfoFields
2085+
}
2086+
}
20722087
}
20732088
}
20742089
}
@@ -2303,6 +2318,7 @@ async def get_podcast(
23032318
query GetPodcast($podcastId: String!, $episodesFirst: Int = 50, $episodesAfter: String, $episodeOrder: PodcastEpisodeOrder = LATEST) {
23042319
podcast(podcastId: $podcastId) {
23052320
...PodcastFields
2321+
rawEpisodes
23062322
isAdvertisingAllowed
23072323
isDownloadAllowed
23082324
rights {
@@ -2515,6 +2531,45 @@ async def get_podcast_episode_bookmarks(
25152531
data = self.get_data(response)
25162532
return GetPodcastEpisodeBookmarks.model_validate(data).me
25172533

2534+
async def get_podcast_episodes_by_ids(
2535+
self, ids: list[str], **kwargs: Any
2536+
) -> list[Optional[GetPodcastEpisodesByIdsPodcastEpisodesByIds]]:
2537+
query = gql("""
2538+
query GetPodcastEpisodesByIds($ids: [String!]!) {
2539+
podcastEpisodesByIds(ids: $ids) {
2540+
...PodcastEpisodeFields
2541+
}
2542+
}
2543+
2544+
fragment PodcastEpisodeFields on PodcastEpisode {
2545+
id
2546+
title
2547+
description
2548+
duration
2549+
cover {
2550+
id
2551+
urls(pictureRequest: {width: 264, height: 264})
2552+
}
2553+
publicationDate
2554+
media {
2555+
url
2556+
codec {
2557+
type
2558+
bitrate
2559+
}
2560+
}
2561+
}
2562+
""")
2563+
variables: dict[str, object] = {"ids": ids}
2564+
response = await self.execute(
2565+
query=query,
2566+
operation_name="GetPodcastEpisodesByIds",
2567+
variables=variables,
2568+
**kwargs
2569+
)
2570+
data = self.get_data(response)
2571+
return GetPodcastEpisodesByIds.model_validate(data).podcast_episodes_by_ids
2572+
25182573
async def get_recently_played(
25192574
self, first: Union[Optional[int], UnsetType] = UNSET, **kwargs: Any
25202575
) -> Optional[GetRecentlyPlayedMe]:

deezer_python_gql/generated/get_music_together_group.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,32 @@ class GetMusicTogetherGroupMusicTogetherGroupSuggestedTracklistTracklistTracksPa
148148

149149

150150
class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklist(PlaylistFields):
151+
tracks: "GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracks"
152+
153+
154+
class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracks(BaseModel):
155+
edges: list["GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdges"]
156+
page_info: (
157+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksPageInfo"
158+
) = Field(alias="pageInfo")
159+
160+
161+
class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdges(BaseModel):
162+
cursor: str
163+
node: Optional[
164+
"GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdgesNode"
165+
]
166+
167+
168+
class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdgesNode(
169+
TrackFields
170+
):
171+
pass
172+
173+
174+
class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksPageInfo(
175+
PageInfoFields
176+
):
151177
pass
152178

153179

@@ -163,3 +189,6 @@ class GetMusicTogetherGroupMusicTogetherGroupCuratedTracklist(PlaylistFields):
163189
GetMusicTogetherGroupMusicTogetherGroupSuggestedTracklistTracklistTracksEdgesMetadata.model_rebuild()
164190
GetMusicTogetherGroupMusicTogetherGroupSuggestedTracklistTracklistTracksEdgesMetadataMusicTogether.model_rebuild()
165191
GetMusicTogetherGroupMusicTogetherGroupSuggestedTracklistTracklistTracksEdgesMetadataMusicTogetherTrackOrigin.model_rebuild()
192+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklist.model_rebuild()
193+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracks.model_rebuild()
194+
GetMusicTogetherGroupMusicTogetherGroupCuratedTracklistTracksEdges.model_rebuild()

deezer_python_gql/generated/get_podcast.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class GetPodcast(BaseModel):
1414

1515

1616
class GetPodcastPodcast(PodcastFields):
17+
raw_episodes: list[str] = Field(alias="rawEpisodes")
1718
is_advertising_allowed: bool = Field(alias="isAdvertisingAllowed")
1819
is_download_allowed: bool = Field(alias="isDownloadAllowed")
1920
rights: "GetPodcastPodcastRights"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by ariadne-codegen
2+
# Source: queries
3+
4+
from typing import Optional
5+
6+
from pydantic import Field
7+
8+
from .base_model import BaseModel
9+
from .fragments import PodcastEpisodeFields
10+
11+
12+
class GetPodcastEpisodesByIds(BaseModel):
13+
podcast_episodes_by_ids: list[
14+
Optional["GetPodcastEpisodesByIdsPodcastEpisodesByIds"]
15+
] = Field(alias="podcastEpisodesByIds")
16+
17+
18+
class GetPodcastEpisodesByIdsPodcastEpisodesByIds(PodcastEpisodeFields):
19+
pass
20+
21+
22+
GetPodcastEpisodesByIds.model_rebuild()

queries/get_music_together_group.graphql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ query GetMusicTogetherGroup(
6666
}
6767
curatedTracklist {
6868
...PlaylistFields
69+
tracks(first: $tracksFirst, after: $tracksAfter) {
70+
edges {
71+
cursor
72+
node {
73+
...TrackFields
74+
}
75+
}
76+
pageInfo {
77+
...PageInfoFields
78+
}
79+
}
6980
}
7081
}
7182
}

0 commit comments

Comments
 (0)