deezer-python-gql 0.17.0__tar.gz → 0.17.1__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.
Files changed (89) hide show
  1. {deezer_python_gql-0.17.0/deezer_python_gql.egg-info → deezer_python_gql-0.17.1}/PKG-INFO +9 -5
  2. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/README.md +5 -1
  3. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/__init__.py +2 -0
  4. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/base_client.py +99 -42
  5. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py +99 -42
  6. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1/deezer_python_gql.egg-info}/PKG-INFO +9 -5
  7. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/requires.txt +3 -3
  8. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/pyproject.toml +4 -4
  9. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/tests/test_client.py +234 -0
  10. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/LICENSE +0 -0
  11. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/MANIFEST.in +0 -0
  12. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py +0 -0
  13. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  14. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  15. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  16. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  17. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  18. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  19. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  20. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_model.py +0 -0
  21. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  22. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/client.py +0 -0
  23. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/create_playlist.py +0 -0
  24. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/delete_playlist.py +0 -0
  25. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/enums.py +0 -0
  26. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/fragments.py +0 -0
  27. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_album.py +0 -0
  28. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist.py +0 -0
  29. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  30. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook.py +0 -0
  31. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  32. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_charts.py +0 -0
  33. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  34. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  35. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  36. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  37. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  38. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  39. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow.py +0 -0
  40. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  41. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  42. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  43. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_livestream.py +0 -0
  44. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  45. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_me.py +0 -0
  46. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  47. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  48. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  49. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  50. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_playlist.py +0 -0
  51. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast.py +0 -0
  52. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  53. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  54. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  55. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recently_played.py +0 -0
  56. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recommendations.py +0 -0
  57. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  58. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  59. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  60. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track.py +0 -0
  61. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track_mix.py +0 -0
  62. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_charts.py +0 -0
  63. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  64. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/input_types.py +0 -0
  65. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  66. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  67. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  68. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  69. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  70. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  71. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  72. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  73. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  74. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  75. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  76. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  77. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  78. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  79. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  80. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search.py +0 -0
  81. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search_flows.py +0 -0
  82. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  83. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/update_playlist.py +0 -0
  84. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/py.typed +0 -0
  85. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  86. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  87. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  88. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
  89. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.0
3
+ Version: 0.17.1
4
4
  Summary: Async typed Python client for Deezer's Pipe GraphQL API.
5
5
  Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
18
18
  Requires-Dist: mypy==2.1.0; extra == "test"
19
19
  Requires-Dist: pre-commit==4.6.0; extra == "test"
20
20
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
- Requires-Dist: pytest==9.0.3; extra == "test"
22
- Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
21
+ Requires-Dist: pytest==9.1.1; extra == "test"
22
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
23
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.13; extra == "test"
24
+ Requires-Dist: ruff==0.15.20; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -189,7 +189,7 @@ asyncio.run(main())
189
189
 
190
190
  | Method | Description |
191
191
  | -------------------------------- | ---------------------------------------------------------------- |
192
- | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
192
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
193
193
 
194
194
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
195
195
 
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
252
252
  handles token acquisition and refresh automatically — you only need to supply a
253
253
  valid ARL value.
254
254
 
255
+ If the ARL is rejected (bad or expired), the client raises
256
+ `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
+ Transient server errors raise `GraphQLClientHttpError` instead.
258
+
255
259
  ## License
256
260
 
257
261
  Apache-2.0
@@ -161,7 +161,7 @@ asyncio.run(main())
161
161
 
162
162
  | Method | Description |
163
163
  | -------------------------------- | ---------------------------------------------------------------- |
164
- | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
164
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
165
165
 
166
166
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
167
167
 
@@ -224,6 +224,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
224
224
  handles token acquisition and refresh automatically — you only need to supply a
225
225
  valid ARL value.
226
226
 
227
+ If the ARL is rejected (bad or expired), the client raises
228
+ `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
229
+ Transient server errors raise `GraphQLClientHttpError` instead.
230
+
227
231
  ## License
228
232
 
229
233
  Apache-2.0
@@ -2,6 +2,7 @@
2
2
 
