deezer-python-gql 0.17.0__tar.gz → 0.17.2__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.2}/PKG-INFO +19 -8
  2. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/README.md +11 -1
  3. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/__init__.py +2 -0
  4. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/base_client.py +133 -44
  5. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/base_client.py +133 -44
  6. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2/deezer_python_gql.egg-info}/PKG-INFO +19 -8
  7. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/requires.txt +7 -6
  8. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/pyproject.toml +9 -7
  9. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/tests/test_client.py +358 -9
  10. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/LICENSE +0 -0
  11. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/MANIFEST.in +0 -0
  12. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/__init__.py +0 -0
  13. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  14. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  15. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  16. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  17. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  18. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  19. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  20. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/base_model.py +0 -0
  21. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  22. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/client.py +0 -0
  23. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/create_playlist.py +0 -0
  24. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/delete_playlist.py +0 -0
  25. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/enums.py +0 -0
  26. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/fragments.py +0 -0
  27. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_album.py +0 -0
  28. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_artist.py +0 -0
  29. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  30. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_audiobook.py +0 -0
  31. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  32. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_charts.py +0 -0
  33. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  34. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  35. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  36. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  37. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  38. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  39. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow.py +0 -0
  40. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  41. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  42. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  43. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_livestream.py +0 -0
  44. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  45. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_me.py +0 -0
  46. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  47. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  48. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  49. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  50. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_playlist.py +0 -0
  51. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast.py +0 -0
  52. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  53. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  54. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  55. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_recently_played.py +0 -0
  56. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_recommendations.py +0 -0
  57. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  58. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  59. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  60. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_track.py +0 -0
  61. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_track_mix.py +0 -0
  62. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_user_charts.py +0 -0
  63. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  64. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/input_types.py +0 -0
  65. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/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.2}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  67. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  68. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  69. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  70. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  71. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  72. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  73. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  74. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  75. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  76. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  77. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  78. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  79. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  80. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/search.py +0 -0
  81. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/search_flows.py +0 -0
  82. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  83. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/update_playlist.py +0 -0
  84. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql/py.typed +0 -0
  85. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  86. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  87. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  88. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/top_level.txt +0 -0
  89. {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.2}/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.2
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
@@ -13,15 +13,16 @@ Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.13.5
15
15
  Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: yarl>=1.22.0
16
17
  Provides-Extra: test
17
- Requires-Dist: codespell==2.4.2; extra == "test"
18
- Requires-Dist: mypy==2.1.0; extra == "test"
19
- Requires-Dist: pre-commit==4.6.0; extra == "test"
18
+ Requires-Dist: codespell==2.4.3; extra == "test"
19
+ Requires-Dist: mypy==2.3.1; extra == "test"
20
+ Requires-Dist: pre-commit==4.6.2; extra == "test"
20
21
  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"
22
+ Requires-Dist: pytest==9.1.1; extra == "test"
23
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
24
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.13; extra == "test"
25
+ Requires-Dist: ruff==0.16.5; extra == "test"
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
28
  Dynamic: license-file
@@ -47,6 +48,7 @@ import asyncio
47
48
 
48
49
  from deezer_python_gql import DeezerGQLClient
49
50
 
51
+
50
52
  async def main():
51
53
  client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
52
54
 
@@ -74,6 +76,7 @@ async def main():
74
76
  results = await client.search(query="Daft Punk")
75
77
  print(len(results.tracks.edges), "tracks found")
76
78
 
79
+
77
80
  asyncio.run(main())
78
81
  ```
79
82
 
@@ -189,7 +192,7 @@ asyncio.run(main())
189
192
 
190
193
  | Method | Description |
191
194
  | -------------------------------- | ---------------------------------------------------------------- |
192
- | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
195
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
193
196
 
194
197
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
195
198
 
@@ -252,6 +255,14 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
252
255
  handles token acquisition and refresh automatically — you only need to supply a
253
256
  valid ARL value.
254
257
 
258
+ An external `aiohttp.ClientSession` may be shared by clients for different Deezer
259
+ accounts. Each client keeps Deezer's response cookies locally and masks cookies from
260
+ the shared session jar, so authentication state cannot cross between those accounts.
261
+
262
+ If the ARL is rejected (bad or expired), the client raises
263
+ `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
264
+ Transient server errors raise `GraphQLClientHttpError` instead.
265
+
255
266
  ## License
