deezer-python-gql 0.16.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.16.0/deezer_python_gql.egg-info → deezer_python_gql-0.17.1}/PKG-INFO +9 -5
  2. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/README.md +5 -1
  3. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/__init__.py +2 -0
  4. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/base_client.py +99 -42
  5. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py +6 -0
  6. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py +99 -42
  7. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/client.py +7 -0
  8. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search.py +15 -0
  9. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1/deezer_python_gql.egg-info}/PKG-INFO +9 -5
  10. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/requires.txt +3 -3
  11. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/pyproject.toml +4 -4
  12. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/tests/test_client.py +234 -0
  13. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/LICENSE +0 -0
  14. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/MANIFEST.in +0 -0
  15. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  16. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  17. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  18. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  19. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  20. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  21. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  22. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_model.py +0 -0
  23. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  24. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/create_playlist.py +0 -0
  25. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/delete_playlist.py +0 -0
  26. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/enums.py +0 -0
  27. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/fragments.py +0 -0
  28. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_album.py +0 -0
  29. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist.py +0 -0
  30. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  31. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook.py +0 -0
  32. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  33. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_charts.py +0 -0
  34. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  35. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  36. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  37. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  38. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  39. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  40. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow.py +0 -0
  41. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  42. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  43. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  44. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_livestream.py +0 -0
  45. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  46. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_me.py +0 -0
  47. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  48. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  49. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  50. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  51. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_playlist.py +0 -0
  52. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast.py +0 -0
  53. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  54. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  55. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  56. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recently_played.py +0 -0
  57. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recommendations.py +0 -0
  58. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  59. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  60. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  61. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track.py +0 -0
  62. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track_mix.py +0 -0
  63. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_charts.py +0 -0
  64. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  65. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/input_types.py +0 -0
  66. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  67. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  68. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  69. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  70. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  71. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  72. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  73. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  74. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  75. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  76. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  77. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  78. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  79. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  80. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  81. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search_flows.py +0 -0
  82. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  83. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/update_playlist.py +0 -0
  84. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/py.typed +0 -0
  85. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  86. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  87. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  88. {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
  89. {deezer_python_gql-0.16.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.16.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):
@@ -615,6 +615,9 @@ from .search import (
615
615
  SearchSearchResultsTracksEdgesNodeContributors,
616
616
  SearchSearchResultsTracksEdgesNodeContributorsEdges,
617
617
  SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist,
618
+ SearchSearchResultsTracksEdgesNodeMedia,
619
+ SearchSearchResultsTracksEdgesNodeMediaRights,
620
+ SearchSearchResultsTracksEdgesNodeMediaRightsSub,
618
621
  SearchSearchResultsTracksPageInfo,
619
622
  )
620
623
  from .search_flows import (
@@ -1133,6 +1136,9 @@ __all__ = [
1133
1136
  "SearchSearchResultsTracksEdgesNodeContributors",
1134
1137
  "SearchSearchResultsTracksEdgesNodeContributorsEdges",
1135
1138
  "SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist",
1139
+ "SearchSearchResultsTracksEdgesNodeMedia",
1140
+ "SearchSearchResultsTracksEdgesNodeMediaRights",
1141
+ "SearchSearchResultsTracksEdgesNodeMediaRightsSub",
1136
1142
  "SearchSearchResultsTracksPageInfo",
1137
1143
  "TrackContributorRoles",
1138
1144
  "TrackFields",
@@ -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):
@@ -4017,6 +4017,13 @@ class DeezerGQLClient(DeezerBaseClient):
4017
4017
  }
4018
4018
  }
4019
4019
  }
4020
+ media {
4021
+ rights {
4022
+ sub {
4023
+ available
4024
+ }
4025
+ }
4026
+ }
4020
4027
  }
4021
4028
  }
4022
4029
  pageInfo {
@@ -45,6 +45,7 @@ class SearchSearchResultsTracksEdgesNode(BaseModel):
45
45
  is_favorite: Optional[bool] = Field(alias="isFavorite")
46
46
  album: Optional["SearchSearchResultsTracksEdgesNodeAlbum"]
47
47
  contributors: "SearchSearchResultsTracksEdgesNodeContributors"
48
+ media: Optional["SearchSearchResultsTracksEdgesNodeMedia"]
48
49
 
49
50
 
50
51
  class SearchSearchResultsTracksEdgesNodeAlbum(BaseModel):
@@ -75,6 +76,18 @@ class SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist(BaseModel):
75
76
  name: str
76
77
 
77
78
 
79
+ class SearchSearchResultsTracksEdgesNodeMedia(BaseModel):
80
+ rights: "SearchSearchResultsTracksEdgesNodeMediaRights"
81
+
82
+
83
+ class SearchSearchResultsTracksEdgesNodeMediaRights(BaseModel):
84
+ sub: Optional["SearchSearchResultsTracksEdgesNodeMediaRightsSub"]
85
+
86
+
87
+ class SearchSearchResultsTracksEdgesNodeMediaRightsSub(BaseModel):
88
+ available: bool
89
+
90
+
78
91
  class SearchSearchResultsTracksPageInfo(PageInfoFields):
79
92
  pass
80
93
 
@@ -244,6 +257,8 @@ SearchSearchResultsTracksEdgesNode.model_rebuild()
244
257
  SearchSearchResultsTracksEdgesNodeAlbum.model_rebuild()
245
258
  SearchSearchResultsTracksEdgesNodeContributors.model_rebuild()
246
259
  SearchSearchResultsTracksEdgesNodeContributorsEdges.model_rebuild()
260
+ SearchSearchResultsTracksEdgesNodeMedia.model_rebuild()
261
+ SearchSearchResultsTracksEdgesNodeMediaRights.model_rebuild()
247
262
  SearchSearchResultsAlbums.model_rebuild()
248
263
  SearchSearchResultsAlbumsEdges.model_rebuild()
249
264
  SearchSearchResultsAlbumsEdgesNode.model_rebuild()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.16.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