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