256
267
 
257
268
  Apache-2.0
@@ -19,6 +19,7 @@ import asyncio
19
19
 
20
20
  from deezer_python_gql import DeezerGQLClient
21
21
 
22
+
22
23
  async def main():
23
24
  client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
24
25
 
@@ -46,6 +47,7 @@ async def main():
46
47
  results = await client.search(query="Daft Punk")
47
48
  print(len(results.tracks.edges), "tracks found")
48
49
 
50
+
49
51
  asyncio.run(main())
50
52
  ```
51
53
 
@@ -161,7 +163,7 @@ asyncio.run(main())
161
163
 
162
164
  | Method | Description |
163
165
  | -------------------------------- | ---------------------------------------------------------------- |
164
- | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
166
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
165
167
 
166
168
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
167
169
 
@@ -224,6 +226,14 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
224
226
  handles token acquisition and refresh automatically — you only need to supply a
225
227
  valid ARL value.
226
228
 
229
+ An external `aiohttp.ClientSession` may be shared by clients for different Deezer
230
+ accounts. Each client keeps Deezer's response cookies locally and masks cookies from
231
+ the shared session jar, so authentication state cannot cross between those accounts.
232
+
233
+ If the ARL is rejected (bad or expired), the client raises
234
+ `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
235
+ Transient server errors raise `GraphQLClientHttpError` instead.
236
+
227
237
  ## License
228
238
 
229
239
  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,14 +2,19 @@
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
8
9
  from base64 import urlsafe_b64decode
9
10
  from dataclasses import dataclass
10
- from typing import Any, ClassVar, Self, cast
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, cast
11
12
 
12
13
  from aiohttp import ClientSession, ClientTimeout
14
+ from yarl import URL
15
+
16
+ if TYPE_CHECKING:
17
+ from aiohttp import ClientResponse
13
18
 
14
19
  logger = logging.getLogger(__name__)
15
20
 
@@ -21,17 +26,31 @@ class GQLResponse:
21
26
 
22
27
  aiohttp responses cannot escape their context manager, so we capture
23
28
  the essential fields inside the ``async with`` block and return this.
