deezer-python-gql 0.16.0__tar.gz → 0.17.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {deezer_python_gql-0.16.0/deezer_python_gql.egg-info → deezer_python_gql-0.17.1}/PKG-INFO +9 -5
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/README.md +5 -1
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/__init__.py +2 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/base_client.py +99 -42
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py +6 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py +99 -42
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/client.py +7 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search.py +15 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1/deezer_python_gql.egg-info}/PKG-INFO +9 -5
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/requires.txt +3 -3
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/pyproject.toml +4 -4
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/tests/test_client.py +234 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/LICENSE +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/MANIFEST.in +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_model.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/create_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/delete_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/enums.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/fragments.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_album.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist_mix.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_charts.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_batch.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_configs.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_livestream.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_made_for_me.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_me.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_group.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recently_played.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recommendations.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_artists.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track_mix.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_charts.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_playlists.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/input_types.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_create_group.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_join_group.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search_flows.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/update_playlist.py +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Async typed Python client for Deezer's Pipe GraphQL API.
|
|
5
5
|
Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
|
|
|
18
18
|
Requires-Dist: mypy==2.1.0; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.6.0; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
|
-
Requires-Dist: pytest==9.
|
|
22
|
-
Requires-Dist: pytest-asyncio==1.
|
|
21
|
+
Requires-Dist: pytest==9.1.1; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
|
|
23
23
|
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
24
|
+
Requires-Dist: ruff==0.15.20; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -189,7 +189,7 @@ asyncio.run(main())
|
|
|
189
189
|
|
|
190
190
|
| Method | Description |
|
|
191
191
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
192
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
192
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
193
193
|
|
|
194
194
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
195
195
|
|
|
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
252
252
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
253
253
|
valid ARL value.
|
|
254
254
|
|
|
255
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
256
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
257
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
258
|
+
|
|
255
259
|
## License
|
|
256
260
|
|
|
257
261
|
Apache-2.0
|
|
@@ -161,7 +161,7 @@ asyncio.run(main())
|
|
|
161
161
|
|
|
162
162
|
| Method | Description |
|
|
163
163
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
164
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
164
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
165
165
|
|
|
166
166
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
167
167
|
|
|
@@ -224,6 +224,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
224
224
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
225
225
|
valid ARL value.
|
|
226
226
|
|
|
227
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
228
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
229
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
230
|
+
|
|
227
231
|
## License
|
|
228
232
|
|
|
229
233
|
Apache-2.0
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from deezer_python_gql.generated.base_client import (
|
|
4
4
|
GQLResponse,
|
|
5
|
+
GraphQLClientAuthError,
|
|
5
6
|
GraphQLClientError,
|
|
6
7
|
GraphQLClientGraphQLError,
|
|
7
8
|
GraphQLClientGraphQLMultiError,
|
|
@@ -13,6 +14,7 @@ from deezer_python_gql.generated.client import DeezerGQLClient
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"DeezerGQLClient",
|
|
15
16
|
"GQLResponse",
|
|
17
|
+
"GraphQLClientAuthError",
|
|
16
18
|
"GraphQLClientError",
|
|
17
19
|
"GraphQLClientGraphQLError",
|
|
18
20
|
"GraphQLClientGraphQLMultiError",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import time
|
|
@@ -21,17 +22,31 @@ class GQLResponse:
|
|
|
21
22
|
|
|
22
23
|
aiohttp responses cannot escape their context manager, so we capture
|
|
23
24
|
the essential fields inside the ``async with`` block and return this.
|
|
25
|
+
Carries the originating operation name and variables so error handling
|
|
26
|
+
and logging stay correct under concurrent requests.
|
|
24
27
|
"""
|
|
25
28
|
|
|
26
29
|
status: int
|
|
27
30
|
data: bytes
|
|
28
31
|
is_success: bool
|
|
32
|
+
operation_name: str | None = None
|
|
33
|
+
variables: dict[str, Any] | None = None
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
class GraphQLClientError(Exception):
|
|
32
37
|
"""Base exception for GraphQL client errors."""
|
|
33
38
|
|
|
34
39
|
|
|
40
|
+
class GraphQLClientAuthError(GraphQLClientError):
|
|
41
|
+
"""
|
|
42
|
+
Raised when ARL → JWT authentication definitively fails.
|
|
43
|
+
|
|
44
|
+
Indicates a bad or expired ARL cookie (or an unexpected auth response),
|
|
45
|
+
not a transient network/server problem — consumers should treat this as
|
|
46
|
+
"re-check your ARL token" rather than retry.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
35
50
|
class GraphQLClientHttpError(GraphQLClientError):
|
|
36
51
|
"""Raised when the HTTP response indicates an error."""
|
|
37
52
|
|
|
@@ -109,6 +124,8 @@ class DeezerBaseClient:
|
|
|
109
124
|
PIPE_URL = "https://pipe.deezer.com/api"
|
|
110
125
|
AUTH_URL = "https://auth.deezer.com/login/arl"
|
|
111
126
|
JWT_REFRESH_MARGIN_SECONDS = 30
|
|
127
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
128
|
+
AUDIOBOOK_CHECK_CHUNK_SIZE = 50
|
|
112
129
|
|
|
113
130
|
def __init__(
|
|
114
131
|
self,
|
|
@@ -122,8 +139,7 @@ class DeezerBaseClient:
|
|
|
122
139
|
self._owns_session = session is None
|
|
123
140
|
self._jwt: str | None = None
|
|
124
141
|
self._jwt_expires_at: float = 0
|
|
125
|
-
self.
|
|
126
|
-
self._last_variables: dict[str, Any] | None = None
|
|
142
|
+
self._jwt_lock = asyncio.Lock()
|
|
127
143
|
|
|
128
144
|
def _get_session(self) -> ClientSession:
|
|
129
145
|
"""Return the HTTP session, creating an internal one if needed."""
|
|
@@ -167,13 +183,12 @@ class DeezerBaseClient:
|
|
|
167
183
|
:param kwargs: Additional keyword arguments passed to the session request.
|
|
168
184
|
"""
|
|
169
185
|
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
170
|
-
self._last_operation_name = operation_name
|
|
171
|
-
self._last_variables = variables
|
|
172
186
|
jwt = await self._ensure_jwt()
|
|
173
187
|
|
|
174
188
|
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
175
189
|
headers["Authorization"] = f"Bearer {jwt}"
|
|
176
190
|
headers["Content-Type"] = "application/json"
|
|
191
|
+
kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
|
|
177
192
|
|
|
178
193
|
payload: dict[str, Any] = {"query": query}
|
|
179
194
|
if operation_name:
|
|
@@ -194,6 +209,8 @@ class DeezerBaseClient:
|
|
|
194
209
|
status=resp.status,
|
|
195
210
|
data=body,
|
|
196
211
|
is_success=resp.ok,
|
|
212
|
+
operation_name=operation_name,
|
|
213
|
+
variables=variables,
|
|
197
214
|
)
|
|
198
215
|
|
|
199
216
|
logger.debug(
|
|
@@ -227,8 +244,8 @@ class DeezerBaseClient:
|
|
|
227
244
|
errors = response_json.get("errors")
|
|
228
245
|
|
|
229
246
|
if errors:
|
|
230
|
-
op =
|
|
231
|
-
variables =
|
|
247
|
+
op = response.operation_name or "<unknown>"
|
|
248
|
+
variables = response.variables
|
|
232
249
|
if data:
|
|
233
250
|
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
234
251
|
# Log the errors but return the valid data.
|
|
@@ -280,65 +297,105 @@ class DeezerBaseClient:
|
|
|
280
297
|
for item in obj:
|
|
281
298
|
cls._inject_missing_typenames(item)
|
|
282
299
|
|
|
300
|
+
def _jwt_is_valid(self) -> bool:
|
|
301
|
+
"""Return True if the current JWT exists and is not about to expire."""
|
|
302
|
+
return self._jwt is not None and time.time() < (
|
|
303
|
+
self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
|
|
304
|
+
)
|
|
305
|
+
|
|
283
306
|
async def _ensure_jwt(self) -> str:
|
|
284
307
|
"""Acquire or refresh the JWT token from ARL cookie."""
|
|
285
|
-
|
|
286
|
-
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
308
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
287
309
|
return self._jwt
|
|
288
310
|
|
|
289
|
-
|
|
290
|
-
|
|
311
|
+
# Serialize refreshes so concurrent expired-JWT requests trigger
|
|
312
|
+
# exactly one auth call.
|
|
313
|
+
async with self._jwt_lock:
|
|
314
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
315
|
+
return self._jwt
|
|
316
|
+
|
|
317
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
318
|
+
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
319
|
+
|
|
320
|
+
session = self._get_session()
|
|
321
|
+
async with session.post(
|
|
322
|
+
self.AUTH_URL,
|
|
323
|
+
params=params,
|
|
324
|
+
cookies={"arl": self._arl},
|
|
325
|
+
timeout=ClientTimeout(total=10),
|
|
326
|
+
) as resp:
|
|
327
|
+
status = resp.status
|
|
328
|
+
is_success = resp.ok
|
|
329
|
+
# Response body is text/plain containing JSON
|
|
330
|
+
text = await resp.text()
|
|
331
|
+
|
|
332
|
+
if not is_success:
|
|
333
|
+
if 400 <= status < 500:
|
|
334
|
+
msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
|
|
335
|
+
raise GraphQLClientAuthError(msg)
|
|
336
|
+
raise GraphQLClientHttpError(
|
|
337
|
+
status_code=status,
|
|
338
|
+
response=GQLResponse(status=status, data=text.encode(), is_success=False),
|
|
339
|
+
)
|
|
291
340
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
payload = json.loads(urlsafe_b64decode(padded))
|
|
311
|
-
self._jwt_expires_at = float(payload["exp"])
|
|
312
|
-
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
313
|
-
|
|
314
|
-
return self._jwt
|
|
341
|
+
try:
|
|
342
|
+
data = json.loads(text)
|
|
343
|
+
jwt: str = data["jwt"]
|
|
344
|
+
# Decode expiration from JWT payload (second segment, base64url-encoded)
|
|
345
|
+
payload_segment = jwt.split(".")[1]
|
|
346
|
+
# Add padding for base64 decoding
|
|
347
|
+
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
348
|
+
payload = json.loads(urlsafe_b64decode(padded))
|
|
349
|
+
expires_at = float(payload["exp"])
|
|
350
|
+
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
351
|
+
msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
|
|
352
|
+
raise GraphQLClientAuthError(msg) from exc
|
|
353
|
+
|
|
354
|
+
self._jwt = jwt
|
|
355
|
+
self._jwt_expires_at = expires_at
|
|
356
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
357
|
+
|
|
358
|
+
return jwt
|
|
315
359
|
|
|
316
360
|
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
|
|
317
361
|
"""
|
|
318
362
|
Check which album IDs are also valid audiobooks on Deezer.
|
|
319
363
|
|
|
364
|
+
Queries are chunked to stay below the API's query complexity limit.
|
|
365
|
+
|
|
320
366
|
:param album_ids: List of Deezer album/audiobook IDs to check.
|
|
321
367
|
"""
|
|
322
|
-
|
|
323
|
-
|
|
368
|
+
audiobook_ids: set[str] = set()
|
|
369
|
+
for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
|
|
370
|
+
chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
|
|
371
|
+
audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
|
|
372
|
+
return audiobook_ids
|
|
324
373
|
|
|
374
|
+
async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
|
|
375
|
+
"""Check a single chunk of album IDs via one aliased query."""
|
|
325
376
|
# Query displayTitle alongside id — querying only { id } echoes back the
|
|
326
377
|
# input without validating that the ID is actually an audiobook.
|
|
378
|
+
# json.dumps escapes the IDs so they cannot break out of the string
|
|
379
|
+
# literal (this is a public library API — IDs may come from anywhere).
|
|
327
380
|
parts = [
|
|
328
|
-
f
|
|
381
|
+
f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
|
|
329
382
|
for i, aid in enumerate(album_ids)
|
|
330
383
|
]
|
|
331
384
|
query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
|
|
332
385
|
|
|
333
386
|
resp = await self.execute(query, operation_name="CheckAudiobookIds")
|
|
334
|
-
self._last_variables = {"album_ids": album_ids}
|
|
335
387
|
try:
|
|
336
388
|
data = self.get_data(resp)
|
|
337
|
-
except GraphQLClientGraphQLMultiError:
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
389
|
+
except GraphQLClientGraphQLMultiError as err:
|
|
390
|
+
# Only swallow errors scoped to our aliased audiobook fields —
|
|
391
|
+
# that is the legitimate "these IDs are not audiobooks" case.
|
|
392
|
+
# Anything else (complexity limit, transient API failure) must
|
|
393
|
+
# surface, otherwise audiobooks silently degrade to albums.
|
|
394
|
+
aliases = {f"a{i}" for i in range(len(album_ids))}
|
|
395
|
+
if all(e.path and e.path[0] in aliases for e in err.errors):
|
|
396
|
+
logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
|
|
397
|
+
return set()
|
|
398
|
+
raise
|
|
342
399
|
|
|
343
400
|
audiobook_ids: set[str] = set()
|
|
344
401
|
for i, aid in enumerate(album_ids):
|
{deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py
RENAMED
|
@@ -615,6 +615,9 @@ from .search import (
|
|
|
615
615
|
SearchSearchResultsTracksEdgesNodeContributors,
|
|
616
616
|
SearchSearchResultsTracksEdgesNodeContributorsEdges,
|
|
617
617
|
SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist,
|
|
618
|
+
SearchSearchResultsTracksEdgesNodeMedia,
|
|
619
|
+
SearchSearchResultsTracksEdgesNodeMediaRights,
|
|
620
|
+
SearchSearchResultsTracksEdgesNodeMediaRightsSub,
|
|
618
621
|
SearchSearchResultsTracksPageInfo,
|
|
619
622
|
)
|
|
620
623
|
from .search_flows import (
|
|
@@ -1133,6 +1136,9 @@ __all__ = [
|
|
|
1133
1136
|
"SearchSearchResultsTracksEdgesNodeContributors",
|
|
1134
1137
|
"SearchSearchResultsTracksEdgesNodeContributorsEdges",
|
|
1135
1138
|
"SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist",
|
|
1139
|
+
"SearchSearchResultsTracksEdgesNodeMedia",
|
|
1140
|
+
"SearchSearchResultsTracksEdgesNodeMediaRights",
|
|
1141
|
+
"SearchSearchResultsTracksEdgesNodeMediaRightsSub",
|
|
1136
1142
|
"SearchSearchResultsTracksPageInfo",
|
|
1137
1143
|
"TrackContributorRoles",
|
|
1138
1144
|
"TrackFields",
|
{deezer_python_gql-0.16.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py
RENAMED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import time
|
|
@@ -23,17 +24,31 @@ class GQLResponse:
|
|
|
23
24
|
|
|
24
25
|
aiohttp responses cannot escape their context manager, so we capture
|
|
25
26
|
the essential fields inside the ``async with`` block and return this.
|
|
27
|
+
Carries the originating operation name and variables so error handling
|
|
28
|
+
and logging stay correct under concurrent requests.
|
|
26
29
|
"""
|
|
27
30
|
|
|
28
31
|
status: int
|
|
29
32
|
data: bytes
|
|
30
33
|
is_success: bool
|
|
34
|
+
operation_name: str | None = None
|
|
35
|
+
variables: dict[str, Any] | None = None
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
class GraphQLClientError(Exception):
|
|
34
39
|
"""Base exception for GraphQL client errors."""
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
class GraphQLClientAuthError(GraphQLClientError):
|
|
43
|
+
"""
|
|
44
|
+
Raised when ARL → JWT authentication definitively fails.
|
|
45
|
+
|
|
46
|
+
Indicates a bad or expired ARL cookie (or an unexpected auth response),
|
|
47
|
+
not a transient network/server problem — consumers should treat this as
|
|
48
|
+
"re-check your ARL token" rather than retry.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
class GraphQLClientHttpError(GraphQLClientError):
|
|
38
53
|
"""Raised when the HTTP response indicates an error."""
|
|
39
54
|
|
|
@@ -111,6 +126,8 @@ class DeezerBaseClient:
|
|
|
111
126
|
PIPE_URL = "https://pipe.deezer.com/api"
|
|
112
127
|
AUTH_URL = "https://auth.deezer.com/login/arl"
|
|
113
128
|
JWT_REFRESH_MARGIN_SECONDS = 30
|
|
129
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
130
|
+
AUDIOBOOK_CHECK_CHUNK_SIZE = 50
|
|
114
131
|
|
|
115
132
|
def __init__(
|
|
116
133
|
self,
|
|
@@ -124,8 +141,7 @@ class DeezerBaseClient:
|
|
|
124
141
|
self._owns_session = session is None
|
|
125
142
|
self._jwt: str | None = None
|
|
126
143
|
self._jwt_expires_at: float = 0
|
|
127
|
-
self.
|
|
128
|
-
self._last_variables: dict[str, Any] | None = None
|
|
144
|
+
self._jwt_lock = asyncio.Lock()
|
|
129
145
|
|
|
130
146
|
def _get_session(self) -> ClientSession:
|
|
131
147
|
"""Return the HTTP session, creating an internal one if needed."""
|
|
@@ -169,13 +185,12 @@ class DeezerBaseClient:
|
|
|
169
185
|
:param kwargs: Additional keyword arguments passed to the session request.
|
|
170
186
|
"""
|
|
171
187
|
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
172
|
-
self._last_operation_name = operation_name
|
|
173
|
-
self._last_variables = variables
|
|
174
188
|
jwt = await self._ensure_jwt()
|
|
175
189
|
|
|
176
190
|
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
177
191
|
headers["Authorization"] = f"Bearer {jwt}"
|
|
178
192
|
headers["Content-Type"] = "application/json"
|
|
193
|
+
kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
|
|
179
194
|
|
|
180
195
|
payload: dict[str, Any] = {"query": query}
|
|
181
196
|
if operation_name:
|
|
@@ -196,6 +211,8 @@ class DeezerBaseClient:
|
|
|
196
211
|
status=resp.status,
|
|
197
212
|
data=body,
|
|
198
213
|
is_success=resp.ok,
|
|
214
|
+
operation_name=operation_name,
|
|
215
|
+
variables=variables,
|
|
199
216
|
)
|
|
200
217
|
|
|
201
218
|
logger.debug(
|
|
@@ -229,8 +246,8 @@ class DeezerBaseClient:
|
|
|
229
246
|
errors = response_json.get("errors")
|
|
230
247
|
|
|
231
248
|
if errors:
|
|
232
|
-
op =
|
|
233
|
-
variables =
|
|
249
|
+
op = response.operation_name or "<unknown>"
|
|
250
|
+
variables = response.variables
|
|
234
251
|
if data:
|
|
235
252
|
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
236
253
|
# Log the errors but return the valid data.
|
|
@@ -282,65 +299,105 @@ class DeezerBaseClient:
|
|
|
282
299
|
for item in obj:
|
|
283
300
|
cls._inject_missing_typenames(item)
|
|
284
301
|
|
|
302
|
+
def _jwt_is_valid(self) -> bool:
|
|
303
|
+
"""Return True if the current JWT exists and is not about to expire."""
|
|
304
|
+
return self._jwt is not None and time.time() < (
|
|
305
|
+
self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
|
|
306
|
+
)
|
|
307
|
+
|
|
285
308
|
async def _ensure_jwt(self) -> str:
|
|
286
309
|
"""Acquire or refresh the JWT token from ARL cookie."""
|
|
287
|
-
|
|
288
|
-
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
310
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
289
311
|
return self._jwt
|
|
290
312
|
|
|
291
|
-
|
|
292
|
-
|
|
313
|
+
# Serialize refreshes so concurrent expired-JWT requests trigger
|
|
314
|
+
# exactly one auth call.
|
|
315
|
+
async with self._jwt_lock:
|
|
316
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
317
|
+
return self._jwt
|
|
318
|
+
|
|
319
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
320
|
+
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
321
|
+
|
|
322
|
+
session = self._get_session()
|
|
323
|
+
async with session.post(
|
|
324
|
+
self.AUTH_URL,
|
|
325
|
+
params=params,
|
|
326
|
+
cookies={"arl": self._arl},
|
|
327
|
+
timeout=ClientTimeout(total=10),
|
|
328
|
+
) as resp:
|
|
329
|
+
status = resp.status
|
|
330
|
+
is_success = resp.ok
|
|
331
|
+
# Response body is text/plain containing JSON
|
|
332
|
+
text = await resp.text()
|
|
333
|
+
|
|
334
|
+
if not is_success:
|
|
335
|
+
if 400 <= status < 500:
|
|
336
|
+
msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
|
|
337
|
+
raise GraphQLClientAuthError(msg)
|
|
338
|
+
raise GraphQLClientHttpError(
|
|
339
|
+
status_code=status,
|
|
340
|
+
response=GQLResponse(status=status, data=text.encode(), is_success=False),
|
|
341
|
+
)
|
|
293
342
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
payload = json.loads(urlsafe_b64decode(padded))
|
|
313
|
-
self._jwt_expires_at = float(payload["exp"])
|
|
314
|
-
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
315
|
-
|
|
316
|
-
return self._jwt
|
|
343
|
+
try:
|
|
344
|
+
data = json.loads(text)
|
|
345
|
+
jwt: str = data["jwt"]
|
|
346
|
+
# Decode expiration from JWT payload (second segment, base64url-encoded)
|
|
347
|
+
payload_segment = jwt.split(".")[1]
|
|
348
|
+
# Add padding for base64 decoding
|
|
349
|
+
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
350
|
+
payload = json.loads(urlsafe_b64decode(padded))
|
|
351
|
+
expires_at = float(payload["exp"])
|
|
352
|
+
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
353
|
+
msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
|
|
354
|
+
raise GraphQLClientAuthError(msg) from exc
|
|
355
|
+
|
|
356
|
+
self._jwt = jwt
|
|
357
|
+
self._jwt_expires_at = expires_at
|
|
358
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
359
|
+
|
|
360
|
+
return jwt
|
|
317
361
|
|
|
318
362
|
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
|
|
319
363
|
"""
|
|
320
364
|
Check which album IDs are also valid audiobooks on Deezer.
|
|
321
365
|
|
|
366
|
+
Queries are chunked to stay below the API's query complexity limit.
|
|
367
|
+
|
|
322
368
|
:param album_ids: List of Deezer album/audiobook IDs to check.
|
|
323
369
|
"""
|
|
324
|
-
|
|
325
|
-
|
|
370
|
+
audiobook_ids: set[str] = set()
|
|
371
|
+
for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
|
|
372
|
+
chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
|
|
373
|
+
audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
|
|
374
|
+
return audiobook_ids
|
|
326
375
|
|
|
376
|
+
async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
|
|
377
|
+
"""Check a single chunk of album IDs via one aliased query."""
|
|
327
378
|
# Query displayTitle alongside id — querying only { id } echoes back the
|
|
328
379
|
# input without validating that the ID is actually an audiobook.
|
|
380
|
+
# json.dumps escapes the IDs so they cannot break out of the string
|
|
381
|
+
# literal (this is a public library API — IDs may come from anywhere).
|
|
329
382
|
parts = [
|
|
330
|
-
f
|
|
383
|
+
f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
|
|
331
384
|
for i, aid in enumerate(album_ids)
|
|
332
385
|
]
|
|
333
386
|
query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
|
|
334
387
|
|
|
335
388
|
resp = await self.execute(query, operation_name="CheckAudiobookIds")
|
|
336
|
-
self._last_variables = {"album_ids": album_ids}
|
|
337
389
|
try:
|
|
338
390
|
data = self.get_data(resp)
|
|
339
|
-
except GraphQLClientGraphQLMultiError:
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
|
|
343
|
-
|
|
391
|
+
except GraphQLClientGraphQLMultiError as err:
|
|
392
|
+
# Only swallow errors scoped to our aliased audiobook fields —
|
|
393
|
+
# that is the legitimate "these IDs are not audiobooks" case.
|
|
394
|
+
# Anything else (complexity limit, transient API failure) must
|
|
395
|
+
# surface, otherwise audiobooks silently degrade to albums.
|
|
396
|
+
aliases = {f"a{i}" for i in range(len(album_ids))}
|
|
397
|
+
if all(e.path and e.path[0] in aliases for e in err.errors):
|
|
398
|
+
logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
|
|
399
|
+
return set()
|
|
400
|
+
raise
|
|
344
401
|
|
|
345
402
|
audiobook_ids: set[str] = set()
|
|
346
403
|
for i, aid in enumerate(album_ids):
|
|
@@ -45,6 +45,7 @@ class SearchSearchResultsTracksEdgesNode(BaseModel):
|
|
|
45
45
|
is_favorite: Optional[bool] = Field(alias="isFavorite")
|
|
46
46
|
album: Optional["SearchSearchResultsTracksEdgesNodeAlbum"]
|
|
47
47
|
contributors: "SearchSearchResultsTracksEdgesNodeContributors"
|
|
48
|
+
media: Optional["SearchSearchResultsTracksEdgesNodeMedia"]
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
class SearchSearchResultsTracksEdgesNodeAlbum(BaseModel):
|
|
@@ -75,6 +76,18 @@ class SearchSearchResultsTracksEdgesNodeContributorsEdgesNodeArtist(BaseModel):
|
|
|
75
76
|
name: str
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
class SearchSearchResultsTracksEdgesNodeMedia(BaseModel):
|
|
80
|
+
rights: "SearchSearchResultsTracksEdgesNodeMediaRights"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SearchSearchResultsTracksEdgesNodeMediaRights(BaseModel):
|
|
84
|
+
sub: Optional["SearchSearchResultsTracksEdgesNodeMediaRightsSub"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SearchSearchResultsTracksEdgesNodeMediaRightsSub(BaseModel):
|
|
88
|
+
available: bool
|
|
89
|
+
|
|
90
|
+
|
|
78
91
|
class SearchSearchResultsTracksPageInfo(PageInfoFields):
|
|
79
92
|
pass
|
|
80
93
|
|
|
@@ -244,6 +257,8 @@ SearchSearchResultsTracksEdgesNode.model_rebuild()
|
|
|
244
257
|
SearchSearchResultsTracksEdgesNodeAlbum.model_rebuild()
|
|
245
258
|
SearchSearchResultsTracksEdgesNodeContributors.model_rebuild()
|
|
246
259
|
SearchSearchResultsTracksEdgesNodeContributorsEdges.model_rebuild()
|
|
260
|
+
SearchSearchResultsTracksEdgesNodeMedia.model_rebuild()
|
|
261
|
+
SearchSearchResultsTracksEdgesNodeMediaRights.model_rebuild()
|
|
247
262
|
SearchSearchResultsAlbums.model_rebuild()
|
|
248
263
|
SearchSearchResultsAlbumsEdges.model_rebuild()
|
|
249
264
|
SearchSearchResultsAlbumsEdgesNode.model_rebuild()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Async typed Python client for Deezer's Pipe GraphQL API.
|
|
5
5
|
Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
|
|
|
18
18
|
Requires-Dist: mypy==2.1.0; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.6.0; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
|
-
Requires-Dist: pytest==9.
|
|
22
|
-
Requires-Dist: pytest-asyncio==1.
|
|
21
|
+
Requires-Dist: pytest==9.1.1; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
|
|
23
23
|
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
24
|
+
Requires-Dist: ruff==0.15.20; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -189,7 +189,7 @@ asyncio.run(main())
|
|
|
189
189
|
|
|
190
190
|
| Method | Description |
|
|
191
191
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
192
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
192
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
193
193
|
|
|
194
194
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
195
195
|
|
|
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
252
252
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
253
253
|
valid ARL value.
|
|
254
254
|
|
|
255
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
256
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
257
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
258
|
+
|
|
255
259
|
## License
|
|
256
260
|
|
|
257
261
|
Apache-2.0
|