deezer-python-gql 0.19.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {deezer_python_gql-0.19.0/deezer_python_gql.egg-info → deezer_python_gql-0.20.0}/PKG-INFO +18 -2
  2. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/README.md +16 -0
  3. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/base_client.py +15 -0
  4. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/__init__.py +16 -0
  5. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/base_client.py +15 -0
  6. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/client.py +33 -0
  7. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/fragments.py +12 -0
  8. deezer_python_gql-0.20.0/deezer_python_gql/generated/get_family.py +34 -0
  9. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0/deezer_python_gql.egg-info}/PKG-INFO +18 -2
  10. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql.egg-info/SOURCES.txt +1 -0
  11. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql.egg-info/requires.txt +1 -1
  12. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/pyproject.toml +7 -2
  13. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/tests/test_client.py +53 -1
  14. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/LICENSE +0 -0
  15. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/MANIFEST.in +0 -0
  16. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/__init__.py +0 -0
  17. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  18. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  19. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  20. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  21. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  22. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  23. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  24. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/ban_artist_from_recommendation.py +0 -0
  25. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/ban_track_from_recommendation.py +0 -0
  26. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/base_model.py +0 -0
  27. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  28. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/create_playlist.py +0 -0
  29. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
  30. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/enums.py +0 -0
  31. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_album.py +0 -0
  32. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_artist.py +0 -0
  33. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  34. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
  35. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  36. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_banned_artists.py +0 -0
  37. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_banned_tracks.py +0 -0
  38. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_charts.py +0 -0
  39. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  40. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  41. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  42. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  43. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  44. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  45. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_flow.py +0 -0
  46. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  47. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  48. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  49. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_flow_tuner.py +0 -0
  50. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_infinite_track_mix.py +0 -0
  51. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_latest_podcast_episodes.py +0 -0
  52. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_livestream.py +0 -0
  53. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  54. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_me.py +0 -0
  55. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  56. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  57. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  58. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  59. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_playlist.py +0 -0
  60. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_podcast.py +0 -0
  61. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  62. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  63. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  64. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
  65. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
  66. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  67. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  68. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  69. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_track.py +0 -0
  70. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
  71. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_tracks_by_ids.py +0 -0
  72. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
  73. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  74. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/input_types.py +0 -0
  75. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  76. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  77. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  78. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  79. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  80. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  81. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  82. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  83. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  84. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  85. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  86. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  87. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  88. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  89. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  90. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/search.py +0 -0
  91. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/search_flows.py +0 -0
  92. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/set_flow_discovery_tuner.py +0 -0
  93. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/unban_artist_from_recommendation.py +0 -0
  94. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/unban_track_from_recommendation.py +0 -0
  95. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  96. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/generated/update_playlist.py +0 -0
  97. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql/py.typed +0 -0
  98. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  99. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  100. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
  101. {deezer_python_gql-0.19.0 → deezer_python_gql-0.20.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Async typed Python client for Deezer's Pipe GraphQL API.
5
5
  Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -22,7 +22,7 @@ Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
22
22
  Requires-Dist: pytest==9.1.1; extra == "test"
23
23
  Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
24
24
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
25
- Requires-Dist: ruff==0.16.8; extra == "test"
25
+ Requires-Dist: ruff==0.16.9; extra == "test"
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
28
28
  Dynamic: license-file
@@ -136,6 +136,7 @@ asyncio.run(main())
136
136
  | `get_banned_tracks(first, after)` | Tracks banned from recommendations, with ban date |
137
137
  | `get_banned_artists(first, after)` | Artists banned from recommendations, with ban date |
138
138
  | `get_flow_tuner()` | Flow tuner: `DEFAULT` or `DISCOVERY` |
139
+ | `get_family()` | Deezer Family members, with `isLoggableAs` for usable profiles |
139
140
 
140
141
  ### Music Together (Collaborative Playlists)
141
142
 
@@ -286,6 +287,21 @@ If the ARL is rejected (bad or expired), the client raises
286
287
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
287
288
  Transient server errors raise `GraphQLClientHttpError` instead.
288
289
 
290
+ ### Deezer Family profiles
291
+
292
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
293
+ Family admin. Pass `account_id` to act as one of them:
294
+
295
+ ```python
296
+ family = (await client.get_family()).family
297
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
298
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
299
+ ```
300
+
301
+ Members with their own login (independent accounts) are not loggable, they need their
302
+ own ARL. If Deezer does not sign in as the profile, the client raises
303
+ `GraphQLClientAuthError`.
304
+
289
305
  ## License
290
306
 
291
307
  Apache-2.0
@@ -107,6 +107,7 @@ asyncio.run(main())
107
107
  | `get_banned_tracks(first, after)` | Tracks banned from recommendations, with ban date |
108
108
  | `get_banned_artists(first, after)` | Artists banned from recommendations, with ban date |
109
109
  | `get_flow_tuner()` | Flow tuner: `DEFAULT` or `DISCOVERY` |
110
+ | `get_family()` | Deezer Family members, with `isLoggableAs` for usable profiles |
110
111
 
111
112
  ### Music Together (Collaborative Playlists)
112
113
 
@@ -257,6 +258,21 @@ If the ARL is rejected (bad or expired), the client raises
257
258
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
258
259
  Transient server errors raise `GraphQLClientHttpError` instead.
259
260
 
261
+ ### Deezer Family profiles
262
+
263
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
264
+ Family admin. Pass `account_id` to act as one of them:
265
+
266
+ ```python
267
+ family = (await client.get_family()).family
268
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
269
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
270
+ ```
271
+
272
+ Members with their own login (independent accounts) are not loggable, they need their
273
+ own ARL. If Deezer does not sign in as the profile, the client raises
274
+ `GraphQLClientAuthError`.
275
+
260
276
  ## License
261
277
 
262
278
  Apache-2.0
@@ -123,6 +123,9 @@ class DeezerBaseClient:
123
123
  :param url: GraphQL endpoint URL (defaults to Pipe API).
124
124
  :param session: Optional pre-configured aiohttp.ClientSession.
125
125
  If provided, the caller is responsible for closing it.
126
+ :param account_id: Optional Deezer Family profile to act as. A profile has no
127
+ ARL of its own; it signs in with the ARL of the Family admin. Only profiles
128
+ that ``get_family()`` marks as ``isLoggableAs`` can be used.
126
129
  """
127
130
 
128
131
  PIPE_URL = "https://pipe.deezer.com/api"
@@ -136,9 +139,11 @@ class DeezerBaseClient:
136
139
  arl: str,
137
140
  url: str = PIPE_URL,
138
141
  session: ClientSession | None = None,
142
+ account_id: str | None = None,
139
143
  ) -> None:
140
144
  self.url = url
141
145
  self._arl = arl
146
+ self._account_id = account_id
142
147
  self._session = session
143
148
  self._owns_session = session is None
144
149
  # An external session may be shared by clients for different Deezer accounts.
@@ -347,12 +352,18 @@ class DeezerBaseClient:
347
352
 
348
353
  logger.debug("JWT expired or missing, refreshing from ARL")
349
354
  params = {"jo": "p", "rto": "c", "i": "c"}
355
+ body = None
356
+ if self._account_id:
357
+ # A Family profile signs in with the admin's ARL and its own id
358
+ params["i"] = "p"
359
+ body = {"arl": self._arl, "account_id": self._account_id}
350
360
 
351
361
  session = self._get_session()
352
362
  async with session.post(
353
363
  self.AUTH_URL,
354
364
  params=params,
355
365
  cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
366
+ json=body,
356
367
  timeout=ClientTimeout(total=10),
357
368
  ) as resp:
358
369
  self._store_cookies(resp)
@@ -383,6 +394,10 @@ class DeezerBaseClient:
383
394
  msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
384
395
  raise GraphQLClientAuthError(msg) from exc
385
396
 
397
+ if self._account_id and str(payload.get("userId")) != self._account_id:
398
+ msg = f"auth.deezer.com did not sign in as Family profile {self._account_id}"
399
+ raise GraphQLClientAuthError(msg)
400
+
386
401
  self._jwt = jwt
387
402
  self._jwt_expires_at = expires_at
388
403
  logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
@@ -86,6 +86,8 @@ from .fragments import (
86
86
  AudiobookFieldsContributorsEdges,
87
87
  AudiobookFieldsContributorsEdgesNodeArtist,
88
88
  AudiobookFieldsCover,
89
+ FamilyProfileFields,
90
+ FamilyProfileFieldsPermissions,
89
91
  LivestreamFields,
90
92
  LivestreamFieldsCover,
91
93
  LivestreamFieldsMedia,
@@ -205,6 +207,13 @@ from .get_charts import (
205
207
  GetChartsChartsCountryTracksEdgesNode,
206
208
  GetChartsChartsCountryTracksPageInfo,
207
209
  )
210
+ from .get_family import (
211
+ GetFamily,
212
+ GetFamilyMe,
213
+ GetFamilyMeFamily,
214
+ GetFamilyMeFamilyLinked,
215
+ GetFamilyMeFamilyMain,
216
+ )
208
217
  from .get_favorite_albums import (
209
218
  GetFavoriteAlbums,
210
219
  GetFavoriteAlbumsMe,
@@ -759,6 +768,8 @@ __all__ = [
759
768
  "DeletePlaylistDeletePlaylist",
760
769
  "DiscoveryTuner",
761
770
  "DiscoveryTunerInput",
771
+ "FamilyProfileFields",
772
+ "FamilyProfileFieldsPermissions",
762
773
  "GetAlbum",
763
774
  "GetAlbumAlbum",
764
775
  "GetAlbumAlbumFallback",
@@ -837,6 +848,11 @@ __all__ = [
837
848
  "GetChartsChartsCountryTracksEdges",
838
849
  "GetChartsChartsCountryTracksEdgesNode",
839
850
  "GetChartsChartsCountryTracksPageInfo",
851
+ "GetFamily",
852
+ "GetFamilyMe",
853
+ "GetFamilyMeFamily",
854
+ "GetFamilyMeFamilyLinked",
855
+ "GetFamilyMeFamilyMain",
840
856
  "GetFavoriteAlbums",
841
857
  "GetFavoriteAlbumsMe",
842
858
  "GetFavoriteAlbumsMeUserFavorites",
@@ -125,6 +125,9 @@ class DeezerBaseClient:
125
125
  :param url: GraphQL endpoint URL (defaults to Pipe API).
126
126
  :param session: Optional pre-configured aiohttp.ClientSession.
127
127
  If provided, the caller is responsible for closing it.
128
+ :param account_id: Optional Deezer Family profile to act as. A profile has no
129
+ ARL of its own; it signs in with the ARL of the Family admin. Only profiles
130
+ that ``get_family()`` marks as ``isLoggableAs`` can be used.
128
131
  """
129
132
 
130
133
  PIPE_URL = "https://pipe.deezer.com/api"
@@ -138,9 +141,11 @@ class DeezerBaseClient:
138
141
  arl: str,
139
142
  url: str = PIPE_URL,
140
143
  session: ClientSession | None = None,
144
+ account_id: str | None = None,
141
145
  ) -> None:
