deezer-python-gql 0.17.1__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {deezer_python_gql-0.17.1/deezer_python_gql.egg-info → deezer_python_gql-0.18.0}/PKG-INFO +12 -5
  2. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/README.md +6 -0
  3. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/base_client.py +35 -3
  4. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +1 -3
  5. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +1 -3
  6. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_client.py +35 -3
  7. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +2 -2
  8. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/client.py +1 -0
  9. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/fragments.py +4 -4
  10. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_audiobook_chapter.py +2 -2
  11. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_albums.py +2 -2
  12. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_artists.py +2 -2
  13. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +2 -2
  14. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_playlists.py +2 -2
  15. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_podcasts.py +2 -2
  16. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_tracks.py +2 -2
  17. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_group.py +4 -4
  18. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_groups.py +4 -4
  19. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_playlist.py +1 -0
  20. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +2 -2
  21. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track.py +3 -3
  22. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +2 -2
  23. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_create_group.py +2 -2
  24. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/search.py +2 -2
  25. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0/deezer_python_gql.egg-info}/PKG-INFO +12 -5
  26. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/requires.txt +5 -4
  27. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/pyproject.toml +10 -5
  28. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/tests/test_client.py +135 -10
  29. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/LICENSE +0 -0
  30. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/MANIFEST.in +0 -0
  31. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/__init__.py +0 -0
  32. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/__init__.py +0 -0
  33. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  34. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  35. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  36. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  37. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  38. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_model.py +0 -0
  39. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/create_playlist.py +0 -0
  40. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
  41. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/enums.py +0 -0
  42. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_album.py +0 -0
  43. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist.py +0 -0
  44. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  45. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
  46. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_charts.py +0 -0
  47. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow.py +0 -0
  48. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  49. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  50. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  51. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_livestream.py +0 -0
  52. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  53. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_me.py +0 -0
  54. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  55. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  56. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast.py +0 -0
  57. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  58. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  59. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
  60. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
  61. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  62. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  63. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  64. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
  65. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
  66. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  67. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/input_types.py +0 -0
  68. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  69. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  70. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  71. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  72. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  73. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  74. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  75. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  76. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  77. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  78. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  79. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  80. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  81. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/search_flows.py +0 -0
  82. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  83. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/update_playlist.py +0 -0
  84. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/py.typed +0 -0
  85. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  86. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  87. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  88. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
  89. {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.1
3
+ Version: 0.18.0
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
22
  Requires-Dist: pytest==9.1.1; extra == "test"
22
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.20; extra == "test"
25
+ Requires-Dist: ruff==0.16.8; 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
 
@@ -252,6 +255,10 @@ 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
+
255
262
  If the ARL is rejected (bad or expired), the client raises
256
263
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
264
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -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
 
@@ -224,6 +226,10 @@ 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
+
227
233
  If the ARL is rejected (bad or expired), the client raises
228
234
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
229
235
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -8,9 +8,13 @@ import logging
8
8
  import time
9
9
  from base64 import urlsafe_b64decode
10
10
  from dataclasses import dataclass
11
- from typing import Any, ClassVar, Self, cast
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, cast
12
12
 
13
13
  from aiohttp import ClientSession, ClientTimeout
14
+ from yarl import URL
15
+
16
+ if TYPE_CHECKING:
17
+ from aiohttp import ClientResponse
14
18
 
15
19
  logger = logging.getLogger(__name__)
16
20
 
@@ -137,6 +141,9 @@ class DeezerBaseClient:
137
141
  self._arl = arl
138
142
  self._session = session
139
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] = {}
140
147
  self._jwt: str | None = None
141
148
  self._jwt_expires_at: float = 0
142
149
  self._jwt_lock = asyncio.Lock()
@@ -167,6 +174,21 @@ class DeezerBaseClient:
167
174
  """Exit the async context manager, closing internal resources."""
168
175
  await self.close()
169
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
+
170
192
  async def execute(
171
193
  self,
172
194
  query: str,
@@ -189,6 +211,8 @@ class DeezerBaseClient:
189
211
  headers["Authorization"] = f"Bearer {jwt}"
190
212
  headers["Content-Type"] = "application/json"
191
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 {})
192
216
 
193
217
  payload: dict[str, Any] = {"query": query}
194
218
  if operation_name:
@@ -203,7 +227,14 @@ class DeezerBaseClient:
203
227
  }
