deezer-python-gql 0.7.0__tar.gz → 0.8.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.7.0/deezer_python_gql.egg-info → deezer_python_gql-0.8.1}/PKG-INFO +1 -1
- deezer_python_gql-0.8.1/deezer_python_gql/base_client.py +317 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/__init__.py +1085 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_album_to_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_artist_to_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_audiobook_to_favorite.py +22 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_playlist_to_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_podcast_to_favorite.py +28 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_track_to_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/add_tracks_to_playlist.py +28 -0
- {deezer_python_gql-0.7.0/deezer_python_gql → deezer_python_gql-0.8.1/deezer_python_gql/generated}/base_client.py +2 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/base_model.py +30 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/bookmark_podcast_episode.py +29 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/client.py +3993 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/create_playlist.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/delete_playlist.py +19 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/enums.py +53 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/fragments.py +272 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_album.py +63 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_artist.py +74 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_artist_mix.py +30 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_audiobook.py +60 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_audiobook_chapter.py +66 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_charts.py +115 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_albums.py +49 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_artists.py +49 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_audiobooks.py +32 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_playlists.py +49 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_podcasts.py +49 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_tracks.py +49 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow.py +41 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_batch.py +73 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_config_tracks.py +45 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_configs.py +97 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_livestream.py +18 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_made_for_me.py +77 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_me.py +17 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_affinity.py +98 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_group.py +194 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_groups.py +93 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_playlist.py +44 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast.py +59 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episode.py +40 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +64 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +22 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_recently_played.py +110 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_recommendations.py +134 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_similar_tracks.py +27 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_smart_tracklist.py +52 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_track.py +76 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_track_mix.py +30 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_user_charts.py +88 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/get_user_playlists.py +41 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/input_types.py +8 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +26 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/mark_as_played_podcast_episode.py +29 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_create_group.py +45 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_generate_group_name.py +19 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_join_group.py +43 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_leave_group.py +42 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +46 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_update_group_settings.py +45 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_album_from_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_artist_from_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_audiobook_from_favorite.py +19 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_playlist_from_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_podcast_from_favorite.py +27 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_track_from_favorite.py +25 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_tracks_from_playlist.py +19 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/search.py +183 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/search_flows.py +61 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/unbookmark_podcast_episode.py +26 -0
- deezer_python_gql-0.8.1/deezer_python_gql/generated/update_playlist.py +25 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1/deezer_python_gql.egg-info}/PKG-INFO +1 -1
- deezer_python_gql-0.8.1/deezer_python_gql.egg-info/SOURCES.txt +85 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/pyproject.toml +2 -1
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/tests/test_client.py +60 -31
- deezer_python_gql-0.7.0/deezer_python_gql.egg-info/SOURCES.txt +0 -14
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/LICENSE +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/MANIFEST.in +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/README.md +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql/__init__.py +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/requires.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/setup.cfg +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Custom async base client with Deezer ARL → JWT authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from base64 import urlsafe_b64decode
|
|
9
|
+
from typing import Any, ClassVar, Self, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GraphQLClientError(Exception):
|
|
17
|
+
"""Base exception for GraphQL client errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GraphQLClientHttpError(GraphQLClientError):
|
|
21
|
+
"""Raised when the HTTP response indicates an error."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, response: httpx.Response) -> None:
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.response = response
|
|
26
|
+
super().__init__(f"HTTP status code: {status_code}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GraphQLClientInvalidResponseError(GraphQLClientError):
|
|
30
|
+
"""Raised when the response cannot be parsed as valid GraphQL."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
33
|
+
self.response = response
|
|
34
|
+
super().__init__("Invalid response format")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GraphQLClientGraphQLError(GraphQLClientError):
|
|
38
|
+
"""A single GraphQL error from the response."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, locations: Any = None, path: Any = None) -> None:
|
|
41
|
+
self.message = message
|
|
42
|
+
self.locations = locations
|
|
43
|
+
self.path = path
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GraphQLClientGraphQLMultiError(GraphQLClientError):
|
|
48
|
+
"""Raised when the GraphQL response contains errors."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, errors: list[GraphQLClientGraphQLError], data: Any = None) -> None:
|
|
51
|
+
self.errors = errors
|
|
52
|
+
self.data = data
|
|
53
|
+
super().__init__(str(errors))
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_errors_dicts(
|
|
57
|
+
cls,
|
|
58
|
+
errors_dicts: list[dict[str, Any]],
|
|
59
|
+
data: Any = None,
|
|
60
|
+
) -> GraphQLClientGraphQLMultiError:
|
|
61
|
+
"""Create from raw error dicts in a GraphQL response."""
|
|
62
|
+
errors = [
|
|
63
|
+
GraphQLClientGraphQLError(
|
|
64
|
+
message=e.get("message", "Unknown error"),
|
|
65
|
+
locations=e.get("locations"),
|
|
66
|
+
path=e.get("path"),
|
|
67
|
+
)
|
|
68
|
+
for e in errors_dicts
|
|
69
|
+
]
|
|
70
|
+
return cls(errors=errors, data=data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DeezerBaseClient:
|
|
74
|
+
"""Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
|
|
75
|
+
|
|
76
|
+
Handles the ARL cookie → JWT token exchange and automatic refresh.
|
|
77
|
+
This class is used as the base client for ariadne-codegen's generated client.
|
|
78
|
+
|
|
79
|
+
Manages its own httpx connection pool by default. Pass an external
|
|
80
|
+
``http_client`` only if you need to share a pool across multiple clients.
|
|
81
|
+
|
|
82
|
+
:param arl: Deezer ARL cookie value for authentication.
|
|
83
|
+
:param url: GraphQL endpoint URL (defaults to Pipe API).
|
|
84
|
+
:param http_client: Optional pre-configured httpx.AsyncClient.
|
|
85
|
+
If provided, the caller is responsible for closing it.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
PIPE_URL = "https://pipe.deezer.com/api"
|
|
89
|
+
AUTH_URL = "https://auth.deezer.com/login/arl"
|
|
90
|
+
JWT_REFRESH_MARGIN_SECONDS = 30
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
arl: str,
|
|
95
|
+
url: str = PIPE_URL,
|
|
96
|
+
http_client: httpx.AsyncClient | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.url = url
|
|
99
|
+
self._arl = arl
|
|
100
|
+
self._http_client = http_client
|
|
101
|
+
self._owns_http_client = http_client is None
|
|
102
|
+
self._jwt: str | None = None
|
|
103
|
+
self._jwt_expires_at: float = 0
|
|
104
|
+
|
|
105
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
106
|
+
"""Return the HTTP client, creating an internal one if needed."""
|
|
107
|
+
if self._http_client is None:
|
|
108
|
+
self._http_client = httpx.AsyncClient()
|
|
109
|
+
self._owns_http_client = True
|
|
110
|
+
return self._http_client
|
|
111
|
+
|
|
112
|
+
async def close(self) -> None:
|
|
113
|
+
"""Close the internal HTTP client if we own it.
|
|
114
|
+
|
|
115
|
+
Safe to call multiple times. Does nothing if an external
|
|
116
|
+
``http_client`` was provided at construction time.
|
|
117
|
+
"""
|
|
118
|
+
if self._owns_http_client and self._http_client is not None:
|
|
119
|
+
await self._http_client.aclose()
|
|
120
|
+
self._http_client = None
|
|
121
|
+
|
|
122
|
+
async def __aenter__(self) -> Self:
|
|
123
|
+
"""Enter the async context manager."""
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
async def __aexit__(self, *args: object) -> None:
|
|
127
|
+
"""Exit the async context manager, closing internal resources."""
|
|
128
|
+
await self.close()
|
|
129
|
+
|
|
130
|
+
async def execute(
|
|
131
|
+
self,
|
|
132
|
+
query: str,
|
|
133
|
+
operation_name: str | None = None,
|
|
134
|
+
variables: dict[str, Any] | None = None,
|
|
135
|
+
**kwargs: Any,
|
|
136
|
+
) -> httpx.Response:
|
|
137
|
+
"""Execute a GraphQL query against the Pipe API.
|
|
138
|
+
|
|
139
|
+
Automatically handles JWT acquisition and refresh from the ARL cookie.
|
|
140
|
+
|
|
141
|
+
:param query: The GraphQL query string.
|
|
142
|
+
:param operation_name: Optional operation name for multi-operation documents.
|
|
143
|
+
:param variables: Optional query variables.
|
|
144
|
+
:param kwargs: Additional keyword arguments passed to httpx.
|
|
145
|
+
"""
|
|
146
|
+
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
147
|
+
jwt = await self._ensure_jwt()
|
|
148
|
+
|
|
149
|
+
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
150
|
+
headers["Authorization"] = f"Bearer {jwt}"
|
|
151
|
+
headers["Content-Type"] = "application/json"
|
|
152
|
+
|
|
153
|
+
payload: dict[str, Any] = {"query": query}
|
|
154
|
+
if operation_name:
|
|
155
|
+
payload["operationName"] = operation_name
|
|
156
|
+
if variables:
|
|
157
|
+
# Filter out UNSET sentinel values that are not JSON-serializable.
|
|
158
|
+
# Inline import avoids depending on generated code at module level.
|
|
159
|
+
from deezer_python_gql.generated.base_model import UnsetType # noqa: PLC0415
|
|
160
|
+
|
|
161
|
+
payload["variables"] = {
|
|
162
|
+
k: v for k, v in variables.items() if not isinstance(v, UnsetType)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
client = self._get_http_client()
|
|
166
|
+
resp = await client.post(
|
|
167
|
+
self.url,
|
|
168
|
+
json=payload,
|
|
169
|
+
headers=headers,
|
|
170
|
+
**kwargs,
|
|
171
|
+
)
|
|
172
|
+
logger.debug(
|
|
173
|
+
"GQL response: %s status=%s length=%s",
|
|
174
|
+
operation_name or "<unnamed>",
|
|
175
|
+
resp.status_code,
|
|
176
|
+
len(resp.content),
|
|
177
|
+
)
|
|
178
|
+
return resp
|
|
179
|
+
|
|
180
|
+
def get_data(self, response: httpx.Response) -> dict[str, Any]:
|
|
181
|
+
"""Parse a GraphQL response and return the data dict.
|
|
182
|
+
|
|
183
|
+
Handles the Pipe API's text/plain content type and standard
|
|
184
|
+
GraphQL error responses.
|
|
185
|
+
|
|
186
|
+
:param response: The HTTP response from execute().
|
|
187
|
+
"""
|
|
188
|
+
if not response.is_success:
|
|
189
|
+
raise GraphQLClientHttpError(status_code=response.status_code, response=response)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
response_json = response.json()
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
raise GraphQLClientInvalidResponseError(response=response) from exc
|
|
195
|
+
|
|
196
|
+
if (not isinstance(response_json, dict)) or (
|
|
197
|
+
"data" not in response_json and "errors" not in response_json
|
|
198
|
+
):
|
|
199
|
+
raise GraphQLClientInvalidResponseError(response=response)
|
|
200
|
+
|
|
201
|
+
data = response_json.get("data")
|
|
202
|
+
errors = response_json.get("errors")
|
|
203
|
+
|
|
204
|
+
if errors:
|
|
205
|
+
if data:
|
|
206
|
+
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
207
|
+
# Log the errors but return the valid data.
|
|
208
|
+
logger.warning(
|
|
209
|
+
"GraphQL response contained %d error(s): %s",
|
|
210
|
+
len(errors),
|
|
211
|
+
[e.get("message", "Unknown error") for e in errors],
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
raise GraphQLClientGraphQLMultiError.from_errors_dicts(
|
|
215
|
+
errors_dicts=errors, data=data
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# The Deezer API omits __typename for single-member union types
|
|
219
|
+
# (e.g. Contributor = Artist). Pydantic discriminated unions require it,
|
|
220
|
+
# so we inject the missing field before model validation.
|
|
221
|
+
self._inject_missing_typenames(data)
|
|
222
|
+
|
|
223
|
+
return cast("dict[str, Any]", data)
|
|
224
|
+
|
|
225
|
+
# Map of parent key → child key → __typename value for single-member unions
|
|
226
|
+
# where the Deezer API omits the discriminator.
|
|
227
|
+
_TYPENAME_PATCHES: ClassVar[dict[str, dict[str, str]]] = {
|
|
228
|
+
"contributors": {"node": "Artist"},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def _inject_missing_typenames(cls, obj: Any) -> None:
|
|
233
|
+
"""Recursively inject __typename into nodes where the API omits it."""
|
|
234
|
+
if isinstance(obj, dict):
|
|
235
|
+
for parent_key, patches in cls._TYPENAME_PATCHES.items():
|
|
236
|
+
if parent_key in obj:
|
|
237
|
+
container = obj[parent_key]
|
|
238
|
+
if isinstance(container, dict) and "edges" in container:
|
|
239
|
+
for edge in container["edges"]:
|
|
240
|
+
if isinstance(edge, dict):
|
|
241
|
+
for child_key, typename in patches.items():
|
|
242
|
+
node = edge.get(child_key)
|
|
243
|
+
if isinstance(node, dict) and "__typename" not in node:
|
|
244
|
+
node["__typename"] = typename
|
|
245
|
+
for value in obj.values():
|
|
246
|
+
cls._inject_missing_typenames(value)
|
|
247
|
+
elif isinstance(obj, list):
|
|
248
|
+
for item in obj:
|
|
249
|
+
cls._inject_missing_typenames(item)
|
|
250
|
+
|
|
251
|
+
async def _ensure_jwt(self) -> str:
|
|
252
|
+
"""Acquire or refresh the JWT token from ARL cookie.
|
|
253
|
+
|
|
254
|
+
The Pipe API uses short-lived JWTs (~6 min TTL) obtained by
|
|
255
|
+
POSTing the ARL cookie to auth.deezer.com. The response is
|
|
256
|
+
Content-Type: text/plain containing JSON.
|
|
257
|
+
"""
|
|
258
|
+
now = time.time()
|
|
259
|
+
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
260
|
+
return self._jwt
|
|
261
|
+
|
|
262
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
263
|
+
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
264
|
+
|
|
265
|
+
client = self._get_http_client()
|
|
266
|
+
resp = await client.post(
|
|
267
|
+
self.AUTH_URL,
|
|
268
|
+
params=params,
|
|
269
|
+
cookies={"arl": self._arl},
|
|
270
|
+
)
|
|
271
|
+
resp.raise_for_status()
|
|
272
|
+
|
|
273
|
+
# Response body is text/plain containing JSON
|
|
274
|
+
data = json.loads(resp.text)
|
|
275
|
+
self._jwt = data["jwt"]
|
|
276
|
+
|
|
277
|
+
# Decode expiration from JWT payload (second segment, base64url-encoded)
|
|
278
|
+
payload_segment = self._jwt.split(".")[1]
|
|
279
|
+
# Add padding for base64 decoding
|
|
280
|
+
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
281
|
+
payload = json.loads(urlsafe_b64decode(padded))
|
|
282
|
+
self._jwt_expires_at = float(payload["exp"])
|
|
283
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
284
|
+
|
|
285
|
+
return self._jwt
|
|
286
|
+
|
|
287
|
+
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
|
|
288
|
+
"""Check which album IDs are also valid audiobooks on Deezer.
|
|
289
|
+
|
|
290
|
+
Uses GraphQL aliases to batch-check many IDs in a single request.
|
|
291
|
+
Returns the subset of IDs that are audiobooks (i.e., the audiobook
|
|
292
|
+
query returns non-null for them).
|
|
293
|
+
|
|
294
|
+
:param album_ids: List of Deezer album/audiobook IDs to check.
|
|
295
|
+
"""
|
|
296
|
+
if not album_ids:
|
|
297
|
+
return set()
|
|
298
|
+
|
|
299
|
+
# Query displayTitle alongside id — querying only { id } echoes back the
|
|
300
|
+
# input without validating that the ID is actually an audiobook.
|
|
301
|
+
parts = [
|
|
302
|
+
f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
|
|
303
|
+
for i, aid in enumerate(album_ids)
|
|
304
|
+
]
|
|
305
|
+
query = "{ " + " ".join(parts) + " }"
|
|
306
|
+
|
|
307
|
+
resp = await self.execute(query)
|
|
308
|
+
data = self.get_data(resp)
|
|
309
|
+
|
|
310
|
+
audiobook_ids: set[str] = set()
|
|
311
|
+
for i, aid in enumerate(album_ids):
|
|
312
|
+
node = data.get(f"a{i}")
|
|
313
|
+
# The API echoes back {id} for any valid album, so we must check
|
|
314
|
+
# a real audiobook field like displayTitle to distinguish.
|
|
315
|
+
if node is not None and node.get("displayTitle") is not None:
|
|
316
|
+
audiobook_ids.add(aid)
|
|
317
|
+
return audiobook_ids
|