142
146
  self.url = url
143
147
  self._arl = arl
148
+ self._account_id = account_id
144
149
  self._session = session
145
150
  self._owns_session = session is None
146
151
  # An external session may be shared by clients for different Deezer accounts.
@@ -349,12 +354,18 @@ class DeezerBaseClient:
349
354
 
350
355
  logger.debug("JWT expired or missing, refreshing from ARL")
351
356
  params = {"jo": "p", "rto": "c", "i": "c"}
357
+ body = None
358
+ if self._account_id:
359
+ # A Family profile signs in with the admin's ARL and its own id
360
+ params["i"] = "p"
361
+ body = {"arl": self._arl, "account_id": self._account_id}
352
362
 
353
363
  session = self._get_session()
354
364
  async with session.post(
355
365
  self.AUTH_URL,
356
366
  params=params,
357
367
  cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
368
+ json=body,
358
369
  timeout=ClientTimeout(total=10),
359
370
  ) as resp:
360
371
  self._store_cookies(resp)
@@ -385,6 +396,10 @@ class DeezerBaseClient:
385
396
  msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
386
397
  raise GraphQLClientAuthError(msg) from exc
387
398
 
399
+ if self._account_id and str(payload.get("userId")) != self._account_id:
400
+ msg = f"auth.deezer.com did not sign in as Family profile {self._account_id}"
401
+ raise GraphQLClientAuthError(msg)
402
+
388
403
  self._jwt = jwt