29
+ Carries the originating operation name and variables so error handling
30
+ and logging stay correct under concurrent requests.
24
31
  """
25
32
 
26
33
  status: int
27
34
  data: bytes
28
35
  is_success: bool
36
+ operation_name: str | None = None
37
+ variables: dict[str, Any] | None = None
29
38
 
30
39
 
31
40
  class GraphQLClientError(Exception):
32
41
  """Base exception for GraphQL client errors."""
33
42
 
34
43
 
44
+ class GraphQLClientAuthError(GraphQLClientError):
45
+ """
46
+ Raised when ARL → JWT authentication definitively fails.
47
+
48
+ Indicates a bad or expired ARL cookie (or an unexpected auth response),
49
+ not a transient network/server problem — consumers should treat this as
50
+ "re-check your ARL token" rather than retry.
51
+ """
52
+
53
+
35
54
  class GraphQLClientHttpError(GraphQLClientError):
36
55
  """Raised when the HTTP response indicates an error."""
37
56
 
@@ -109,6 +128,8 @@ class DeezerBaseClient:
109
128
  PIPE_URL = "https://pipe.deezer.com/api"
110
129
  AUTH_URL = "https://auth.deezer.com/login/arl"
111
130
  JWT_REFRESH_MARGIN_SECONDS = 30
131
+ REQUEST_TIMEOUT_SECONDS = 30
132
+ AUDIOBOOK_CHECK_CHUNK_SIZE = 50
112
133
 
113
134
  def __init__(
114
135
  self,
@@ -120,10 +141,12 @@ class DeezerBaseClient:
120
141
  self._arl = arl
121
142
  self._session = session
122
143
  self._owns_session = session is None
144
+ # An external session may be shared by clients for different Deezer accounts.
145
+ # Keep Deezer's response cookies per client instead of trusting that shared jar.
146
+ self._cookies: dict[str, str] = {}
123
147
  self._jwt: str | None = None
124
148
  self._jwt_expires_at: float = 0
125
- self._last_operation_name: str | None = None
126
- self._last_variables: dict[str, Any] | None = None
149
+ self._jwt_lock = asyncio.Lock()
127
150
 
128
151
  def _get_session(self) -> ClientSession:
129
152
  """Return the HTTP session, creating an internal one if needed."""
@@ -151,6 +174,21 @@ class DeezerBaseClient:
151
174
  """Exit the async context manager, closing internal resources."""
152
175
  await self.close()
153
176
 
177
+ def _request_cookies(self, url: str, *, include_arl: bool = False) -> dict[str, str]:
178
+ """Return account-local cookies while neutralizing a shared session's jar."""
179
+ session = self._get_session()
180
+ blanked = dict.fromkeys(session.cookie_jar.filter_cookies(URL(url)), "")
181
+ cookies = blanked | self._cookies
182
+ if include_arl:
183
+ cookies["arl"] = self._arl
184
+ return cookies
185
+
186
+ def _store_cookies(self, response: ClientResponse) -> None:
187
+ """Remember response cookies for this client instead of relying on the session jar."""
188
+ self._cookies.update(
189
+ {name: morsel.value for name, morsel in response.cookies.items() if name != "arl"}
190
+ )
191
+
154
192
  async def execute(
155
193
  self,
156
194
  query: str,
@@ -167,13 +205,14 @@ class DeezerBaseClient:
167
205
  :param kwargs: Additional keyword arguments passed to the session request.
168
206
  """
169
207
  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
208
  jwt = await self._ensure_jwt()
173
209
 
174
210
  headers: dict[str, str] = kwargs.pop("headers", None) or {}
175
211
  headers["Authorization"] = f"Bearer {jwt}"
176
212
  headers["Content-Type"] = "application/json"
213
+ kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
214
+ request_cookies = self._request_cookies(self.url)
215
+ request_cookies.update(kwargs.pop("cookies", None) or {})
177
216
 
178
217
  payload: dict[str, Any] = {"query": query}
179
218
  if operation_name:
@@ -188,12 +227,21 @@ class DeezerBaseClient:
188
227
  }
189
228
 
190
229
  session = self._get_session()
191
- async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
230
+ async with session.post(
231
+ self.url,
232
+ json=payload,
233
+ headers=headers,
234
+ cookies=request_cookies,
235
+ **kwargs,
236
+ ) as resp:
237
+ self._store_cookies(resp)
192
238
  body = await resp.read()
193
239
  gql_response = GQLResponse(
194
240
  status=resp.status,
195
241
  data=body,
196
242
  is_success=resp.ok,
243
+ operation_name=operation_name,
244
+ variables=variables,
197
245
  )
198
246
 
199
247
  logger.debug(
@@ -227,8 +275,8 @@ class DeezerBaseClient:
227
275
  errors = response_json.get("errors")
228
276
 
229
277
  if errors:
230
- op = self._last_operation_name or "<unknown>"
231
- variables = self._last_variables
278
+ op = response.operation_name or "<unknown>"
279
+ variables = response.variables
232
280
  if data:
233
281
  # Partial success — some items failed (e.g. deleted albums in favorites).
234
282
  # Log the errors but return the valid data.
@@ -280,65 +328,106 @@ class DeezerBaseClient:
280
328
  for item in obj:
281
329
  cls._inject_missing_typenames(item)
282
330
 
331
+ def _jwt_is_valid(self) -> bool:
332
+ """Return True if the current JWT exists and is not about to expire."""
333
+ return self._jwt is not None and time.time() < (
334
+ self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
335
+ )
336
+
283
337
  async def _ensure_jwt(self) -> str:
284
338
  """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):