204
228
 
205
229
  session = self._get_session()
206
- 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)
207
238
  body = await resp.read()
208
239
  gql_response = GQLResponse(
209
240
  status=resp.status,
@@ -321,9 +352,10 @@ class DeezerBaseClient:
321
352
  async with session.post(
322
353
  self.AUTH_URL,
323
354
  params=params,
324
- cookies={"arl": self._arl},
355
+ cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
325
356
  timeout=ClientTimeout(total=10),
326
357
  ) as resp:
358
+ self._store_cookies(resp)
327
359
  status = resp.status
328
360
  is_success = resp.ok
329
361
  # Response body is text/plain containing JSON
@@ -1,8 +1,6 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any
5
-
6
4
  from pydantic import Field
7
5
 
8
6
  from .base_model import BaseModel
@@ -16,7 +14,7 @@ class AddAudiobookToFavorite(BaseModel):
16
14
 
17
15
  class AddAudiobookToFavoriteAddAudiobookToFavorite(BaseModel):
18
16
  id: str
19
- favorited_at: Any = Field(alias="favoritedAt")
17
+ favorited_at: str = Field(alias="favoritedAt")
20
18
 
21
19
 
22
20
  AddAudiobookToFavorite.model_rebuild()
@@ -1,8 +1,6 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any
5
-
6
4
  from pydantic import Field
7
5
 
8
6
  from .base_model import BaseModel
@@ -16,7 +14,7 @@ class AddPodcastToFavorite(BaseModel):
16
14
 
17
15
  class AddPodcastToFavoriteAddPodcastToFavorite(BaseModel):
18
16
  podcast: "AddPodcastToFavoriteAddPodcastToFavoritePodcast"
19
- favorited_at: Any = Field(alias="favoritedAt")
17
+ favorited_at: str = Field(alias="favoritedAt")
20
18
 
21
19
 
22
20
  class AddPodcastToFavoriteAddPodcastToFavoritePodcast(BaseModel):
@@ -10,9 +10,13 @@ import logging
10
10
  import time
11
11
  from base64 import urlsafe_b64decode
12
12
  from dataclasses import dataclass
13
- from typing import Any, ClassVar, Self, cast
13
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, cast
14
14
 
15
15
  from aiohttp import ClientSession, ClientTimeout
16
+ from yarl import URL
17
+
18
+ if TYPE_CHECKING:
19
+ from aiohttp import ClientResponse
16
20
 
17
21
  logger = logging.getLogger(__name__)
18
22
 
@@ -139,6 +143,9 @@ class DeezerBaseClient:
139
143
  self._arl = arl
140
144
  self._session = session
141
145
  self._owns_session = session is None
146
+ # An external session may be shared by clients for different Deezer accounts.
147
+ # Keep Deezer's response cookies per client instead of trusting that shared jar.
148
+ self._cookies: dict[str, str] = {}
142
149
  self._jwt: str | None = None
143
150
  self._jwt_expires_at: float = 0
144
151
  self._jwt_lock = asyncio.Lock()
@@ -169,6 +176,21 @@ class DeezerBaseClient:
169
176
  """Exit the async context manager, closing internal resources."""
170
177
  await self.close()
171
178
 
179
+ def _request_cookies(self, url: str, *, include_arl: bool = False) -> dict[str, str]:
180
+ """Return account-local cookies while neutralizing a shared session's jar."""
181
+ session = self._get_session()
182
+ blanked = dict.fromkeys(session.cookie_jar.filter_cookies(URL(url)), "")
183
+ cookies = blanked | self._cookies
184
+ if include_arl:
185
+ cookies["arl"] = self._arl
186
+ return cookies
187
+
188
+ def _store_cookies(self, response: ClientResponse) -> None:
189
+ """Remember response cookies for this client instead of relying on the session jar."""
190
+ self._cookies.update(
191
+ {name: morsel.value for name, morsel in response.cookies.items() if name != "arl"}
192
+ )
193
+
172
194
  async def execute(
173
195
  self,
174
196
  query: str,
@@ -191,6 +213,8 @@ class DeezerBaseClient:
191
213
  headers["Authorization"] = f"Bearer {jwt}"
192
214
  headers["Content-Type"] = "application/json"
193
215
  kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
216
+ request_cookies = self._request_cookies(self.url)
217
+ request_cookies.update(kwargs.pop("cookies", None) or {})
194
218
 
195
219
  payload: dict[str, Any] = {"query": query}
196
220
  if operation_name:
@@ -205,7 +229,14 @@ class DeezerBaseClient:
205
229
  }
206
230
 
207
231
  session = self._get_session()
208
- async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
232
+ async with session.post(
233
+ self.url,
234
+ json=payload,
235
+ headers=headers,
236
+ cookies=request_cookies,
237
+ **kwargs,
238
+ ) as resp:
239
+ self._store_cookies(resp)
209
240
  body = await resp.read()
210
241
  gql_response = GQLResponse(
211
242
  status=resp.status,
@@ -323,9 +354,10 @@ class DeezerBaseClient:
323
354
  async with session.post(
324
355
  self.AUTH_URL,
325
356
  params=params,
326
- cookies={"arl": self._arl},
357
+ cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
327
358
  timeout=ClientTimeout(total=10),
328
359
  ) as resp:
360
+ self._store_cookies(resp)
329
361
  status = resp.status
330
362
  is_success = resp.ok
331
363
  # Response body is text/plain containing JSON
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -22,7 +22,7 @@ class BookmarkPodcastEpisodeBookmarkPodcastEpisode(BaseModel):
22
22
  class BookmarkPodcastEpisodeBookmarkPodcastEpisodeBookmark(BaseModel):
23
23
  position: int
24
24
  is_played: bool = Field(alias="isPlayed")
25
- bookmarked_at: Any = Field(alias="bookmarkedAt")
25
+ bookmarked_at: str = Field(alias="bookmarkedAt")
26
26
 
27
27
 
28
28
  BookmarkPodcastEpisode.model_rebuild()
@@ -2414,6 +2414,7 @@ class DeezerGQLClient(DeezerBaseClient):
2414
2414
  tracks(first: $tracksFirst, after: $tracksAfter) {
2415
2415
  edges {
2416
2416
  cursor
2417
+ addedAt
2417
2418
  node {
2418
2419
  ...TrackFields
2419
2420
  }
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Literal, Optional, Union
4
+ from typing import Literal, Optional, Union
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -20,7 +20,7 @@ class AlbumFields(BaseModel):
20
20
  type_: AlbumType = Field(alias="type")
21
21
  cover: Optional["AlbumFieldsCover"]
22
22
  contributors: "AlbumFieldsContributors"
23
- release_date: Optional[Any] = Field(alias="releaseDate")
23
+ release_date: Optional[str] = Field(alias="releaseDate")
24
24
  is_explicit: Optional[bool] = Field(alias="isExplicit")
25
25
  is_favorite: Optional[bool] = Field(alias="isFavorite")
26
26
  fans_count: int = Field(alias="fansCount")
@@ -88,7 +88,7 @@ class AudiobookFields(BaseModel):
88
88
  cover: Optional["AudiobookFieldsCover"]
89
89
  description: Optional[str]
90
90
  duration: int
91
- release_date: Optional[Any] = Field(alias="releaseDate")
91
+ release_date: Optional[str] = Field(alias="releaseDate")
92
92
  fans_count: int = Field(alias="fansCount")
93
93
  is_explicit: bool = Field(alias="isExplicit")
94
94
  is_favorite: Optional[bool] = Field(alias="isFavorite")
@@ -179,7 +179,7 @@ class PodcastEpisodeFields(BaseModel):
179
179
  description: Optional[str]
180
180
  duration: int
181
181
  cover: Optional["PodcastEpisodeFieldsCover"]
182
- publication_date: Optional[Any] = Field(alias="publicationDate")
182
+ publication_date: Optional[str] = Field(alias="publicationDate")
183
183
  media: "PodcastEpisodeFieldsMedia"
184
184
 
185
185
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -37,7 +37,7 @@ class GetAudiobookChapterAudiobookChapterMedia(BaseModel):
37
37
 
38
38
  class GetAudiobookChapterAudiobookChapterMediaToken(BaseModel):
39
39
  payload: str
40
- expires_at: Any = Field(alias="expiresAt")
40
+ expires_at: str = Field(alias="expiresAt")
41
41
 
42
42
 
43
43
  class GetAudiobookChapterAudiobookChapterMediaEstimatedSizes(BaseModel):
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class GetFavoriteAlbumsMeUserFavoritesAlbums(BaseModel):
30
30
 
31
31
  class GetFavoriteAlbumsMeUserFavoritesAlbumsEdges(BaseModel):
32
32
  cursor: str
33
- favorited_at: Any = Field(alias="favoritedAt")
33
+ favorited_at: str = Field(alias="favoritedAt")
34
34
  node: Optional["GetFavoriteAlbumsMeUserFavoritesAlbumsEdgesNode"]
35
35
 
36
36
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class GetFavoriteArtistsMeUserFavoritesArtists(BaseModel):
30
30
 
31
31
  class GetFavoriteArtistsMeUserFavoritesArtistsEdges(BaseModel):
32
32
  cursor: str
33
- favorited_at: Any = Field(alias="favoritedAt")
33
+ favorited_at: str = Field(alias="favoritedAt")
34
34
  node: Optional["GetFavoriteArtistsMeUserFavoritesArtistsEdgesNode"]
35
35
 
36
36
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -24,7 +24,7 @@ class GetFavoriteAudiobooksMeFavorites(BaseModel):
24
24
 
25
25
  class GetFavoriteAudiobooksMeFavoritesRawAudiobooks(BaseModel):
26
26
  id: str
27
- favorited_at: Any = Field(alias="favoritedAt")
27
+ favorited_at: str = Field(alias="favoritedAt")
28
28
 
29
29
 
30
30
  GetFavoriteAudiobooks.model_rebuild()
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class GetFavoritePlaylistsMeUserFavoritesPlaylists(BaseModel):
30
30
 
31
31
  class GetFavoritePlaylistsMeUserFavoritesPlaylistsEdges(BaseModel):
32
32
  cursor: str
33
- favorited_at: Any = Field(alias="favoritedAt")
33
+ favorited_at: str = Field(alias="favoritedAt")
34
34
  node: Optional["GetFavoritePlaylistsMeUserFavoritesPlaylistsEdgesNode"]
35
35
 
36
36
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class GetFavoritePodcastsMeUserFavoritesPodcasts(BaseModel):
30
30
 
31
31
  class GetFavoritePodcastsMeUserFavoritesPodcastsEdges(BaseModel):
32
32
  cursor: str
33
- favorited_at: Any = Field(alias="favoritedAt")
33
+ favorited_at: str = Field(alias="favoritedAt")
34
34
  node: Optional["GetFavoritePodcastsMeUserFavoritesPodcastsEdgesNode"]
35
35
 
36
36
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class GetFavoriteTracksMeUserFavoritesTracks(BaseModel):
30
30
 
31
31
  class GetFavoriteTracksMeUserFavoritesTracksEdges(BaseModel):
32
32
  cursor: str
33
- favorited_at: Any = Field(alias="favoritedAt")
33
+ favorited_at: str = Field(alias="favoritedAt")
34
34
  node: Optional["GetFavoriteTracksMeUserFavoritesTracksEdgesNode"]
35
35
 
36
36
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -21,8 +21,8 @@ class GetMusicTogetherGroupMusicTogetherGroup(BaseModel):
21
21
  is_ready: bool = Field(alias="isReady")
22
22
  is_family: bool = Field(alias="isFamily")
23
23
  estimated_members_count: int = Field(alias="estimatedMembersCount")
24
- created_at: Any = Field(alias="createdAt")
25
- updated_at: Any = Field(alias="updatedAt")
24
+ created_at: str = Field(alias="createdAt")
25
+ updated_at: str = Field(alias="updatedAt")
26
26
  members: "GetMusicTogetherGroupMusicTogetherGroupMembers"
27
27
  suggested_tracklist: Optional[
28
28
  "GetMusicTogetherGroupMusicTogetherGroupSuggestedTracklist"
@@ -41,7 +41,7 @@ class GetMusicTogetherGroupMusicTogetherGroupMembers(BaseModel):
41
41
 
42
42
  class GetMusicTogetherGroupMusicTogetherGroupMembersEdges(BaseModel):
43
43
  cursor: str
44
- joined_at: Any = Field(alias="joinedAt")
44
+ joined_at: str = Field(alias="joinedAt")
45
45
  node: Optional["GetMusicTogetherGroupMusicTogetherGroupMembersEdgesNode"]
46
46
  affinity: Optional["GetMusicTogetherGroupMusicTogetherGroupMembersEdgesAffinity"]
47
47
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -38,8 +38,8 @@ class GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNode(BaseModel):
38
38
  is_ready: bool = Field(alias="isReady")
39
39
  is_family: bool = Field(alias="isFamily")
40
40
  estimated_members_count: int = Field(alias="estimatedMembersCount")
41
- created_at: Any = Field(alias="createdAt")
42
- updated_at: Any = Field(alias="updatedAt")
41
+ created_at: str = Field(alias="createdAt")
42
+ updated_at: str = Field(alias="updatedAt")
43
43
  members: "GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNodeMembers"
44
44
 
45
45
 
@@ -52,7 +52,7 @@ class GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNodeMembers(BaseModel):
52
52
 
53
53
  class GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNodeMembersEdges(BaseModel):
54
54
  cursor: str
55
- joined_at: Any = Field(alias="joinedAt")
55
+ joined_at: str = Field(alias="joinedAt")
56
56
  node: Optional[
57
57
  "GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNodeMembersEdgesNode"
58
58
  ]
@@ -27,6 +27,7 @@ class GetPlaylistPlaylistTracks(BaseModel):
27
27
 
28
28
  class GetPlaylistPlaylistTracksEdges(BaseModel):
29
29
  cursor: str
30
+ added_at: str = Field(alias="addedAt")
30
31
  node: Optional["GetPlaylistPlaylistTracksEdgesNode"]
31
32
 
32
33
 
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -35,7 +35,7 @@ class GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksEdgesNode(BaseModel):
35
35
  episode: "GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksEdgesNodeEpisode"
36
36
  position: int
37
37
  is_played: bool = Field(alias="isPlayed")
38
- bookmarked_at: Any = Field(alias="bookmarkedAt")
38
+ bookmarked_at: str = Field(alias="bookmarkedAt")
39
39
 
40
40
 
41
41
  class GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksEdgesNodeEpisode(
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Literal, Optional, Union
4
+ from typing import Literal, Optional, Union
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -25,7 +25,7 @@ class GetTrackTrack(BaseModel):
25
25
  album: Optional["GetTrackTrackAlbum"]
26
26
  contributors: "GetTrackTrackContributors"
27
27
  is_atmos: bool = Field(alias="isAtmos")
28
- release_date: Optional[Any] = Field(alias="releaseDate")
28
+ release_date: Optional[str] = Field(alias="releaseDate")
29
29
  media: Optional["GetTrackTrackMedia"]
30
30
  lyrics: Optional["GetTrackTrackLyrics"]
31
31
 
@@ -73,7 +73,7 @@ class GetTrackTrackMedia(BaseModel):
73
73
 
74
74
  class GetTrackTrackMediaToken(BaseModel):
75
75
  payload: str
76
- expires_at: Any = Field(alias="expiresAt")
76
+ expires_at: str = Field(alias="expiresAt")
77
77
 
78
78
 
79
79
  class GetTrackTrackMediaEstimatedSizes(BaseModel):
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Optional
4
+ from typing import Optional
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -22,7 +22,7 @@ class MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisode(BaseModel):
22
22
  class MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisodeBookmark(BaseModel):
23
23
  position: int
24
24
  is_played: bool = Field(alias="isPlayed")
25
- bookmarked_at: Any = Field(alias="bookmarkedAt")
25
+ bookmarked_at: str = Field(alias="bookmarkedAt")
26
26
 
27
27
 
28
28
  MarkAsPlayedPodcastEpisode.model_rebuild()
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Literal, Optional, Union
4
+ from typing import Literal, Optional, Union
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -30,7 +30,7 @@ class MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupOu
30
30
  is_ready: bool = Field(alias="isReady")
31
31
  is_family: bool = Field(alias="isFamily")
32
32
  estimated_members_count: int = Field(alias="estimatedMembersCount")
33
- created_at: Any = Field(alias="createdAt")
33
+ created_at: str = Field(alias="createdAt")
34
34
 
35
35
 
36
36
  class MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupError(
@@ -1,7 +1,7 @@
1
1
  # Generated by ariadne-codegen
2
2
  # Source: queries
3
3
 
4
- from typing import Any, Literal, Optional, Union
4
+ from typing import Literal, Optional, Union
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -108,7 +108,7 @@ class SearchSearchResultsAlbumsEdgesNode(BaseModel):
108
108
  type_: AlbumType = Field(alias="type")
109
109
  cover: Optional["SearchSearchResultsAlbumsEdgesNodeCover"]
110
110
  contributors: "SearchSearchResultsAlbumsEdgesNodeContributors"
111
- release_date: Optional[Any] = Field(alias="releaseDate")
111
+ release_date: Optional[str] = Field(alias="releaseDate")
112
112
  is_explicit: Optional[bool] = Field(alias="isExplicit")
113
113
  is_favorite: Optional[bool] = Field(alias="isFavorite")
114
114
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.1
3
+ Version: 0.18.0
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
22
  Requires-Dist: pytest==9.1.1; extra == "test"
22
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.20; extra == "test"
25
+ Requires-Dist: ruff==0.16.8; 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
 
@@ -252,6 +255,10 @@ 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
+
255
262
  If the ARL is rejected (bad or expired), the client raises
256
263
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
264
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -1,15 +1,16 @@
1
1
  aiohttp>=3.13.5
2
2
  pydantic>=2.0.0
3
+ yarl>=1.22.0
3
4
 
4
5
  [dev]
5
6
  ariadne-codegen[subscriptions]
6
7
 
7
8
  [test]
8
- codespell==2.4.2
9
- mypy==2.1.0
10
- pre-commit==4.6.0
9
+ codespell==2.4.3
10
+ mypy==2.3.1
11
+ pre-commit==4.6.2
11
12
  pre-commit-hooks==6.0.0
12
13
  pytest==9.1.1
13
14
  pytest-asyncio==1.4.0
14
15
  pytest-cov==7.1.0
15
- ruff==0.15.20
16
+ ruff==0.16.8
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.17.1"
3
+ version = "0.18.0"
4
4
  description = "Async typed Python client for Deezer's Pipe GraphQL API."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -15,6 +15,7 @@ classifiers = [
15
15
  dependencies = [
16
16
  "aiohttp>=3.13.5",
17
17
  "pydantic>=2.0.0",
18
+ "yarl>=1.22.0",
18
19
  ]
19
20
 
20
21
  [project.license]
@@ -22,14 +23,14 @@ text = "Apache-2.0"
22
23
 
23
24
  [project.optional-dependencies]
24
25
  test = [
25
- "codespell==2.4.2",
26
- "mypy==2.1.0",
27
- "pre-commit==4.6.0",
26
+ "codespell==2.4.3",
27
+ "mypy==2.3.1",
28
+ "pre-commit==4.6.2",
28
29
  "pre-commit-hooks==6.0.0",
29
30
  "pytest==9.1.1",
30
31
  "pytest-asyncio==1.4.0",
31
32
  "pytest-cov==7.1.0",
32
- "ruff==0.15.20",
33
+ "ruff==0.16.8",
33
34
  ]
34
35
  dev = [
35
36
  "ariadne-codegen[subscriptions]",
@@ -67,6 +68,9 @@ plugins = [
67
68
  "ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin",
68
69
  ]
69
70
 
71
+ [tool.ariadne-codegen.scalars.Date]
72
+ type = "str"
73
+
70
74
  [tool.codespell]
71
75
  skip = "*.json,schema.graphql"
72
76
 
@@ -99,6 +103,7 @@ ignore = [
99
103
  "TD004",
100
104
  "TRY003",
101
105
  "COM812",
106
+ "CPY001",
102
107
  "ISC001",
103
108
  ]
104
109
  select = [
@@ -13,11 +13,15 @@ import asyncio
13
13
  import base64
14
14
  import json
15
15
  import time
16
+ from http.cookies import SimpleCookie
16
17
  from pathlib import Path
17
- from typing import Any
18
+ from typing import Any, Self, cast
18
19
  from unittest.mock import AsyncMock, MagicMock, patch
19
20
 
20
21
  import pytest
22
+ from aiohttp import ClientSession, CookieJar
23
+ from pydantic import ValidationError
24
+ from yarl import URL
21
25
 
22
26
  from deezer_python_gql import DeezerGQLClient
23
27
  from deezer_python_gql.base_client import (
@@ -124,7 +128,7 @@ def _load_fixture(name: str) -> dict[str, Any]:
124
128
  return result
125
129
 
126
130
 
127
- def _make_jwt(exp: float | None = None) -> str:
131
+ def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
128
132
  """
129
133
  Build a fake JWT with a configurable expiration timestamp.
130
134
 
@@ -133,13 +137,10 @@ def _make_jwt(exp: float | None = None) -> str:
133
137
  if exp is None:
134
138
  exp = time.time() + 360 # 6 min, matching Deezer's real TTL
135
139
  header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
136
- payload = (
137
- base64.urlsafe_b64encode(
138
- json.dumps({"exp": exp}).encode(),
139
- )
140
- .rstrip(b"=")
141
- .decode()
142
- )
140
+ payload_data: dict[str, float | str] = {"exp": exp}
141
+ if subject is not None:
142
+ payload_data["sub"] = subject
143
+ payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=").decode()
143
144
  return f"{header}.{payload}.fake_signature"
144
145
 
145
146
 
@@ -156,6 +157,7 @@ def _mock_post_context_manager(response_body: bytes, status: int = 200) -> Async
156
157
  mock_resp.read = AsyncMock(return_value=response_body)
157
158
  mock_resp.text = AsyncMock(return_value=response_body.decode())
158
159
  mock_resp.raise_for_status = MagicMock()
160
+ mock_resp.cookies = SimpleCookie()
159
161
 
160
162
  cm = AsyncMock()
161
163
  cm.__aenter__ = AsyncMock(return_value=mock_resp)
@@ -171,6 +173,86 @@ def _mock_auth_cm(jwt: str | None = None) -> AsyncMock:
171
173
  return _mock_post_context_manager(body)
172
174
 
173
175
 
176
+ class _FakeCookieResponse:
177
+ """Small aiohttp response stand-in for the shared-cookie regression test."""
178
+
179
+ def __init__(
180
+ self,
181
+ body: bytes,
182
+ response_cookies: SimpleCookie | None = None,
183
+ ) -> None:
184
+ self.status = 200
185
+ self.ok = True
186
+ self._body = body
187
+ self.cookies = response_cookies or SimpleCookie()
188
+
189
+ async def __aenter__(self) -> Self:
190
+ return self
191
+
192
+ async def __aexit__(self, *args: object) -> None:
193
+ return None
194
+
195
+ async def read(self) -> bytes:
196
+ return self._body
197
+
198
+ async def text(self) -> str:
199
+ return self._body.decode()
200
+
201
+
202
+ class _FakeSharedSession:
203
+ """Model Deezer preferring an existing refresh cookie over a new ARL."""
204
+
205
+ def __init__(self) -> None:
206
+ self.cookie_jar = CookieJar()
207
+ self._arl_accounts = {"arl-a": "1001", "arl-b": "2002"}
208
+ self._refresh_accounts = {"refresh-a": "1001", "refresh-b": "2002"}
209
+ self._account_refresh = {value: key for key, value in self._refresh_accounts.items()}
210
+ self._account_cookies = {
211
+ account: {
212
+ "refresh-token": refresh,
213
+ "sid": f"sid-{account}",
214
+ "dzr_uniq_id": f"unique-{account}",
215
+ "_abck": f"abck-{account}",
216
+ "bm_sz": f"bm-{account}",
217
+ }
218
+ for account, refresh in self._account_refresh.items()
219
+ }
220
+ self._account_jwt = {
221
+ account: _make_jwt(subject=account) for account in self._arl_accounts.values()
222
+ }
223
+ self._jwt_account = {jwt: account for account, jwt in self._account_jwt.items()}
224
+ self.requests: list[tuple[str, dict[str, str]]] = []
225
+
226
+ def post(self, url: str, **kwargs: Any) -> _FakeCookieResponse:
227
+ """Resolve the request identity and update the shared response-cookie jar."""
228
+ request_cookies = {
229
+ name: morsel.value for name, morsel in self.cookie_jar.filter_cookies(URL(url)).items()
230
+ }
231
+ request_cookies.update(kwargs.get("cookies") or {})
232
+ self.requests.append((url, request_cookies))
233
+
234
+ if url == DeezerBaseClient.AUTH_URL:
235
+ if refresh := request_cookies.get("refresh-token"):
236
+ account = self._refresh_accounts[refresh]
237
+ else:
238
+ account = self._arl_accounts[request_cookies["arl"]]
239
+ response_cookies = SimpleCookie()
240
+ for name, value in self._account_cookies[account].items():
241
+ response_cookies[name] = value
242
+ response_cookies[name]["domain"] = ".deezer.com"
243
+ response_cookies[name]["path"] = "/"
244
+ self.cookie_jar.update_cookies(response_cookies, response_url=URL(url))
245
+ return _FakeCookieResponse(
246
+ json.dumps({"jwt": self._account_jwt[account]}).encode(),
247
+ response_cookies,
248
+ )
249
+
250
+ authorization = cast("dict[str, str]", kwargs["headers"])["Authorization"]
251
+ jwt = authorization.removeprefix("Bearer ")
252
+ account = self._jwt_account[jwt]
253
+ return _FakeCookieResponse(json.dumps({"data": {"me": {"id": account}}}).encode())
254
+
255
+
174
256
  # ---------------------------------------------------------------------------
175
257
  # 1. Client setup
176
258
  # ---------------------------------------------------------------------------
@@ -413,6 +495,40 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
413
495
  assert auth_call.kwargs["cookies"] == {"arl": "my_secret_arl"}
414
496
 
415
497
 
498
+ @pytest.mark.parametrize(("first_arl", "second_arl"), [("arl-a", "arl-b"), ("arl-b", "arl-a")])
499
+ @pytest.mark.asyncio
500
+ async def test_shared_session_keeps_account_cookies_isolated(
501
+ first_arl: str, second_arl: str
502
+ ) -> None:
503
+ """Verify a shared jar cannot make the first authenticated account win."""
504
+ session = _FakeSharedSession()
505
+ expected = session._arl_accounts # noqa: SLF001
506
+ first = DeezerGQLClient(arl=first_arl, session=cast("ClientSession", session))
507
+ second = DeezerGQLClient(arl=second_arl, session=cast("ClientSession", session))
508
+
509
+ first_me = await first.get_me()
510
+ second_me = await second.get_me()
511
+
512
+ assert first_me is not None
513
+ assert second_me is not None
514
+ assert first_me.id == expected[first_arl]
515
+ assert second_me.id == expected[second_arl]
516
+
517
+ auth_requests = [cookies for url, cookies in session.requests if url == first.AUTH_URL]
518
+ pipe_requests = [cookies for url, cookies in session.requests if url == first.url]
519
+ assert auth_requests == [
520
+ {"arl": first_arl},
521
+ {
522
+ **dict.fromkeys(session._account_cookies[first_me.id], ""), # noqa: SLF001
523
+ "arl": second_arl,
524
+ },
525
+ ]
526
+ assert pipe_requests == [
527
+ session._account_cookies[first_me.id], # noqa: SLF001
528
+ session._account_cookies[second_me.id], # noqa: SLF001
529
+ ]
530
+
531
+
416
532
  @pytest.mark.asyncio
417
533
  async def test_auth_parses_text_plain_response() -> None:
418
534
  """Verify the client handles auth.deezer.com's text/plain JSON response."""
@@ -845,7 +961,7 @@ def test_smoke_get_artist() -> None:
845
961
 
846
962
 
847
963
  def test_smoke_get_playlist() -> None:
848
- """Verify GetPlaylist fixture parses with owner and paginated tracks."""
964
+ """Verify GetPlaylist fixture parses with owner, paginated tracks and add dates."""
849
965
  data = _load_fixture("get_playlist.json")
850
966
  playlist = GetPlaylist.model_validate(data).playlist
851
967
  assert playlist is not None
@@ -853,6 +969,7 @@ def test_smoke_get_playlist() -> None:
853
969
  assert playlist.title
854
970
  assert playlist.owner is not None
855
971
  assert len(playlist.tracks.edges) > 0
972
+ assert playlist.tracks.edges[0].added_at == "2026-09-18T07:30:12.000Z"
856
973
 
857
974
 
858
975
  def test_smoke_search() -> None:
@@ -1077,6 +1194,14 @@ def test_smoke_get_favorite_tracks() -> None:
1077
1194
  assert tracks.page_info.has_next_page is True
1078
1195
 
1079
1196
 
1197
+ def test_date_scalar_rejects_non_strings() -> None:
1198
+ """Date scalar fields are typed as ISO 8601 strings, so other values fail validation."""
1199
+ data = _load_fixture("get_favorite_tracks.json")
1200
+ data["me"]["userFavorites"]["tracks"]["edges"][0]["favoritedAt"] = 1749945600
1201
+ with pytest.raises(ValidationError, match="favoritedAt"):
1202
+ GetFavoriteTracks.model_validate(data)
1203
+
1204
+
1080
1205
  def test_smoke_get_favorite_playlists() -> None:
1081
1206
  """Verify GetFavoritePlaylists fixture parses with pagination."""
1082
1207
  data = _load_fixture("get_favorite_playlists.json")