389
404
  self._jwt_expires_at = expires_at
390
405
  logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
@@ -65,6 +65,7 @@ from .get_audiobook_chapter import (
65
65
  from .get_banned_artists import GetBannedArtists, GetBannedArtistsMe
66
66
  from .get_banned_tracks import GetBannedTracks, GetBannedTracksMe
67
67
  from .get_charts import GetCharts, GetChartsCharts
68
+ from .get_family import GetFamily, GetFamilyMe
68
69
  from .get_favorite_albums import GetFavoriteAlbums, GetFavoriteAlbumsMe
69
70
  from .get_favorite_artists import GetFavoriteArtists, GetFavoriteArtistsMe
70
71
  from .get_favorite_audiobooks import GetFavoriteAudiobooks, GetFavoriteAudiobooksMe
@@ -1475,6 +1476,38 @@ class DeezerGQLClient(DeezerBaseClient):
1475
1476
  data = self.get_data(response)
1476
1477
  return GetCharts.model_validate(data).charts
1477
1478
 
1479
+ async def get_family(self, **kwargs: Any) -> Optional[GetFamilyMe]:
1480
+ query = gql("""
1481
+ query GetFamily {
1482
+ me {
1483
+ id
1484
+ family {
1485
+ main {
1486
+ ...FamilyProfileFields
1487
+ }
1488
+ linked {
1489
+ ...FamilyProfileFields
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ fragment FamilyProfileFields on FamilyUser {
1496
+ id
1497
+ name
1498
+ caption
1499
+ permissions {
1500
+ isLoggableAs
1501
+ }
1502
+ }
1503
+ """)
1504
+ variables: dict[str, object] = {}
1505
+ response = await self.execute(
1506
+ query=query, operation_name="GetFamily", variables=variables, **kwargs
1507
+ )
1508
+ data = self.get_data(response)
1509
+ return GetFamily.model_validate(data).me
1510
+
1478
1511
  async def get_favorite_albums(
1479
1512
  self,
1480
1513
  first: Union[Optional[int], UnsetType] = UNSET,
@@ -132,6 +132,17 @@ class AudiobookFieldsContributorsEdgesNodeArtist(BaseModel):
132
132
  name: str
133
133
 
134
134
 
135
+ class FamilyProfileFields(BaseModel):
136
+ id: str
137
+ name: str
138
+ caption: str
139
+ permissions: "FamilyProfileFieldsPermissions"
140
+
141
+
142
+ class FamilyProfileFieldsPermissions(BaseModel):
143
+ is_loggable_as: bool = Field(alias="isLoggableAs")
144
+
145
+
135
146
  class LivestreamFields(BaseModel):
136
147
  id: str
137
148
  name: str
@@ -292,6 +303,7 @@ AlbumFields.model_rebuild()
292
303
  ArtistFields.model_rebuild()
293
304
  AudiobookChapterFields.model_rebuild()
294
305
  AudiobookFields.model_rebuild()
306
+ FamilyProfileFields.model_rebuild()
295
307
  LivestreamFields.model_rebuild()
296
308
  PageInfoFields.model_rebuild()
297
309
  PlaylistFields.model_rebuild()
@@ -0,0 +1,34 @@
1
+ # Generated by ariadne-codegen
2
+ # Source: queries
3
+
4
+ from typing import Optional
5
+
6
+ from .base_model import BaseModel
7
+ from .fragments import FamilyProfileFields
8
+
9
+
10
+ class GetFamily(BaseModel):
11
+ me: Optional["GetFamilyMe"]
12
+
13
+
14
+ class GetFamilyMe(BaseModel):
15
+ id: str
16
+ family: Optional["GetFamilyMeFamily"]
17
+
18
+
19
+ class GetFamilyMeFamily(BaseModel):
20
+ main: "GetFamilyMeFamilyMain"
21
+ linked: list["GetFamilyMeFamilyLinked"]
22
+
23
+
24
+ class GetFamilyMeFamilyMain(FamilyProfileFields):
25
+ pass
26
+
27
+
28
+ class GetFamilyMeFamilyLinked(FamilyProfileFields):
29
+ pass
30
+
31
+
32
+ GetFamily.model_rebuild()
33
+ GetFamilyMe.model_rebuild()
34
+ GetFamilyMeFamily.model_rebuild()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Async typed Python client for Deezer's Pipe GraphQL API.
5
5
  Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -22,7 +22,7 @@ Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
22
22
  Requires-Dist: pytest==9.1.1; extra == "test"
23
23
  Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
24
24
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
25
- Requires-Dist: ruff==0.16.8; extra == "test"
25
+ Requires-Dist: ruff==0.16.9; extra == "test"
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
28
28
  Dynamic: license-file
@@ -136,6 +136,7 @@ asyncio.run(main())
136
136
  | `get_banned_tracks(first, after)` | Tracks banned from recommendations, with ban date |
137
137
  | `get_banned_artists(first, after)` | Artists banned from recommendations, with ban date |
138
138
  | `get_flow_tuner()` | Flow tuner: `DEFAULT` or `DISCOVERY` |
139
+ | `get_family()` | Deezer Family members, with `isLoggableAs` for usable profiles |
139
140
 
140
141
  ### Music Together (Collaborative Playlists)
141
142
 
@@ -286,6 +287,21 @@ If the ARL is rejected (bad or expired), the client raises
286
287
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
287
288
  Transient server errors raise `GraphQLClientHttpError` instead.
288
289
 
290
+ ### Deezer Family profiles
291
+
292
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
293
+ Family admin. Pass `account_id` to act as one of them:
294
+
295
+ ```python
296
+ family = (await client.get_family()).family
297
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
298
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
299
+ ```
300
+
301
+ Members with their own login (independent accounts) are not loggable, they need their
302
+ own ARL. If Deezer does not sign in as the profile, the client raises
303
+ `GraphQLClientAuthError`.
304
+
289
305
  ## License
290
306
 
291
307
  Apache-2.0
@@ -37,6 +37,7 @@ deezer_python_gql/generated/get_audiobook_chapter.py
37
37
  deezer_python_gql/generated/get_banned_artists.py
38
38
  deezer_python_gql/generated/get_banned_tracks.py
39
39
  deezer_python_gql/generated/get_charts.py
40
+ deezer_python_gql/generated/get_family.py
40
41
  deezer_python_gql/generated/get_favorite_albums.py
41
42
  deezer_python_gql/generated/get_favorite_artists.py
42
43
  deezer_python_gql/generated/get_favorite_audiobooks.py
@@ -13,4 +13,4 @@ pre-commit-hooks==6.0.0
13
13
  pytest==9.1.1
14
14
  pytest-asyncio==1.4.0
15
15
  pytest-cov==7.1.0
16
- ruff==0.16.8
16
+ ruff==0.16.9
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "Async typed Python client for Deezer's Pipe GraphQL API."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -30,7 +30,7 @@ test = [
30
30
  "pytest==9.1.1",
31
31
  "pytest-asyncio==1.4.0",
32
32
  "pytest-cov==7.1.0",
33
- "ruff==0.16.8",
33
+ "ruff==0.16.9",
34
34
  ]
35
35
  dev = [
36
36
  "ariadne-codegen[subscriptions]",
@@ -179,3 +179,8 @@ warn_unused_ignores = true
179
179
  [tool.pytest.ini_options]
180
180
  addopts = "--cov deezer_python_gql"
181
181
  asyncio_mode = "auto"
182
+
183
+ [tool.uv]
184
+ override-dependencies = [
185
+ "ruff==0.16.9",
186
+ ]
@@ -57,6 +57,7 @@ from deezer_python_gql.generated.get_audiobook_chapter import GetAudiobookChapte
57
57
  from deezer_python_gql.generated.get_banned_artists import GetBannedArtists
58
58
  from deezer_python_gql.generated.get_banned_tracks import GetBannedTracks
59
59
  from deezer_python_gql.generated.get_charts import GetCharts
60
+ from deezer_python_gql.generated.get_family import GetFamily
60
61
  from deezer_python_gql.generated.get_favorite_albums import GetFavoriteAlbums
61
62
  from deezer_python_gql.generated.get_favorite_artists import GetFavoriteArtists
62
63
  from deezer_python_gql.generated.get_favorite_audiobooks import GetFavoriteAudiobooks
@@ -147,11 +148,14 @@ def _load_fixture(name: str) -> dict[str, Any]:
147
148
  return result
148
149
 
149
150
 
150
- def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
151
+ def _make_jwt(
152
+ exp: float | None = None, subject: str | None = None, user_id: str | None = None
153
+ ) -> str:
151
154
  """
152
155
  Build a fake JWT with a configurable expiration timestamp.
153
156
 
154
157
  :param exp: Unix timestamp for JWT expiry. Defaults to 6 min from now.
158
+ :param user_id: Optional ``userId`` claim, which Deezer sets to the account id.
155
159
  """
156
160
  if exp is None:
157
161
  exp = time.time() + 360 # 6 min, matching Deezer's real TTL
@@ -159,6 +163,8 @@ def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
159
163
  payload_data: dict[str, float | str] = {"exp": exp}
160
164
  if subject is not None:
161
165
  payload_data["sub"] = subject
166
+ if user_id is not None:
167
+ payload_data["userId"] = user_id
162
168
  payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=").decode()
163
169
  return f"{header}.{payload}.fake_signature"
164
170
 
@@ -525,6 +531,39 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
525
531
  assert auth_call.kwargs["cookies"] == {"arl": "my_secret_arl"}
526
532
 
527
533
 
534
+ @pytest.mark.asyncio
535
+ async def test_auth_family_profile_signs_in_with_account_id() -> None:
536
+ """Verify a Family profile sends the admin's ARL and its id in the payload."""
537
+ client = DeezerBaseClient(arl="admin_arl", account_id="456")
538
+
539
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
540
+ mock_instance = MagicMock()
541
+ mock_instance.post = MagicMock(return_value=_mock_auth_cm(_make_jwt(user_id="456")))
542
+ mock_instance.close = AsyncMock()
543
+ mock_cls.return_value = mock_instance
544
+
545
+ await client._ensure_jwt() # noqa: SLF001
546
+
547
+ auth_call = mock_instance.post.call_args
548
+ assert auth_call.kwargs["params"]["i"] == "p"
549
+ assert auth_call.kwargs["json"] == {"arl": "admin_arl", "account_id": "456"}
550
+
551
+
552
+ @pytest.mark.asyncio
553
+ async def test_auth_family_profile_rejects_a_token_for_another_account() -> None:
554
+ """Verify a token for another account is never used for a Family profile."""
555
+ client = DeezerBaseClient(arl="admin_arl", account_id="456")
556
+
557
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
558
+ mock_instance = MagicMock()
559
+ mock_instance.post = MagicMock(return_value=_mock_auth_cm(_make_jwt(user_id="123")))
560
+ mock_instance.close = AsyncMock()
561
+ mock_cls.return_value = mock_instance
562
+
563
+ with pytest.raises(GraphQLClientAuthError, match="Family profile 456"):
564
+ await client._ensure_jwt() # noqa: SLF001
565
+
566
+
528
567
  @pytest.mark.parametrize(("first_arl", "second_arl"), [("arl-a", "arl-b"), ("arl-b", "arl-a")])
529
568
  @pytest.mark.asyncio
530
569
  async def test_shared_session_keeps_account_cookies_isolated(
@@ -1969,3 +2008,16 @@ def test_smoke_get_infinite_track_mix() -> None:
1969
2008
  data = _load_fixture("get_infinite_track_mix.json")
1970
2009
  mix = GetInfiniteTrackMix.model_validate(data).raw_infinite_track_mix
1971
2010
  assert [t.id for t in mix.tracks] == ["3135554", "3155977", "10284909"]
2011
+
2012
+
2013
+ def test_smoke_get_family() -> None:
2014
+ """Verify GetFamily fixture parses the admin, a profile and an independent account."""
2015
+ data = _load_fixture("get_family.json")
2016
+ me = GetFamily.model_validate(data).me
2017
+ assert me is not None
2018
+ assert me.family is not None
2019
+ assert me.family.main.id == me.id
2020
+ assert [(m.id, m.permissions.is_loggable_as) for m in me.family.linked] == [
2021
+ ("2002", True),
2022
+ ("3003", False),
2023
+ ]