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.
- {deezer_python_gql-0.17.1/deezer_python_gql.egg-info → deezer_python_gql-0.18.0}/PKG-INFO +12 -5
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/README.md +6 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/base_client.py +35 -3
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +1 -3
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +1 -3
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_client.py +35 -3
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/client.py +1 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/fragments.py +4 -4
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_audiobook_chapter.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_albums.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_artists.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_playlists.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_podcasts.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_favorite_tracks.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_group.py +4 -4
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_groups.py +4 -4
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_playlist.py +1 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track.py +3 -3
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_create_group.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/search.py +2 -2
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0/deezer_python_gql.egg-info}/PKG-INFO +12 -5
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/requires.txt +5 -4
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/pyproject.toml +10 -5
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/tests/test_client.py +135 -10
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/LICENSE +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/MANIFEST.in +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/__init__.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/__init__.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_model.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/create_playlist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/enums.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_album.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_charts.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_livestream.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_me.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/input_types.py +0 -0
- {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
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/search_flows.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/update_playlist.py +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
18
|
-
Requires-Dist: mypy==2.1
|
|
19
|
-
Requires-Dist: pre-commit==4.6.
|
|
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.
|
|
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(
|
|
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=
|
|
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:
|
|
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:
|
|
17
|
+
favorited_at: str = Field(alias="favoritedAt")
|
|
20
18
|
|
|
21
19
|
|
|
22
20
|
class AddPodcastToFavoriteAddPodcastToFavoritePodcast(BaseModel):
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_client.py
RENAMED
|
@@ -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(
|
|
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=
|
|
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
|
|
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:
|
|
25
|
+
bookmarked_at: str = Field(alias="bookmarkedAt")
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
BookmarkPodcastEpisode.model_rebuild()
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/fragments.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Generated by ariadne-codegen
|
|
2
2
|
# Source: queries
|
|
3
3
|
|
|
4
|
-
from typing import
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
25
|
-
updated_at:
|
|
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:
|
|
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
|
|
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:
|
|
42
|
-
updated_at:
|
|
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:
|
|
55
|
+
joined_at: str = Field(alias="joinedAt")
|
|
56
56
|
node: Optional[
|
|
57
57
|
"GetMusicTogetherGroupsMeMusicTogetherGroupsEdgesNodeMembersEdgesNode"
|
|
58
58
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Generated by ariadne-codegen
|
|
2
2
|
# Source: queries
|
|
3
3
|
|
|
4
|
-
from typing import
|
|
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:
|
|
38
|
+
bookmarked_at: str = Field(alias="bookmarkedAt")
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class GetPodcastEpisodeBookmarksMePodcastEpisodeBookmarksEdgesNodeEpisode(
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Generated by ariadne-codegen
|
|
2
2
|
# Source: queries
|
|
3
3
|
|
|
4
|
-
from typing import
|
|
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[
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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[
|
|
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.
|
|
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.
|
|
18
|
-
Requires-Dist: mypy==2.1
|
|
19
|
-
Requires-Dist: pre-commit==4.6.
|
|
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.
|
|
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.
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/requires.txt
RENAMED
|
@@ -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.
|
|
9
|
-
mypy==2.1
|
|
10
|
-
pre-commit==4.6.
|
|
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.
|
|
16
|
+
ruff==0.16.8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "deezer-python-gql"
|
|
3
|
-
version = "0.
|
|
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.
|
|
26
|
-
"mypy==2.1
|
|
27
|
-
"pre-commit==4.6.
|
|
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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/base_model.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/create_playlist.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/delete_playlist.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_album.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_artist_mix.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_audiobook.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_charts.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_flow_batch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_livestream.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_made_for_me.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_podcast.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_track_mix.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/get_user_charts.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/input_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/search_flows.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql/generated/update_playlist.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/not-zip-safe
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.1 → deezer_python_gql-0.18.0}/deezer_python_gql.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|