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.
Files changed (88) hide show
  1. {deezer_python_gql-0.7.0/deezer_python_gql.egg-info → deezer_python_gql-0.8.1}/PKG-INFO +1 -1
  2. deezer_python_gql-0.8.1/deezer_python_gql/base_client.py +317 -0
  3. deezer_python_gql-0.8.1/deezer_python_gql/generated/__init__.py +1085 -0
  4. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_album_to_favorite.py +25 -0
  5. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_artist_to_favorite.py +25 -0
  6. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_audiobook_to_favorite.py +22 -0
  7. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_playlist_to_favorite.py +25 -0
  8. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_podcast_to_favorite.py +28 -0
  9. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_track_to_favorite.py +25 -0
  10. deezer_python_gql-0.8.1/deezer_python_gql/generated/add_tracks_to_playlist.py +28 -0
  11. {deezer_python_gql-0.7.0/deezer_python_gql → deezer_python_gql-0.8.1/deezer_python_gql/generated}/base_client.py +2 -0
  12. deezer_python_gql-0.8.1/deezer_python_gql/generated/base_model.py +30 -0
  13. deezer_python_gql-0.8.1/deezer_python_gql/generated/bookmark_podcast_episode.py +29 -0
  14. deezer_python_gql-0.8.1/deezer_python_gql/generated/client.py +3993 -0
  15. deezer_python_gql-0.8.1/deezer_python_gql/generated/create_playlist.py +25 -0
  16. deezer_python_gql-0.8.1/deezer_python_gql/generated/delete_playlist.py +19 -0
  17. deezer_python_gql-0.8.1/deezer_python_gql/generated/enums.py +53 -0
  18. deezer_python_gql-0.8.1/deezer_python_gql/generated/fragments.py +272 -0
  19. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_album.py +63 -0
  20. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_artist.py +74 -0
  21. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_artist_mix.py +30 -0
  22. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_audiobook.py +60 -0
  23. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_audiobook_chapter.py +66 -0
  24. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_charts.py +115 -0
  25. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_albums.py +49 -0
  26. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_artists.py +49 -0
  27. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_audiobooks.py +32 -0
  28. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_playlists.py +49 -0
  29. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_podcasts.py +49 -0
  30. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_favorite_tracks.py +49 -0
  31. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow.py +41 -0
  32. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_batch.py +73 -0
  33. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_config_tracks.py +45 -0
  34. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_flow_configs.py +97 -0
  35. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_livestream.py +18 -0
  36. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_made_for_me.py +77 -0
  37. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_me.py +17 -0
  38. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_affinity.py +98 -0
  39. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_group.py +194 -0
  40. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_music_together_groups.py +93 -0
  41. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_playlist.py +44 -0
  42. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast.py +59 -0
  43. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episode.py +40 -0
  44. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +64 -0
  45. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +22 -0
  46. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_recently_played.py +110 -0
  47. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_recommendations.py +134 -0
  48. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_similar_tracks.py +27 -0
  49. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_smart_tracklist.py +52 -0
  50. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_track.py +76 -0
  51. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_track_mix.py +30 -0
  52. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_user_charts.py +88 -0
  53. deezer_python_gql-0.8.1/deezer_python_gql/generated/get_user_playlists.py +41 -0
  54. deezer_python_gql-0.8.1/deezer_python_gql/generated/input_types.py +8 -0
  55. deezer_python_gql-0.8.1/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +26 -0
  56. deezer_python_gql-0.8.1/deezer_python_gql/generated/mark_as_played_podcast_episode.py +29 -0
  57. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_create_group.py +45 -0
  58. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_generate_group_name.py +19 -0
  59. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_join_group.py +43 -0
  60. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_leave_group.py +42 -0
  61. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +46 -0
  62. deezer_python_gql-0.8.1/deezer_python_gql/generated/music_together_update_group_settings.py +45 -0
  63. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_album_from_favorite.py +25 -0
  64. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_artist_from_favorite.py +25 -0
  65. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_audiobook_from_favorite.py +19 -0
  66. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_playlist_from_favorite.py +25 -0
  67. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_podcast_from_favorite.py +27 -0
  68. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_track_from_favorite.py +25 -0
  69. deezer_python_gql-0.8.1/deezer_python_gql/generated/remove_tracks_from_playlist.py +19 -0
  70. deezer_python_gql-0.8.1/deezer_python_gql/generated/search.py +183 -0
  71. deezer_python_gql-0.8.1/deezer_python_gql/generated/search_flows.py +61 -0
  72. deezer_python_gql-0.8.1/deezer_python_gql/generated/unbookmark_podcast_episode.py +26 -0
  73. deezer_python_gql-0.8.1/deezer_python_gql/generated/update_playlist.py +25 -0
  74. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1/deezer_python_gql.egg-info}/PKG-INFO +1 -1
  75. deezer_python_gql-0.8.1/deezer_python_gql.egg-info/SOURCES.txt +85 -0
  76. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/pyproject.toml +2 -1
  77. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/tests/test_client.py +60 -31
  78. deezer_python_gql-0.7.0/deezer_python_gql.egg-info/SOURCES.txt +0 -14
  79. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/LICENSE +0 -0
  80. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/MANIFEST.in +0 -0
  81. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/README.md +0 -0
  82. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql/__init__.py +0 -0
  83. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql/py.typed +0 -0
  84. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  85. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  86. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/requires.txt +0 -0
  87. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
  88. {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -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