339
+ if self._jwt is not None and self._jwt_is_valid():
287
340
  return self._jwt
288
341
 
289
- logger.debug("JWT expired or missing, refreshing from ARL")
290
- params = {"jo": "p", "rto": "c", "i": "c"}
291
-
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)
342
+ # Serialize refreshes so concurrent expired-JWT requests trigger
343
+ # exactly one auth call.
344
+ async with self._jwt_lock:
345
+ if self._jwt is not None and self._jwt_is_valid():
346
+ return self._jwt
347
+
348
+ logger.debug("JWT expired or missing, refreshing from ARL")
349
+ params = {"jo": "p", "rto": "c", "i": "c"}
350
+
351
+ session = self._get_session()
352
+ async with session.post(
353
+ self.AUTH_URL,
354
+ params=params,
355
+ cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
356
+ timeout=ClientTimeout(total=10),
357
+ ) as resp:
358
+ self._store_cookies(resp)
359
+ status = resp.status
360
+ is_success = resp.ok
361
+ # Response body is text/plain containing JSON
362
+ text = await resp.text()
363
+
364
+ if not is_success:
365
+ if 400 <= status < 500:
366
+ msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
367
+ raise GraphQLClientAuthError(msg)
368
+ raise GraphQLClientHttpError(
369
+ status_code=status,
370
+ response=GQLResponse(status=status, data=text.encode(), is_success=False),
371
+ )
313
372
 
314
- return self._jwt
373
+ try:
374
+ data = json.loads(text)
375
+ jwt: str = data["jwt"]
376
+ # Decode expiration from JWT payload (second segment, base64url-encoded)
377
+ payload_segment = jwt.split(".")[1]
378
+ # Add padding for base64 decoding
379
+ padded = payload_segment + "=" * (-len(payload_segment) % 4)
380
+ payload = json.loads(urlsafe_b64decode(padded))
381
+ expires_at = float(payload["exp"])
382
+ except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
383
+ msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
384
+ raise GraphQLClientAuthError(msg) from exc
385
+
386
+ self._jwt = jwt
387
+ self._jwt_expires_at = expires_at
388
+ logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
389
+
390
+ return jwt
315
391
 
316
392
  async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
317
393
  """
318
394
  Check which album IDs are also valid audiobooks on Deezer.
319
395
 
396
+ Queries are chunked to stay below the API's query complexity limit.
397
+
320
398
  :param album_ids: List of Deezer album/audiobook IDs to check.
321
399
  """
322
- if not album_ids:
323
- return set()
400
+ audiobook_ids: set[str] = set()
401
+ for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
402
+ chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
403
+ audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
404
+ return audiobook_ids
324
405
 
406
+ async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
407
+ """Check a single chunk of album IDs via one aliased query."""
325
408
  # Query displayTitle alongside id — querying only { id } echoes back the
326
409
  # input without validating that the ID is actually an audiobook.
410
+ # json.dumps escapes the IDs so they cannot break out of the string
411
+ # literal (this is a public library API — IDs may come from anywhere).
327
412
  parts = [
328
- f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
413
+ f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
329
414
  for i, aid in enumerate(album_ids)
330
415
  ]
331
416
  query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
332
417
 
333
418
  resp = await self.execute(query, operation_name="CheckAudiobookIds")
334
- self._last_variables = {"album_ids": album_ids}
335
419
  try:
336
420
  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()
421
+ except GraphQLClientGraphQLMultiError as err:
422
+ # Only swallow errors scoped to our aliased audiobook fields —
423
+ # that is the legitimate "these IDs are not audiobooks" case.
424
+ # Anything else (complexity limit, transient API failure) must
425
+ # surface, otherwise audiobooks silently degrade to albums.
426
+ aliases = {f"a{i}" for i in range(len(album_ids))}
427
+ if all(e.path and e.path[0] in aliases for e in err.errors):
428
+ logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
429
+ return set()
430
+ raise
342
431
 
343
432
  audiobook_ids: set[str] = set()
344
433
  for i, aid in enumerate(album_ids):