deezer-python-gql 0.19.0__tar.gz → 0.21.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 (102) hide show
  1. {deezer_python_gql-0.19.0/deezer_python_gql.egg-info → deezer_python_gql-0.21.0}/PKG-INFO +22 -5
  2. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/README.md +17 -0
  3. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/base_client.py +15 -0
  4. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/__init__.py +22 -0
  5. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/base_client.py +15 -0
  6. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/client.py +67 -0
  7. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/fragments.py +12 -0
  8. deezer_python_gql-0.21.0/deezer_python_gql/generated/get_family.py +34 -0
  9. deezer_python_gql-0.21.0/deezer_python_gql/generated/move_track_in_playlist.py +21 -0
  10. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0/deezer_python_gql.egg-info}/PKG-INFO +22 -5
  11. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql.egg-info/SOURCES.txt +2 -0
  12. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql.egg-info/requires.txt +4 -4
  13. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/pyproject.toml +10 -5
  14. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/tests/test_client.py +62 -1
  15. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/LICENSE +0 -0
  16. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/MANIFEST.in +0 -0
  17. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/__init__.py +0 -0
  18. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  19. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  20. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  21. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  22. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  23. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  24. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  25. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/ban_artist_from_recommendation.py +0 -0
  26. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/ban_track_from_recommendation.py +0 -0
  27. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/base_model.py +0 -0
  28. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  29. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/create_playlist.py +0 -0
  30. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
  31. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/enums.py +0 -0
  32. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_album.py +0 -0
  33. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_artist.py +0 -0
  34. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  35. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
  36. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  37. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_banned_artists.py +0 -0
  38. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_banned_tracks.py +0 -0
  39. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_charts.py +0 -0
  40. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  41. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  42. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  43. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  44. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  45. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  46. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_flow.py +0 -0
  47. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  48. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  49. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  50. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_flow_tuner.py +0 -0
  51. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_infinite_track_mix.py +0 -0
  52. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_latest_podcast_episodes.py +0 -0
  53. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_livestream.py +0 -0
  54. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  55. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_me.py +0 -0
  56. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  57. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  58. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  59. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  60. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_playlist.py +0 -0
  61. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_podcast.py +0 -0
  62. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  63. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  64. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  65. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
  66. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
  67. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  68. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  69. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  70. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_track.py +0 -0
  71. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
  72. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_tracks_by_ids.py +0 -0
  73. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
  74. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  75. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/input_types.py +0 -0
  76. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  77. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  78. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  79. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  80. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  81. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  82. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  83. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  84. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  85. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  86. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  87. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  88. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  89. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  90. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  91. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/search.py +0 -0
  92. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/search_flows.py +0 -0
  93. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/set_flow_discovery_tuner.py +0 -0
  94. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/unban_artist_from_recommendation.py +0 -0
  95. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/unban_track_from_recommendation.py +0 -0
  96. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  97. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/generated/update_playlist.py +0 -0
  98. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql/py.typed +0 -0
  99. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  100. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  101. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
  102. {deezer_python_gql-0.19.0 → deezer_python_gql-0.21.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.21.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
@@ -16,13 +16,13 @@ Requires-Dist: pydantic>=2.0.0
16
16
  Requires-Dist: yarl>=1.22.0
17
17
  Provides-Extra: test
18
18
  Requires-Dist: codespell==2.4.3; extra == "test"
19
- Requires-Dist: mypy==2.3.1; extra == "test"
20
- Requires-Dist: pre-commit==4.6.2; extra == "test"
19
+ Requires-Dist: mypy==2.4.0; extra == "test"
20
+ Requires-Dist: pre-commit==4.7.0; extra == "test"
21
21
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
22
22
  Requires-Dist: pytest==9.1.1; extra == "test"
23
- Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
+ Requires-Dist: pytest-asyncio==1.4.1; 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.17.2; 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
 
@@ -190,6 +191,7 @@ Keep a track or artist out of the user's recommendations (Flow, mixes).
190
191
  | `delete_playlist(playlist_id)` | Delete a playlist |
191
192
  | `add_tracks_to_playlist(playlist_id, track_ids)` | Add tracks to a playlist |
192
193
  | `remove_tracks_from_playlist(playlist_id, ...)` | Remove tracks from a playlist |
194
+ | `move_track_in_playlist(playlist_id, ...)` | Move a track behind another one or to the top |
193
195
 
194
196
  ### Podcast Episode Management
195
197
 
@@ -286,6 +288,21 @@ If the ARL is rejected (bad or expired), the client raises
286
288
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
287
289
  Transient server errors raise `GraphQLClientHttpError` instead.
288
290
 
291
+ ### Deezer Family profiles
292
+
293
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
294
+ Family admin. Pass `account_id` to act as one of them:
295
+
296
+ ```python
297
+ family = (await client.get_family()).family
298
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
299
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
300
+ ```
301
+
302
+ Members with their own login (independent accounts) are not loggable, they need their
303
+ own ARL. If Deezer does not sign in as the profile, the client raises
304
+ `GraphQLClientAuthError`.
305
+
289
306
  ## License
290
307
 
291
308
  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
 
@@ -161,6 +162,7 @@ Keep a track or artist out of the user's recommendations (Flow, mixes).
161
162
  | `delete_playlist(playlist_id)` | Delete a playlist |
162
163
  | `add_tracks_to_playlist(playlist_id, track_ids)` | Add tracks to a playlist |
163
164
  | `remove_tracks_from_playlist(playlist_id, ...)` | Remove tracks from a playlist |
165
+ | `move_track_in_playlist(playlist_id, ...)` | Move a track behind another one or to the top |
164
166
 
165
167
  ### Podcast Episode Management
166
168
 
@@ -257,6 +259,21 @@ If the ARL is rejected (bad or expired), the client raises
257
259
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
258
260
  Transient server errors raise `GraphQLClientHttpError` instead.
259
261
 
262
+ ### Deezer Family profiles
263
+
264
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
265
+ Family admin. Pass `account_id` to act as one of them:
266
+
267
+ ```python
268
+ family = (await client.get_family()).family
269
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
270
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
271
+ ```
272
+
273
+ Members with their own login (independent accounts) are not loggable, they need their
274
+ own ARL. If Deezer does not sign in as the profile, the client raises
275
+ `GraphQLClientAuthError`.
276
+
260
277
  ## License
261
278
 
262
279
  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,
@@ -552,6 +561,10 @@ from .mark_as_played_podcast_episode import (
552
561
  MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisode,
553
562
  MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisodeBookmark,
554
563
  )
564
+ from .move_track_in_playlist import (
565
+ MoveTrackInPlaylist,
566
+ MoveTrackInPlaylistMoveTrackInPlaylist,
567
+ )
555
568
  from .music_together_create_group import (
556
569
  MusicTogetherCreateGroup,
557
570
  MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupError,
@@ -759,6 +772,8 @@ __all__ = [
759
772
  "DeletePlaylistDeletePlaylist",
760
773
  "DiscoveryTuner",
761
774
  "DiscoveryTunerInput",
775
+ "FamilyProfileFields",
776
+ "FamilyProfileFieldsPermissions",
762
777
  "GetAlbum",
763
778
  "GetAlbumAlbum",
764
779
  "GetAlbumAlbumFallback",
@@ -837,6 +852,11 @@ __all__ = [
837
852
  "GetChartsChartsCountryTracksEdges",
838
853
  "GetChartsChartsCountryTracksEdgesNode",
839
854
  "GetChartsChartsCountryTracksPageInfo",
855
+ "GetFamily",
856
+ "GetFamilyMe",
857
+ "GetFamilyMeFamily",
858
+ "GetFamilyMeFamilyLinked",
859
+ "GetFamilyMeFamilyMain",
840
860
  "GetFavoriteAlbums",
841
861
  "GetFavoriteAlbumsMe",
842
862
  "GetFavoriteAlbumsMeUserFavorites",
@@ -1127,6 +1147,8 @@ __all__ = [
1127
1147
  "MarkAsPlayedPodcastEpisode",
1128
1148
  "MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisode",
1129
1149
  "MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisodeBookmark",
1150
+ "MoveTrackInPlaylist",
1151
+ "MoveTrackInPlaylistMoveTrackInPlaylist",
1130
1152
  "MusicTogetherCreateGroup",
1131
1153
  "MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupError",
1132
1154
  "MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupOutput",
@@ -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
@@ -126,6 +127,10 @@ from .mark_as_played_podcast_episode import (
126
127
  MarkAsPlayedPodcastEpisode,
127
128
  MarkAsPlayedPodcastEpisodeMarkAsPlayedPodcastEpisode,
128
129
  )
130
+ from .move_track_in_playlist import (
131
+ MoveTrackInPlaylist,
132
+ MoveTrackInPlaylistMoveTrackInPlaylist,
133
+ )
129
134
  from .music_together_create_group import (
130
135
  MusicTogetherCreateGroup,
131
136
  MusicTogetherCreateGroupMusicTogetherCreateGroupMusicTogetherCreateGroupError,
@@ -1475,6 +1480,38 @@ class DeezerGQLClient(DeezerBaseClient):
1475
1480
  data = self.get_data(response)
1476
1481
  return GetCharts.model_validate(data).charts
1477
1482
 
1483
+ async def get_family(self, **kwargs: Any) -> Optional[GetFamilyMe]:
1484
+ query = gql("""
1485
+ query GetFamily {
1486
+ me {
1487
+ id
1488
+ family {
1489
+ main {
1490
+ ...FamilyProfileFields
1491
+ }
1492
+ linked {
1493
+ ...FamilyProfileFields
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ fragment FamilyProfileFields on FamilyUser {
1500
+ id
1501
+ name
1502
+ caption
1503
+ permissions {
1504
+ isLoggableAs
1505
+ }
1506
+ }
1507
+ """)
1508
+ variables: dict[str, object] = {}
1509
+ response = await self.execute(
1510
+ query=query, operation_name="GetFamily", variables=variables, **kwargs
1511
+ )
1512
+ data = self.get_data(response)
1513
+ return GetFamily.model_validate(data).me
1514
+
1478
1515
  async def get_favorite_albums(
1479
1516
  self,
1480
1517
  first: Union[Optional[int], UnsetType] = UNSET,
@@ -4402,6 +4439,36 @@ class DeezerGQLClient(DeezerBaseClient):
4402
4439
  data = self.get_data(response)
4403
4440
  return RemoveTracksFromPlaylist.model_validate(data).remove_tracks_from_playlist
4404
4441
 
4442
+ async def move_track_in_playlist(
4443
+ self,
4444
+ playlist_id: str,
4445
+ track_id: str,
4446
+ after_track_id: Union[Optional[str], UnsetType] = UNSET,
4447
+ **kwargs: Any,
4448
+ ) -> MoveTrackInPlaylistMoveTrackInPlaylist:
4449
+ query = gql("""
4450
+ mutation MoveTrackInPlaylist($playlistId: String!, $trackId: String!, $afterTrackId: String) {
4451
+ moveTrackInPlaylist(
4452
+ input: {playlistId: $playlistId, trackId: $trackId, afterTrackId: $afterTrackId}
4453
+ ) {
4454
+ moveStatus
4455
+ }
4456
+ }
4457
+ """)
4458
+ variables: dict[str, object] = {
4459
+ "playlistId": playlist_id,
4460
+ "trackId": track_id,
4461
+ "afterTrackId": after_track_id,
4462
+ }
4463
+ response = await self.execute(
4464
+ query=query,
4465
+ operation_name="MoveTrackInPlaylist",
4466
+ variables=variables,
4467
+ **kwargs,
4468
+ )
4469
+ data = self.get_data(response)
4470
+ return MoveTrackInPlaylist.model_validate(data).move_track_in_playlist
4471
+
4405
4472
  async def ban_track_from_recommendation(
4406
4473
  self, track_id: str, **kwargs: Any
4407
4474
  ) -> BanTrackFromRecommendationBanTrackFromRecommendation:
@@ -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()
@@ -0,0 +1,21 @@
1
+ # Generated by ariadne-codegen
2
+ # Source: queries
3
+
4
+ from typing import Optional
5
+
6
+ from pydantic import Field
7
+
8
+ from .base_model import BaseModel
9
+
10
+
11
+ class MoveTrackInPlaylist(BaseModel):
12
+ move_track_in_playlist: "MoveTrackInPlaylistMoveTrackInPlaylist" = Field(
13
+ alias="moveTrackInPlaylist"
14
+ )
15
+
16
+
17
+ class MoveTrackInPlaylistMoveTrackInPlaylist(BaseModel):
18
+ move_status: Optional[bool] = Field(alias="moveStatus")
19
+
20
+
21
+ MoveTrackInPlaylist.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.21.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
@@ -16,13 +16,13 @@ Requires-Dist: pydantic>=2.0.0
16
16
  Requires-Dist: yarl>=1.22.0
17
17
  Provides-Extra: test
18
18
  Requires-Dist: codespell==2.4.3; extra == "test"
19
- Requires-Dist: mypy==2.3.1; extra == "test"
20
- Requires-Dist: pre-commit==4.6.2; extra == "test"
19
+ Requires-Dist: mypy==2.4.0; extra == "test"
20
+ Requires-Dist: pre-commit==4.7.0; extra == "test"
21
21
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
22
22
  Requires-Dist: pytest==9.1.1; extra == "test"
23
- Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
+ Requires-Dist: pytest-asyncio==1.4.1; 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.17.2; 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
 
@@ -190,6 +191,7 @@ Keep a track or artist out of the user's recommendations (Flow, mixes).
190
191
  | `delete_playlist(playlist_id)` | Delete a playlist |
191
192
  | `add_tracks_to_playlist(playlist_id, track_ids)` | Add tracks to a playlist |
192
193
  | `remove_tracks_from_playlist(playlist_id, ...)` | Remove tracks from a playlist |
194
+ | `move_track_in_playlist(playlist_id, ...)` | Move a track behind another one or to the top |
193
195
 
194
196
  ### Podcast Episode Management
195
197
 
@@ -286,6 +288,21 @@ If the ARL is rejected (bad or expired), the client raises
286
288
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
287
289
  Transient server errors raise `GraphQLClientHttpError` instead.
288
290
 
291
+ ### Deezer Family profiles
292
+
293
+ Profiles of a Deezer Family have no ARL of their own, they share the login of the
294
+ Family admin. Pass `account_id` to act as one of them:
295
+
296
+ ```python
297
+ family = (await client.get_family()).family
298
+ profile = next(m for m in family.linked if m.permissions.is_loggable_as)
299
+ profile_client = DeezerGQLClient(arl="ADMIN_ARL", account_id=profile.id)
300
+ ```
301
+
302
+ Members with their own login (independent accounts) are not loggable, they need their
303
+ own ARL. If Deezer does not sign in as the profile, the client raises
304
+ `GraphQLClientAuthError`.
305
+
289
306
  ## License
290
307
 
291
308
  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
@@ -75,6 +76,7 @@ deezer_python_gql/generated/get_user_playlists.py
75
76
  deezer_python_gql/generated/input_types.py
76
77
  deezer_python_gql/generated/mark_as_not_played_podcast_episode.py
77
78
  deezer_python_gql/generated/mark_as_played_podcast_episode.py
79
+ deezer_python_gql/generated/move_track_in_playlist.py
78
80
  deezer_python_gql/generated/music_together_create_group.py
79
81
  deezer_python_gql/generated/music_together_generate_group_name.py
80
82
  deezer_python_gql/generated/music_together_join_group.py
@@ -7,10 +7,10 @@ ariadne-codegen[subscriptions]
7
7
 
8
8
  [test]
9
9
  codespell==2.4.3
10
- mypy==2.3.1
11
- pre-commit==4.6.2
10
+ mypy==2.4.0
11
+ pre-commit==4.7.0
12
12
  pre-commit-hooks==6.0.0
13
13
  pytest==9.1.1
14
- pytest-asyncio==1.4.0
14
+ pytest-asyncio==1.4.1
15
15
  pytest-cov==7.1.0
16
- ruff==0.16.8
16
+ ruff==0.17.2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.19.0"
3
+ version = "0.21.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"
@@ -24,13 +24,13 @@ text = "Apache-2.0"
24
24
  [project.optional-dependencies]
25
25
  test = [
26
26
  "codespell==2.4.3",
27
- "mypy==2.3.1",
28
- "pre-commit==4.6.2",
27
+ "mypy==2.4.0",
28
+ "pre-commit==4.7.0",
29
29
  "pre-commit-hooks==6.0.0",
30
30
  "pytest==9.1.1",
31
- "pytest-asyncio==1.4.0",
31
+ "pytest-asyncio==1.4.1",
32
32
  "pytest-cov==7.1.0",
33
- "ruff==0.16.8",
33
+ "ruff==0.17.2",
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.17.2",
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
@@ -104,6 +105,7 @@ from deezer_python_gql.generated.mark_as_not_played_podcast_episode import (
104
105
  from deezer_python_gql.generated.mark_as_played_podcast_episode import (
105
106
  MarkAsPlayedPodcastEpisode,
106
107
  )
108
+ from deezer_python_gql.generated.move_track_in_playlist import MoveTrackInPlaylist
107
109
  from deezer_python_gql.generated.music_together_create_group import (
108
110
  MusicTogetherCreateGroup,
109
111
  )
@@ -147,11 +149,14 @@ def _load_fixture(name: str) -> dict[str, Any]:
147
149
  return result
148
150
 
149
151
 
150
- def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
152
+ def _make_jwt(
153
+ exp: float | None = None, subject: str | None = None, user_id: str | None = None
154
+ ) -> str:
151
155
  """
152
156
  Build a fake JWT with a configurable expiration timestamp.
153
157
 
154
158
  :param exp: Unix timestamp for JWT expiry. Defaults to 6 min from now.
159
+ :param user_id: Optional ``userId`` claim, which Deezer sets to the account id.
155
160
  """
156
161
  if exp is None:
157
162
  exp = time.time() + 360 # 6 min, matching Deezer's real TTL
@@ -159,6 +164,8 @@ def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
159
164
  payload_data: dict[str, float | str] = {"exp": exp}
160
165
  if subject is not None:
161
166
  payload_data["sub"] = subject
167
+ if user_id is not None:
168
+ payload_data["userId"] = user_id
162
169
  payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=").decode()
163
170
  return f"{header}.{payload}.fake_signature"
164
171
 
@@ -334,6 +341,7 @@ def test_client_has_generated_methods() -> None:
334
341
  "delete_playlist",
335
342
  "add_tracks_to_playlist",
336
343
  "remove_tracks_from_playlist",
344
+ "move_track_in_playlist",
337
345
  "get_podcast",
338
346
  "get_podcast_episode",
339
347
  "get_podcast_episodes_by_ids",
@@ -525,6 +533,39 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
525
533
  assert auth_call.kwargs["cookies"] == {"arl": "my_secret_arl"}
526
534
 
527
535
 
536
+ @pytest.mark.asyncio
537
+ async def test_auth_family_profile_signs_in_with_account_id() -> None:
538
+ """Verify a Family profile sends the admin's ARL and its id in the payload."""
539
+ client = DeezerBaseClient(arl="admin_arl", account_id="456")
540
+
541
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
542
+ mock_instance = MagicMock()
543
+ mock_instance.post = MagicMock(return_value=_mock_auth_cm(_make_jwt(user_id="456")))
544
+ mock_instance.close = AsyncMock()
545
+ mock_cls.return_value = mock_instance
546
+
547
+ await client._ensure_jwt() # noqa: SLF001
548
+
549
+ auth_call = mock_instance.post.call_args
550
+ assert auth_call.kwargs["params"]["i"] == "p"
551
+ assert auth_call.kwargs["json"] == {"arl": "admin_arl", "account_id": "456"}
552
+
553
+
554
+ @pytest.mark.asyncio
555
+ async def test_auth_family_profile_rejects_a_token_for_another_account() -> None:
556
+ """Verify a token for another account is never used for a Family profile."""
557
+ client = DeezerBaseClient(arl="admin_arl", account_id="456")
558
+
559
+ with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
560
+ mock_instance = MagicMock()
561
+ mock_instance.post = MagicMock(return_value=_mock_auth_cm(_make_jwt(user_id="123")))
562
+ mock_instance.close = AsyncMock()
563
+ mock_cls.return_value = mock_instance
564
+
565
+ with pytest.raises(GraphQLClientAuthError, match="Family profile 456"):
566
+ await client._ensure_jwt() # noqa: SLF001
567
+
568
+
528
569
  @pytest.mark.parametrize(("first_arl", "second_arl"), [("arl-a", "arl-b"), ("arl-b", "arl-a")])
529
570
  @pytest.mark.asyncio
530
571
  async def test_shared_session_keeps_account_cookies_isolated(
@@ -1484,6 +1525,13 @@ def test_smoke_remove_tracks_from_playlist() -> None:
1484
1525
  assert result.remove_tracks_from_playlist.removed_track_ids == ["100000001", "100000002"]
1485
1526
 
1486
1527
 
1528
+ def test_smoke_move_track_in_playlist() -> None:
1529
+ """Verify MoveTrackInPlaylist fixture parses with move status."""
1530
+ data = _load_fixture("move_track_in_playlist.json")
1531
+ result = MoveTrackInPlaylist.model_validate(data)
1532
+ assert result.move_track_in_playlist.move_status is True
1533
+
1534
+
1487
1535
  def test_smoke_get_livestream() -> None:
1488
1536
  """Verify GetLivestream fixture parses with fields and media URLs."""
1489
1537
  data = _load_fixture("get_livestream.json")
@@ -1969,3 +2017,16 @@ def test_smoke_get_infinite_track_mix() -> None:
1969
2017
  data = _load_fixture("get_infinite_track_mix.json")
1970
2018
  mix = GetInfiniteTrackMix.model_validate(data).raw_infinite_track_mix
1971
2019
  assert [t.id for t in mix.tracks] == ["3135554", "3155977", "10284909"]
2020
+
2021
+
2022
+ def test_smoke_get_family() -> None:
2023
+ """Verify GetFamily fixture parses the admin, a profile and an independent account."""
2024
+ data = _load_fixture("get_family.json")
2025
+ me = GetFamily.model_validate(data).me
2026
+ assert me is not None
2027
+ assert me.family is not None
2028
+ assert me.family.main.id == me.id
2029
+ assert [(m.id, m.permissions.is_loggable_as) for m in me.family.linked] == [
2030
+ ("2002", True),
2031
+ ("3003", False),
2032
+ ]