3
3
  from deezer_python_gql.generated.base_client import (
4
4
  GQLResponse,
5
+ GraphQLClientAuthError,
5
6
  GraphQLClientError,
6
7
  GraphQLClientGraphQLError,
7
8
  GraphQLClientGraphQLMultiError,
@@ -13,6 +14,7 @@ from deezer_python_gql.generated.client import DeezerGQLClient
13
14
  __all__ = [
14
15
  "DeezerGQLClient",
15
16
  "GQLResponse",
17
+ "GraphQLClientAuthError",
16
18
  "GraphQLClientError",
17
19
  "GraphQLClientGraphQLError",
18
20
  "GraphQLClientGraphQLMultiError",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import logging
7
8
  import time
@@ -21,17 +22,31 @@ class GQLResponse:
21
22
 
22
23
  aiohttp responses cannot escape their context manager, so we capture
23
24
  the essential fields inside the ``async with`` block and return this.
25
+ Carries the originating operation name and variables so error handling
26
+ and logging stay correct under concurrent requests.
24
27
  """
25
28
 
26
29
  status: int
27
30
  data: bytes
28
31
  is_success: bool
32
+ operation_name: str | None = None
33
+ variables: dict[str, Any] | None = None
29
34
 
30
35
 
31
36
  class GraphQLClientError(Exception):
32
37
  """Base exception for GraphQL client errors."""
33
38
 
34
39
 
40
+ class GraphQLClientAuthError(GraphQLClientError):
41
+ """
42
+ Raised when ARL → JWT authentication definitively fails.
43
+
44
+ Indicates a bad or expired ARL cookie (or an unexpected auth response),
45
+ not a transient network/server problem — consumers should treat this as
46
+ "re-check your ARL token" rather than retry.
47
+ """
48
+
49
+
35
50
  class GraphQLClientHttpError(GraphQLClientError):
36
51
  """Raised when the HTTP response indicates an error."""
37
52
 
@@ -109,6 +124,8 @@ class DeezerBaseClient:
109
124
  PIPE_URL = "https://pipe.deezer.com/api"
110
125
  AUTH_URL = "https://auth.deezer.com/login/arl"
111
126
  JWT_REFRESH_MARGIN_SECONDS = 30
127
+ REQUEST_TIMEOUT_SECONDS = 30
128
+ AUDIOBOOK_CHECK_CHUNK_SIZE = 50
112
129
 
113
130
  def __init__(
114
131
  self,
@@ -122,8 +139,7 @@ class DeezerBaseClient:
122
139
  self._owns_session = session is None
123
140
  self._jwt: str | None = None
124
141
  self._jwt_expires_at: float = 0
125
- self._last_operation_name: str | None = None
126
- self._last_variables: dict[str, Any] | None = None
142
+ self._jwt_lock = asyncio.Lock()
127
143
 
128
144
  def _get_session(self) -> ClientSession:
129
145
  """Return the HTTP session, creating an internal one if needed."""
@@ -167,13 +183,12 @@ class DeezerBaseClient:
167
183
  :param kwargs: Additional keyword arguments passed to the session request.
168
184
  """
169
185
  logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
170
- self._last_operation_name = operation_name
171
- self._last_variables = variables
172
186
  jwt = await self._ensure_jwt()
173
187
 
174
188
  headers: dict[str, str] = kwargs.pop("headers", None) or {}
175
189
  headers["Authorization"] = f"Bearer {jwt}"
176
190
  headers["Content-Type"] = "application/json"
191
+ kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
177
192
 
178
193
  payload: dict[str, Any] = {"query": query}
179
194
  if operation_name:
@@ -194,6 +209,8 @@ class DeezerBaseClient:
194
209
  status=resp.status,
195
210
  data=body,
196
211
  is_success=resp.ok,
212
+ operation_name=operation_name,
213
+ variables=variables,
197
214
  )
198
215
 
199
216
  logger.debug(
@@ -227,8 +244,8 @@ class DeezerBaseClient:
227
244
  errors = response_json.get("errors")
228
245
 
229
246
  if errors:
230
- op = self._last_operation_name or "<unknown>"
231
- variables = self._last_variables
247
+ op = response.operation_name or "<unknown>"
248
+ variables = response.variables
232
249
  if data:
233
250
  # Partial success — some items failed (e.g. deleted albums in favorites).
234
251
  # Log the errors but return the valid data.
@@ -280,65 +297,105 @@ class DeezerBaseClient:
280
297
  for item in obj:
281
298
  cls._inject_missing_typenames(item)
282
299
 
300
+ def _jwt_is_valid(self) -> bool:
301
+ """Return True if the current JWT exists and is not about to expire."""
302
+ return self._jwt is not None and time.time() < (
303
+ self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
304
+ )
305
+
283
306
  async def _ensure_jwt(self) -> str:
284
307
  """Acquire or refresh the JWT token from ARL cookie."""
285
- now = time.time()
286
- if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
308
+ if self._jwt is not None and self._jwt_is_valid():
287
309
  return self._jwt
288
310
 
289
- logger.debug("JWT expired or missing, refreshing from ARL")
290
- params = {"jo": "p", "rto": "c", "i": "c"}
311
+ # Serialize refreshes so concurrent expired-JWT requests trigger
312
+ # exactly one auth call.
313
+ async with self._jwt_lock:
314
+ if self._jwt is not None and self._jwt_is_valid():
315
+ return self._jwt
316
+
317
+ logger.debug("JWT expired or missing, refreshing from ARL")
318
+ params = {"jo": "p", "rto": "c", "i": "c"}
319
+
320
+ session = self._get_session()
321
+ async with session.post(
322
+ self.AUTH_URL,
323
+ params=params,
324
+ cookies={"arl": self._arl},
325
+ timeout=ClientTimeout(total=10),
326
+ ) as resp:
327
+ status = resp.status
328
+ is_success = resp.ok
329
+ # Response body is text/plain containing JSON
330
+ text = await resp.text()
331
+
332
+ if not is_success:
333
+ if 400 <= status < 500:
334
+ msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
335
+ raise GraphQLClientAuthError(msg)
336
+ raise GraphQLClientHttpError(
337
+ status_code=status,
338
+ response=GQLResponse(status=status, data=text.encode(), is_success=False),
339
+ )
291
340
 
292
- session = self._get_session()
293
- async with session.post(
294
- self.AUTH_URL,
295
- params=params,
296
- cookies={"arl": self._arl},
297
- timeout=ClientTimeout(total=10),
298
- ) as resp:
299
- resp.raise_for_status()
300
- # Response body is text/plain containing JSON
301
- text = await resp.text()
302
-
303
- data = json.loads(text)
304
- self._jwt = data["jwt"]
305
-
306
- # Decode expiration from JWT payload (second segment, base64url-encoded)
307
- payload_segment = self._jwt.split(".")[1]
308
- # Add padding for base64 decoding
309
- padded = payload_segment + "=" * (-len(payload_segment) % 4)
310
- payload = json.loads(urlsafe_b64decode(padded))
311
- self._jwt_expires_at = float(payload["exp"])
312
- logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
313
-
314
- return self._jwt
341
+ try:
342
+ data = json.loads(text)
343
+ jwt: str = data["jwt"]
344
+ # Decode expiration from JWT payload (second segment, base64url-encoded)
345
+ payload_segment = jwt.split(".")[1]
346
+ # Add padding for base64 decoding
347
+ padded = payload_segment + "=" * (-len(payload_segment) % 4)
348
+ payload = json.loads(urlsafe_b64decode(padded))
349
+ expires_at = float(payload["exp"])
350
+ except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
351
+ msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
352
+ raise GraphQLClientAuthError(msg) from exc
353
+
354
+ self._jwt = jwt
355
+ self._jwt_expires_at = expires_at
356
+ logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
357
+
358
+ return jwt
315
359
 
316
360
  async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
317
361
  """
318
362
  Check which album IDs are also valid audiobooks on Deezer.
319
363
 
364
+ Queries are chunked to stay below the API's query complexity limit.
365
+
320
366
  :param album_ids: List of Deezer album/audiobook IDs to check.
321
367
  """
322
- if not album_ids:
323
- return set()
368
+ audiobook_ids: set[str] = set()
369
+ for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
370
+ chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
371
+ audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
372
+ return audiobook_ids
324
373
 
374
+ async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
375
+ """Check a single chunk of album IDs via one aliased query."""
325
376
  # Query displayTitle alongside id — querying only { id } echoes back the
326
377
  # input without validating that the ID is actually an audiobook.
378
+ # json.dumps escapes the IDs so they cannot break out of the string
379
+ # literal (this is a public library API — IDs may come from anywhere).
327
380
  parts = [
328
- f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
381
+ f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
329
382
  for i, aid in enumerate(album_ids)
330
383
  ]
331
384
  query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
332
385
 
333
386
  resp = await self.execute(query, operation_name="CheckAudiobookIds")
334
- self._last_variables = {"album_ids": album_ids}
335
387
  try:
336
388
  data = self.get_data(resp)
337
- except GraphQLClientGraphQLMultiError:
338
- # Expected when none of the IDs are audiobooks (every alias errors).
339
- # Also handles transient API errors — return empty rather than crash.
340
- logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
341
- return set()
389
+ except GraphQLClientGraphQLMultiError as err:
390
+ # Only swallow errors scoped to our aliased audiobook fields —
391
+ # that is the legitimate "these IDs are not audiobooks" case.
392
+ # Anything else (complexity limit, transient API failure) must
393
+ # surface, otherwise audiobooks silently degrade to albums.
394
+ aliases = {f"a{i}" for i in range(len(album_ids))}
395
+ if all(e.path and e.path[0] in aliases for e in err.errors):
396
+ logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
397
+ return set()
398
+ raise
342
399
 
343
400
  audiobook_ids: set[str] = set()
344
401
  for i, aid in enumerate(album_ids):
@@ -4,6 +4,7 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import asyncio
7
8
  import json
8
9
  import logging
9
10
  import time
@@ -23,17 +24,31 @@ class GQLResponse:
23
24
 
24
25
  aiohttp responses cannot escape their context manager, so we capture
25
26
  the essential fields inside the ``async with`` block and return this.
27
+ Carries the originating operation name and variables so error handling
28
+ and logging stay correct under concurrent requests.
26
29
  """
27
30
 
28
31
  status: int
29
32
  data: bytes
30
33
  is_success: bool
34
+ operation_name: str | None = None
35
+ variables: dict[str, Any] | None = None
31
36
 
32
37
 
33
38
  class GraphQLClientError(Exception):
34
39
  """Base exception for GraphQL client errors."""
35
40
 
36
41
 
42
+ class GraphQLClientAuthError(GraphQLClientError):
43
+ """
44
+ Raised when ARL → JWT authentication definitively fails.
45
+
46
+ Indicates a bad or expired ARL cookie (or an unexpected auth response),
47
+ not a transient network/server problem — consumers should treat this as
48
+ "re-check your ARL token" rather than retry.
49
+ """
50
+
51
+
37
52
  class GraphQLClientHttpError(GraphQLClientError):
38
53
  """Raised when the HTTP response indicates an error."""
39
54
 
@@ -111,6 +126,8 @@ class DeezerBaseClient:
111
126
  PIPE_URL = "https://pipe.deezer.com/api"
112
127
  AUTH_URL = "https://auth.deezer.com/login/arl"
113
128
  JWT_REFRESH_MARGIN_SECONDS = 30
129
+ REQUEST_TIMEOUT_SECONDS = 30
130
+ AUDIOBOOK_CHECK_CHUNK_SIZE = 50
114
131
 
115
132
  def __init__(
116
133
  self,
@@ -124,8 +141,7 @@ class DeezerBaseClient:
124
141
  self._owns_session = session is None
125
142
  self._jwt: str | None = None
126
143
  self._jwt_expires_at: float = 0
127
- self._last_operation_name: str | None = None
128
- self._last_variables: dict[str, Any] | None = None
144
+ self._jwt_lock = asyncio.Lock()
129
145
 
130
146
  def _get_session(self) -> ClientSession:
131
147
  """Return the HTTP session, creating an internal one if needed."""
@@ -169,13 +185,12 @@ class DeezerBaseClient:
169
185
  :param kwargs: Additional keyword arguments passed to the session request.
170
186
  """
171
187
  logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
172
- self._last_operation_name = operation_name
173
- self._last_variables = variables
174
188
  jwt = await self._ensure_jwt()
175
189
 
176
190
  headers: dict[str, str] = kwargs.pop("headers", None) or {}
177
191
  headers["Authorization"] = f"Bearer {jwt}"
178
192
  headers["Content-Type"] = "application/json"
193
+ kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
179
194
 
180
195
  payload: dict[str, Any] = {"query": query}
181
196
  if operation_name:
@@ -196,6 +211,8 @@ class DeezerBaseClient:
196
211
  status=resp.status,
197
212
  data=body,
198
213
  is_success=resp.ok,
214
+ operation_name=operation_name,
215
+ variables=variables,
199
216
  )
200
217
 
201
218
  logger.debug(
@@ -229,8 +246,8 @@ class DeezerBaseClient:
229
246
  errors = response_json.get("errors")
230
247
 
231
248
  if errors:
232
- op = self._last_operation_name or "<unknown>"
233
- variables = self._last_variables
249
+ op = response.operation_name or "<unknown>"
250
+ variables = response.variables
234
251
  if data:
235
252
  # Partial success — some items failed (e.g. deleted albums in favorites).
236
253
  # Log the errors but return the valid data.
@@ -282,65 +299,105 @@ class DeezerBaseClient:
282
299
  for item in obj:
283
300
  cls._inject_missing_typenames(item)
284
301
 
302
+ def _jwt_is_valid(self) -> bool:
303
+ """Return True if the current JWT exists and is not about to expire."""
304
+ return self._jwt is not None and time.time() < (
305
+ self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
306
+ )
307
+
285
308
  async def _ensure_jwt(self) -> str:
286
309
  """Acquire or refresh the JWT token from ARL cookie."""
287
- now = time.time()
288
- if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
310
+ if self._jwt is not None and self._jwt_is_valid():
289
311
  return self._jwt
290
312
 
291
- logger.debug("JWT expired or missing, refreshing from ARL")
292
- params = {"jo": "p", "rto": "c", "i": "c"}
313
+ # Serialize refreshes so concurrent expired-JWT requests trigger
314
+ # exactly one auth call.
315
+ async with self._jwt_lock:
316
+ if self._jwt is not None and self._jwt_is_valid():
317
+ return self._jwt
318
+
319
+ logger.debug("JWT expired or missing, refreshing from ARL")
320
+ params = {"jo": "p", "rto": "c", "i": "c"}
321
+
322
+ session = self._get_session()
323
+ async with session.post(
324
+ self.AUTH_URL,
325
+ params=params,
326
+ cookies={"arl": self._arl},
327
+ timeout=ClientTimeout(total=10),
328
+ ) as resp:
329
+ status = resp.status
330
+ is_success = resp.ok
331
+ # Response body is text/plain containing JSON
332
+ text = await resp.text()
333
+
334
+ if not is_success:
335
+ if 400 <= status < 500:
336
+ msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
337
+ raise GraphQLClientAuthError(msg)
338
+ raise GraphQLClientHttpError(
339
+ status_code=status,
340
+ response=GQLResponse(status=status, data=text.encode(), is_success=False),
341
+ )
293
342
 
294
- session = self._get_session()
295
- async with session.post(
296
- self.AUTH_URL,
297
- params=params,
298
- cookies={"arl": self._arl},
299
- timeout=ClientTimeout(total=10),
300
- ) as resp:
301
- resp.raise_for_status()
302
- # Response body is text/plain containing JSON
303
- text = await resp.text()
304
-
305
- data = json.loads(text)
306
- self._jwt = data["jwt"]
307
-
308
- # Decode expiration from JWT payload (second segment, base64url-encoded)
309
- payload_segment = self._jwt.split(".")[1]
310
- # Add padding for base64 decoding
311
- padded = payload_segment + "=" * (-len(payload_segment) % 4)
312
- payload = json.loads(urlsafe_b64decode(padded))
313
- self._jwt_expires_at = float(payload["exp"])
314
- logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
315
-
316
- return self._jwt
343
+ try:
344
+ data = json.loads(text)
345
+ jwt: str = data["jwt"]
346
+ # Decode expiration from JWT payload (second segment, base64url-encoded)
347
+ payload_segment = jwt.split(".")[1]
348
+ # Add padding for base64 decoding
349
+ padded = payload_segment + "=" * (-len(payload_segment) % 4)
350
+ payload = json.loads(urlsafe_b64decode(padded))
351
+ expires_at = float(payload["exp"])
352
+ except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
353
+ msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
354
+ raise GraphQLClientAuthError(msg) from exc
355
+
356
+ self._jwt = jwt
357
+ self._jwt_expires_at = expires_at
358
+ logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
359
+
360
+ return jwt
317
361
 
318
362
  async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
319
363
  """
320
364
  Check which album IDs are also valid audiobooks on Deezer.
321
365
 
366
+ Queries are chunked to stay below the API's query complexity limit.
367
+
322
368
  :param album_ids: List of Deezer album/audiobook IDs to check.
323
369
  """
324
- if not album_ids:
325
- return set()
370
+ audiobook_ids: set[str] = set()
371
+ for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
372
+ chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
373
+ audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
374
+ return audiobook_ids
326
375
 
376
+ async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
377
+ """Check a single chunk of album IDs via one aliased query."""
327
378
  # Query displayTitle alongside id — querying only { id } echoes back the
328
379
  # input without validating that the ID is actually an audiobook.
380
+ # json.dumps escapes the IDs so they cannot break out of the string
381
+ # literal (this is a public library API — IDs may come from anywhere).
329
382
  parts = [
330
- f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
383
+ f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
331
384
  for i, aid in enumerate(album_ids)
332
385
  ]
333
386
  query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
334
387
 
335
388
  resp = await self.execute(query, operation_name="CheckAudiobookIds")
336
- self._last_variables = {"album_ids": album_ids}
337
389
  try:
338
390
  data = self.get_data(resp)
339
- except GraphQLClientGraphQLMultiError:
340
- # Expected when none of the IDs are audiobooks (every alias errors).
341
- # Also handles transient API errors — return empty rather than crash.
342
- logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
343
- return set()
391
+ except GraphQLClientGraphQLMultiError as err:
392
+ # Only swallow errors scoped to our aliased audiobook fields —
393
+ # that is the legitimate "these IDs are not audiobooks" case.
394
+ # Anything else (complexity limit, transient API failure) must
395
+ # surface, otherwise audiobooks silently degrade to albums.
396
+ aliases = {f"a{i}" for i in range(len(album_ids))}
397
+ if all(e.path and e.path[0] in aliases for e in err.errors):
398
+ logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
399
+ return set()
400
+ raise
344
401
 
345
402
  audiobook_ids: set[str] = set()
346
403
  for i, aid in enumerate(album_ids):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.0
3
+ Version: 0.17.1
4
4
  Summary: Async typed Python client for Deezer's Pipe GraphQL API.
5
5
  Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
18
18
  Requires-Dist: mypy==2.1.0; extra == "test"
19
19
  Requires-Dist: pre-commit==4.6.0; extra == "test"
20
20
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
- Requires-Dist: pytest==9.0.3; extra == "test"
22
- Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
21
+ Requires-Dist: pytest==9.1.1; extra == "test"
22
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
23
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.13; extra == "test"
24
+ Requires-Dist: ruff==0.15.20; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -189,7 +189,7 @@ asyncio.run(main())
189
189
 
190
190
  | Method | Description |
191
191
  | -------------------------------- | ---------------------------------------------------------------- |
192
- | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
192
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
193
193
 
194
194
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
195
195
 
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
252
252
  handles token acquisition and refresh automatically — you only need to supply a
253
253
  valid ARL value.
254
254
 
255
+ If the ARL is rejected (bad or expired), the client raises
256
+ `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
+ Transient server errors raise `GraphQLClientHttpError` instead.
258
+
255
259
  ## License
256
260
 
257
261
  Apache-2.0
@@ -9,7 +9,7 @@ codespell==2.4.2
9
9
  mypy==2.1.0
10
10
  pre-commit==4.6.0
11
11
  pre-commit-hooks==6.0.0
12
- pytest==9.0.3
13
- pytest-asyncio==1.3.0
12
+ pytest==9.1.1
13
+ pytest-asyncio==1.4.0
14
14
  pytest-cov==7.1.0
15
- ruff==0.15.13
15
+ ruff==0.15.20
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.17.0"
3
+ version = "0.17.1"
4
4
  description = "Async typed Python client for Deezer's Pipe GraphQL API."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -26,10 +26,10 @@ test = [
26
26
  "mypy==2.1.0",
27
27
  "pre-commit==4.6.0",
28
28
  "pre-commit-hooks==6.0.0",
29
- "pytest==9.0.3",
30
- "pytest-asyncio==1.3.0",
29
+ "pytest==9.1.1",
30
+ "pytest-asyncio==1.4.0",
31
31
  "pytest-cov==7.1.0",
32
- "ruff==0.15.13",
32
+ "ruff==0.15.20",
33
33
  ]
34
34
  dev = [
35
35
  "ariadne-codegen[subscriptions]",
@@ -9,6 +9,7 @@ Organized into three layers:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import asyncio
12
13
  import base64
13
14
  import json
14
15
  import time
@@ -22,6 +23,7 @@ from deezer_python_gql import DeezerGQLClient
22
23
  from deezer_python_gql.base_client import (
23
24
  DeezerBaseClient,
24
25
  GQLResponse,
26
+ GraphQLClientAuthError,
25
27
  GraphQLClientGraphQLMultiError,
26
28
  GraphQLClientHttpError,
27
29
  GraphQLClientInvalidResponseError,
@@ -431,6 +433,114 @@ async def test_auth_parses_text_plain_response() -> None:
431
433
  assert client._jwt_expires_at > 0 # noqa: SLF001
432
434
 
433
435
 
436
+ @pytest.mark.asyncio
437
+ async def test_auth_rejected_arl_raises_auth_error() -> None:
438
+ """Verify a 4xx from auth.deezer.com raises GraphQLClientAuthError."""
439
+ client = DeezerBaseClient(arl="bad_arl")
440
+ auth_cm = _mock_post_context_manager(b"Forbidden", status=403)
441
+
442
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
443
+ mock_instance = MagicMock()
444
+ mock_instance.post = MagicMock(return_value=auth_cm)
445
+ mock_instance.close = AsyncMock()
446
+ mock_cls.return_value = mock_instance
447
+
448
+ with pytest.raises(GraphQLClientAuthError, match="HTTP 403"):
449
+ await client._ensure_jwt() # noqa: SLF001
450
+
451
+
452
+ @pytest.mark.asyncio
453
+ async def test_auth_server_error_raises_http_error() -> None:
454
+ """Verify a 5xx from auth.deezer.com raises GraphQLClientHttpError, not AuthError."""
455
+ client = DeezerBaseClient(arl="test_arl")
456
+ auth_cm = _mock_post_context_manager(b"Service Unavailable", status=503)
457
+
458
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
459
+ mock_instance = MagicMock()
460
+ mock_instance.post = MagicMock(return_value=auth_cm)
461
+ mock_instance.close = AsyncMock()
462
+ mock_cls.return_value = mock_instance
463
+
464
+ with pytest.raises(GraphQLClientHttpError) as exc_info:
465
+ await client._ensure_jwt() # noqa: SLF001
466
+ assert exc_info.value.status_code == 503
467
+
468
+
469
+ @pytest.mark.asyncio
470
+ async def test_auth_invalid_json_raises_auth_error() -> None:
471
+ """Verify a non-JSON auth response raises GraphQLClientAuthError."""
472
+ client = DeezerBaseClient(arl="test_arl")
473
+ auth_cm = _mock_post_context_manager(b"<html>maintenance</html>")
474
+
475
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
476
+ mock_instance = MagicMock()
477
+ mock_instance.post = MagicMock(return_value=auth_cm)
478
+ mock_instance.close = AsyncMock()
479
+ mock_cls.return_value = mock_instance
480
+
481
+ with pytest.raises(GraphQLClientAuthError):
482
+ await client._ensure_jwt() # noqa: SLF001
483
+
484
+
485
+ @pytest.mark.asyncio
486
+ async def test_auth_missing_jwt_key_raises_auth_error() -> None:
487
+ """Verify an auth response without a 'jwt' key raises GraphQLClientAuthError."""
488
+ client = DeezerBaseClient(arl="test_arl")
489
+ auth_cm = _mock_post_context_manager(json.dumps({"error": "invalid arl"}).encode())
490
+
491
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
492
+ mock_instance = MagicMock()
493
+ mock_instance.post = MagicMock(return_value=auth_cm)
494
+ mock_instance.close = AsyncMock()
495
+ mock_cls.return_value = mock_instance
496
+
497
+ with pytest.raises(GraphQLClientAuthError):
498
+ await client._ensure_jwt() # noqa: SLF001
499
+
500
+
501
+ @pytest.mark.asyncio
502
+ async def test_auth_concurrent_refresh_single_auth_call() -> None:
503
+ """Verify concurrent expired-JWT requests trigger exactly one auth call."""
504
+ client = DeezerBaseClient(arl="test_arl")
505
+ auth_cm = _mock_auth_cm()
506
+
507
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
508
+ mock_instance = MagicMock()
509
+ mock_instance.post = MagicMock(return_value=auth_cm)
510
+ mock_instance.close = AsyncMock()
511
+ mock_cls.return_value = mock_instance
512
+
513
+ results = await asyncio.gather(
514
+ client._ensure_jwt(), # noqa: SLF001
515
+ client._ensure_jwt(), # noqa: SLF001
516
+ client._ensure_jwt(), # noqa: SLF001
517
+ )
518
+
519
+ assert mock_instance.post.call_count == 1
520
+ assert len(set(results)) == 1
521
+
522
+
523
+ @pytest.mark.asyncio
524
+ async def test_execute_sets_default_timeout() -> None:
525
+ """Verify execute() applies the 30s default request timeout."""
526
+ client = DeezerBaseClient(arl="test_arl")
527
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
528
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
529
+
530
+ gql_cm = _mock_post_context_manager(json.dumps({"data": {"me": {"id": "1"}}}).encode())
531
+
532
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
533
+ mock_instance = MagicMock()
534
+ mock_instance.post = MagicMock(return_value=gql_cm)
535
+ mock_instance.close = AsyncMock()
536
+ mock_cls.return_value = mock_instance
537
+
538
+ await client.execute(query="{ me { id } }")
539
+
540
+ timeout = mock_instance.post.call_args.kwargs["timeout"]
541
+ assert timeout.total == DeezerBaseClient.REQUEST_TIMEOUT_SECONDS
542
+
543
+
434
544
  # ---------------------------------------------------------------------------
435
545
  # 4. Error handling (mocked HTTP)
436
546
  # ---------------------------------------------------------------------------
@@ -556,6 +666,130 @@ async def test_check_audiobook_ids_empty_input() -> None:
556
666
  assert result == set()
557
667
 
558
668
 
669
+ @pytest.mark.asyncio
670
+ async def test_check_audiobook_ids_chunks_large_input() -> None:
671
+ """Verify large ID lists are split into multiple aliased queries."""
672
+ client = DeezerBaseClient(arl="test_arl")
673
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
674
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
675
+
676
+ chunk_size = DeezerBaseClient.AUDIOBOOK_CHECK_CHUNK_SIZE
677
+ album_ids = [str(n) for n in range(chunk_size * 2 + 10)]
678
+
679
+ def _chunk_response(size: int) -> AsyncMock:
680
+ data = {f"a{i}": {"id": "x", "displayTitle": "Book"} for i in range(size)}
681
+ return _mock_post_context_manager(json.dumps({"data": data}).encode())
682
+
683
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
684
+ mock_instance = MagicMock()
685
+ mock_instance.post = MagicMock(
686
+ side_effect=[
687
+ _chunk_response(chunk_size),
688
+ _chunk_response(chunk_size),
689
+ _chunk_response(10),
690
+ ]
691
+ )
692
+ mock_instance.close = AsyncMock()
693
+ mock_cls.return_value = mock_instance
694
+
695
+ result = await client.check_audiobook_ids(album_ids)
696
+
697
+ assert mock_instance.post.call_count == 3
698
+ assert result == set(album_ids)
699
+
700
+
701
+ @pytest.mark.asyncio
702
+ async def test_check_audiobook_ids_escapes_ids() -> None:
703
+ """Verify IDs are JSON-escaped and cannot break out of the query string."""
704
+ client = DeezerBaseClient(arl="test_arl")
705
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
706
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
707
+
708
+ gql_cm = _mock_post_context_manager(json.dumps({"data": {"a0": None}}).encode())
709
+
710
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
711
+ mock_instance = MagicMock()
712
+ mock_instance.post = MagicMock(return_value=gql_cm)
713
+ mock_instance.close = AsyncMock()
714
+ mock_cls.return_value = mock_instance
715
+
716
+ await client.check_audiobook_ids(['123") { id } evil: me { id'])
717
+
718
+ query = mock_instance.post.call_args.kwargs["json"]["query"]
719
+ assert 'audiobookId: "123\\") { id } evil: me { id"' in query
720
+
721
+
722
+ @pytest.mark.asyncio
723
+ async def test_check_audiobook_ids_swallows_alias_scoped_errors() -> None:
724
+ """Verify per-alias not-found errors yield an empty set (none are audiobooks)."""
725
+ client = DeezerBaseClient(arl="test_arl")
726
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
727
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
728
+
729
+ gql_cm = _mock_post_context_manager(
730
+ json.dumps(
731
+ {
732
+ "data": None,
733
+ "errors": [
734
+ {"message": "Audiobook not found", "path": ["a0"]},
735
+ {"message": "Audiobook not found", "path": ["a1"]},
736
+ ],
737
+ }
738
+ ).encode(),
739
+ )
740
+
741
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
742
+ mock_instance = MagicMock()
743
+ mock_instance.post = MagicMock(return_value=gql_cm)
744
+ mock_instance.close = AsyncMock()
745
+ mock_cls.return_value = mock_instance
746
+
747
+ result = await client.check_audiobook_ids(["111", "222"])
748
+
749
+ assert result == set()
750
+
751
+
752
+ @pytest.mark.asyncio
753
+ async def test_check_audiobook_ids_raises_on_query_level_errors() -> None:
754
+ """Verify non-alias errors (e.g. complexity limit) surface instead of being swallowed."""
755
+ client = DeezerBaseClient(arl="test_arl")
756
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
757
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
758
+
759
+ gql_cm = _mock_post_context_manager(
760
+ json.dumps(
761
+ {
762
+ "data": None,
763
+ "errors": [{"message": "Query complexity limit exceeded"}],
764
+ }
765
+ ).encode(),
766
+ )
767
+
768
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
769
+ mock_instance = MagicMock()
770
+ mock_instance.post = MagicMock(return_value=gql_cm)
771
+ mock_instance.close = AsyncMock()
772
+ mock_cls.return_value = mock_instance
773
+
774
+ with pytest.raises(GraphQLClientGraphQLMultiError):
775
+ await client.check_audiobook_ids(["111", "222"])
776
+
777
+
778
+ def test_get_data_error_uses_response_operation_name() -> None:
779
+ """Verify GraphQL errors are attributed to the operation carried by the response."""
780
+ client = DeezerBaseClient(arl="test")
781
+ response = GQLResponse(
782
+ status=200,
783
+ data=json.dumps({"data": None, "errors": [{"message": "boom"}]}).encode(),
784
+ is_success=True,
785
+ operation_name="GetTrack",
786
+ )
787
+
788
+ with pytest.raises(GraphQLClientGraphQLMultiError) as exc_info:
789
+ client.get_data(response)
790
+ assert exc_info.value.operation_name == "GetTrack"
791
+
792
+
559
793
  # ---------------------------------------------------------------------------
560
794
  # 6. Model smoke tests (one per query — fixture-based)
561
795
  # ---------------------------------------------------------------------------