deezer-python-gql 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -14,14 +14,14 @@ License-File: LICENSE
14
14
  Requires-Dist: httpx>=0.27.0
15
15
  Requires-Dist: pydantic>=2.0.0
16
16
  Provides-Extra: test
17
- Requires-Dist: codespell==2.4.1; extra == "test"
17
+ Requires-Dist: codespell==2.4.2; extra == "test"
18
18
  Requires-Dist: mypy==1.19.1; extra == "test"
19
19
  Requires-Dist: pre-commit==4.5.1; extra == "test"
20
20
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
21
  Requires-Dist: pytest==9.0.2; extra == "test"
22
22
  Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
23
- Requires-Dist: pytest-cov==7.0.0; extra == "test"
24
- Requires-Dist: ruff==0.15.1; extra == "test"
23
+ Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
+ Requires-Dist: ruff==0.15.7; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -79,14 +79,26 @@ asyncio.run(main())
79
79
 
80
80
  ## Available Queries
81
81
 
82
- | Method | Description |
83
- | --------------------------- | ------------------------------------------------------------- |
84
- | `get_me()` | Current authenticated user |
85
- | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
86
- | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
87
- | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
88
- | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
89
- | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
82
+ | Method | Description |
83
+ | ------------------------------------------------ | ------------------------------------------------------------- |
84
+ | `get_me()` | Current authenticated user |
85
+ | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
86
+ | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
87
+ | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
88
+ | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
89
+ | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
90
+ | `get_flow()` | User's default Flow with tracks |
91
+ | `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
92
+ | `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
93
+ | `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
94
+ | `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
95
+ | `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
96
+ | `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
97
+ | `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
98
+ | `get_favorite_artists(first, after)` | Paginated favorite artists |
99
+ | `get_favorite_albums(first, after)` | Paginated favorite albums |
100
+ | `get_favorite_tracks(first, after)` | Paginated favorite tracks |
101
+ | `get_favorite_playlists(first, after)` | Paginated favorite playlists |
90
102
 
91
103
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
92
104
 
@@ -51,14 +51,26 @@ asyncio.run(main())
51
51
 
52
52
  ## Available Queries
53
53
 
54
- | Method | Description |
55
- | --------------------------- | ------------------------------------------------------------- |
56
- | `get_me()` | Current authenticated user |
57
- | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
58
- | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
59
- | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
60
- | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
61
- | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
54
+ | Method | Description |
55
+ | ------------------------------------------------ | ------------------------------------------------------------- |
56
+ | `get_me()` | Current authenticated user |
57
+ | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
58
+ | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
59
+ | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
60
+ | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
61
+ | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
62
+ | `get_flow()` | User's default Flow with tracks |
63
+ | `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
64
+ | `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
65
+ | `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
66
+ | `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
67
+ | `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
68
+ | `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
69
+ | `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
70
+ | `get_favorite_artists(first, after)` | Paginated favorite artists |
71
+ | `get_favorite_albums(first, after)` | Paginated favorite albums |
72
+ | `get_favorite_tracks(first, after)` | Paginated favorite tracks |
73
+ | `get_favorite_playlists(first, after)` | Paginated favorite playlists |
62
74
 
63
75
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
64
76
 
@@ -3,12 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import time
7
8
  from base64 import urlsafe_b64decode
8
- from typing import Any, cast
9
+ from typing import Any, ClassVar, cast
9
10
 
10
11
  import httpx
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  class GraphQLClientError(Exception):
14
17
  """Base exception for GraphQL client errors."""
@@ -110,6 +113,7 @@ class DeezerBaseClient:
110
113
  :param variables: Optional query variables.
111
114
  :param kwargs: Additional keyword arguments passed to httpx.
112
115
  """
116
+ logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
113
117
  jwt = await self._ensure_jwt()
114
118
 
115
119
  headers: dict[str, str] = kwargs.pop("headers", None) or {}
@@ -120,16 +124,29 @@ class DeezerBaseClient:
120
124
  if operation_name:
121
125
  payload["operationName"] = operation_name
122
126
  if variables:
123
- payload["variables"] = variables
127
+ # Filter out UNSET sentinel values that are not JSON-serializable.
128
+ # Inline import avoids depending on generated code at module level.
129
+ from deezer_python_gql.generated.base_model import UnsetType # noqa: PLC0415
130
+
131
+ payload["variables"] = {
132
+ k: v for k, v in variables.items() if not isinstance(v, UnsetType)
133
+ }
124
134
 
125
135
  client = self._http_client or httpx.AsyncClient()
126
136
  try:
127
- return await client.post(
137
+ resp = await client.post(
128
138
  self.url,
129
139
  json=payload,
130
140
  headers=headers,
131
141
  **kwargs,
132
142
  )
143
+ logger.debug(
144
+ "GQL response: %s status=%s length=%s",
145
+ operation_name or "<unnamed>",
146
+ resp.status_code,
147
+ len(resp.content),
148
+ )
149
+ return resp
133
150
  finally:
134
151
  if not self._http_client:
135
152
  await client.aclose()
@@ -159,10 +176,52 @@ class DeezerBaseClient:
159
176
  errors = response_json.get("errors")
160
177
 
161
178
  if errors:
162
- raise GraphQLClientGraphQLMultiError.from_errors_dicts(errors_dicts=errors, data=data)
179
+ if data:
180
+ # Partial success — some items failed (e.g. deleted albums in favorites).
181
+ # Log the errors but return the valid data.
182
+ logger.warning(
183
+ "GraphQL response contained %d error(s): %s",
184
+ len(errors),
185
+ [e.get("message", "Unknown error") for e in errors],
186
+ )
187
+ else:
188
+ raise GraphQLClientGraphQLMultiError.from_errors_dicts(
189
+ errors_dicts=errors, data=data
190
+ )
191
+
192
+ # The Deezer API omits __typename for single-member union types
193
+ # (e.g. Contributor = Artist). Pydantic discriminated unions require it,
194
+ # so we inject the missing field before model validation.
195
+ self._inject_missing_typenames(data)
163
196
 
164
197
  return cast("dict[str, Any]", data)
165
198
 
199
+ # Map of parent key → child key → __typename value for single-member unions
200
+ # where the Deezer API omits the discriminator.
201
+ _TYPENAME_PATCHES: ClassVar[dict[str, dict[str, str]]] = {
202
+ "contributors": {"node": "Artist"},
203
+ }
204
+
205
+ @classmethod
206
+ def _inject_missing_typenames(cls, obj: Any) -> None:
207
+ """Recursively inject __typename into nodes where the API omits it."""
208
+ if isinstance(obj, dict):
209
+ for parent_key, patches in cls._TYPENAME_PATCHES.items():
210
+ if parent_key in obj:
211
+ container = obj[parent_key]
212
+ if isinstance(container, dict) and "edges" in container:
213
+ for edge in container["edges"]:
214
+ if isinstance(edge, dict):
215
+ for child_key, typename in patches.items():
216
+ node = edge.get(child_key)
217
+ if isinstance(node, dict) and "__typename" not in node:
218
+ node["__typename"] = typename
219
+ for value in obj.values():
220
+ cls._inject_missing_typenames(value)
221
+ elif isinstance(obj, list):
222
+ for item in obj:
223
+ cls._inject_missing_typenames(item)
224
+
166
225
  async def _ensure_jwt(self) -> str:
167
226
  """Acquire or refresh the JWT token from ARL cookie.
168
227
 
@@ -174,6 +233,7 @@ class DeezerBaseClient:
174
233
  if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
175
234
  return self._jwt
176
235
 
236
+ logger.debug("JWT expired or missing, refreshing from ARL")
177
237
  params = {"jo": "p", "rto": "c", "i": "c"}
178
238
 
179
239
  async with httpx.AsyncClient() as http:
@@ -194,5 +254,6 @@ class DeezerBaseClient:
194
254
  padded = payload_segment + "=" * (-len(payload_segment) % 4)
195
255
  payload = json.loads(urlsafe_b64decode(padded))
196
256
  self._jwt_expires_at = float(payload["exp"])
257
+ logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
197
258
 
198
259
  return self._jwt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -14,14 +14,14 @@ License-File: LICENSE
14
14
  Requires-Dist: httpx>=0.27.0
15
15
  Requires-Dist: pydantic>=2.0.0
16
16
  Provides-Extra: test
17
- Requires-Dist: codespell==2.4.1; extra == "test"
17
+ Requires-Dist: codespell==2.4.2; extra == "test"
18
18
  Requires-Dist: mypy==1.19.1; extra == "test"
19
19
  Requires-Dist: pre-commit==4.5.1; extra == "test"
20
20
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
21
  Requires-Dist: pytest==9.0.2; extra == "test"
22
22
  Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
23
- Requires-Dist: pytest-cov==7.0.0; extra == "test"
24
- Requires-Dist: ruff==0.15.1; extra == "test"
23
+ Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
+ Requires-Dist: ruff==0.15.7; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -79,14 +79,26 @@ asyncio.run(main())
79
79
 
80
80
  ## Available Queries
81
81
 
82
- | Method | Description |
83
- | --------------------------- | ------------------------------------------------------------- |
84
- | `get_me()` | Current authenticated user |
85
- | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
86
- | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
87
- | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
88
- | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
89
- | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
82
+ | Method | Description |
83
+ | ------------------------------------------------ | ------------------------------------------------------------- |
84
+ | `get_me()` | Current authenticated user |
85
+ | `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
86
+ | `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
87
+ | `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
88
+ | `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
89
+ | `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
90
+ | `get_flow()` | User's default Flow with tracks |
91
+ | `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
92
+ | `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
93
+ | `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
94
+ | `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
95
+ | `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
96
+ | `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
97
+ | `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
98
+ | `get_favorite_artists(first, after)` | Paginated favorite artists |
99
+ | `get_favorite_albums(first, after)` | Paginated favorite albums |
100
+ | `get_favorite_tracks(first, after)` | Paginated favorite tracks |
101
+ | `get_favorite_playlists(first, after)` | Paginated favorite playlists |
90
102
 
91
103
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
92
104
 
@@ -5,11 +5,11 @@ pydantic>=2.0.0
5
5
  ariadne-codegen[subscriptions]
6
6
 
7
7
  [test]
8
- codespell==2.4.1
8
+ codespell==2.4.2
9
9
  mypy==1.19.1
10
10
  pre-commit==4.5.1
11
11
  pre-commit-hooks==6.0.0
12
12
  pytest==9.0.2
13
13
  pytest-asyncio==1.3.0
14
- pytest-cov==7.0.0
15
- ruff==0.15.1
14
+ pytest-cov==7.1.0
15
+ ruff==0.15.7
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.1.0"
3
+ version = "0.2.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"
@@ -22,14 +22,14 @@ text = "Apache-2.0"
22
22
 
23
23
  [project.optional-dependencies]
24
24
  test = [
25
- "codespell==2.4.1",
25
+ "codespell==2.4.2",
26
26
  "mypy==1.19.1",
27
27
  "pre-commit==4.5.1",
28
28
  "pre-commit-hooks==6.0.0",
29
29
  "pytest==9.0.2",
30
30
  "pytest-asyncio==1.3.0",
31
- "pytest-cov==7.0.0",
32
- "ruff==0.15.1",
31
+ "pytest-cov==7.1.0",
32
+ "ruff==0.15.7",
33
33
  ]
34
34
  dev = [
35
35
  "ariadne-codegen[subscriptions]",
@@ -28,10 +28,24 @@ from deezer_python_gql.base_client import (
28
28
  )
29
29
  from deezer_python_gql.generated.get_album import GetAlbum
30
30
  from deezer_python_gql.generated.get_artist import GetArtist
31
+ from deezer_python_gql.generated.get_charts import GetCharts
32
+ from deezer_python_gql.generated.get_favorite_albums import GetFavoriteAlbums
33
+ from deezer_python_gql.generated.get_favorite_artists import GetFavoriteArtists
34
+ from deezer_python_gql.generated.get_favorite_playlists import GetFavoritePlaylists
35
+ from deezer_python_gql.generated.get_favorite_tracks import GetFavoriteTracks
36
+ from deezer_python_gql.generated.get_flow import GetFlow
37
+ from deezer_python_gql.generated.get_flow_config_tracks import GetFlowConfigTracks
38
+ from deezer_python_gql.generated.get_flow_configs import GetFlowConfigs
39
+ from deezer_python_gql.generated.get_made_for_me import GetMadeForMe
31
40
  from deezer_python_gql.generated.get_me import GetMe
32
41
  from deezer_python_gql.generated.get_playlist import GetPlaylist
42
+ from deezer_python_gql.generated.get_recently_played import GetRecentlyPlayed
43
+ from deezer_python_gql.generated.get_recommendations import GetRecommendations
44
+ from deezer_python_gql.generated.get_smart_tracklist import GetSmartTracklist
33
45
  from deezer_python_gql.generated.get_track import GetTrack
46
+ from deezer_python_gql.generated.get_user_charts import GetUserCharts
34
47
  from deezer_python_gql.generated.search import Search
48
+ from deezer_python_gql.generated.search_flows import SearchFlows
35
49
 
36
50
  FIXTURES = Path(__file__).parent / "fixtures"
37
51
 
@@ -96,7 +110,28 @@ def test_client_instantiation() -> None:
96
110
  def test_client_has_generated_methods() -> None:
97
111
  """Verify that codegen produced all expected query methods."""
98
112
  client = DeezerGQLClient(arl="test")
99
- expected_methods = ["get_me", "get_track", "get_album", "get_artist", "get_playlist", "search"]
113
+ expected_methods = [
114
+ "get_me",
115
+ "get_track",
116
+ "get_album",
117
+ "get_artist",
118
+ "get_playlist",
119
+ "search",
120
+ "get_flow",
121
+ "get_flow_configs",
122
+ "get_flow_config_tracks",
123
+ "get_made_for_me",
124
+ "get_smart_tracklist",
125
+ "get_charts",
126
+ "get_recommendations",
127
+ "get_recently_played",
128
+ "get_favorite_artists",
129
+ "get_favorite_albums",
130
+ "get_favorite_tracks",
131
+ "get_favorite_playlists",
132
+ "search_flows",
133
+ "get_user_charts",
134
+ ]
100
135
  for method in expected_methods:
101
136
  assert hasattr(client, method), f"Missing method: {method}"
102
137
  assert callable(getattr(client, method))
@@ -404,3 +439,231 @@ def test_smoke_search() -> None:
404
439
  assert len(results.artists.edges) > 0
405
440
  assert len(results.playlists.edges) > 0
406
441
  assert isinstance(results.tracks.page_info.has_next_page, bool)
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # 5. Browse-related model smoke tests (new queries)
446
+ # ---------------------------------------------------------------------------
447
+
448
+
449
+ def test_smoke_get_flow() -> None:
450
+ """Verify GetFlow fixture parses with flow tracks."""
451
+ data = _load_fixture("get_flow.json")
452
+ me = GetFlow.model_validate(data).me
453
+ assert me is not None
454
+ assert me.flow is not None
455
+ assert me.flow.id == "flow:default"
456
+ assert me.flow.title == "Flow"
457
+ assert len(me.flow.tracks) == 2
458
+ assert me.flow.tracks[0].track is not None
459
+ assert me.flow.tracks[0].track.title == "Harder, Better, Faster, Stronger"
460
+
461
+
462
+ def test_smoke_get_flow_configs() -> None:
463
+ """Verify GetFlowConfigs fixture parses mood and genre flow configs."""
464
+ data = _load_fixture("get_flow_configs.json")
465
+ me = GetFlowConfigs.model_validate(data).me
466
+ assert me is not None
467
+ configs = me.flow_configs
468
+ assert len(configs.moods.edges) == 3
469
+ assert len(configs.genres.edges) == 3
470
+ mood_node = configs.moods.edges[0].node
471
+ assert mood_node is not None
472
+ assert mood_node.title == "Chill"
473
+ genre_node = configs.genres.edges[0].node
474
+ assert genre_node is not None
475
+ assert genre_node.title == "Rock"
476
+ assert configs.moods.page_info.has_next_page is True
477
+
478
+
479
+ def test_smoke_get_flow_config_tracks() -> None:
480
+ """Verify GetFlowConfigTracks fixture parses tracks for a flow config."""
481
+ data = _load_fixture("get_flow_config_tracks.json")
482
+ flow_config = GetFlowConfigTracks.model_validate(data).flow_config
483
+ assert flow_config is not None
484
+ assert flow_config.id == "flow_config:chill"
485
+ assert flow_config.title == "Chill"
486
+ assert len(flow_config.tracks) == 1
487
+ assert flow_config.tracks[0].track is not None
488
+ assert flow_config.tracks[0].track.title == "Around the World"
489
+
490
+
491
+ def test_smoke_get_made_for_me() -> None:
492
+ """Verify GetMadeForMe fixture parses SmartTracklist and Flow items."""
493
+ data = _load_fixture("get_made_for_me.json")
494
+ me = GetMadeForMe.model_validate(data).me
495
+ assert me is not None
496
+ edges = me.made_for_me.edges
497
+ assert len(edges) == 3
498
+ # First two are SmartTracklists, third is a Flow
499
+ node_0 = edges[0].node
500
+ assert node_0 is not None
501
+ assert node_0.typename__ == "SmartTracklist"
502
+ node_2 = edges[2].node
503
+ assert node_2 is not None
504
+ assert node_2.typename__ == "Flow"
505
+
506
+
507
+ def test_smoke_get_smart_tracklist() -> None:
508
+ """Verify GetSmartTracklist fixture parses with paginated tracks."""
509
+ data = _load_fixture("get_smart_tracklist.json")
510
+ st = GetSmartTracklist.model_validate(data).smart_tracklist
511
+ assert st is not None
512
+ assert st.id == "smart:daily_mix_1"
513
+ assert st.title == "Your Daily Mix 1"
514
+ assert len(st.tracks.edges) == 2
515
+ track_node = st.tracks.edges[0].node
516
+ assert track_node is not None
517
+ assert track_node.title == "Harder, Better, Faster, Stronger"
518
+ assert st.tracks.page_info.has_next_page is True
519
+
520
+
521
+ def test_smoke_get_charts() -> None:
522
+ """Verify GetCharts fixture parses all chart categories."""
523
+ data = _load_fixture("get_charts.json")
524
+ charts = GetCharts.model_validate(data).charts
525
+ assert charts is not None
526
+ country = charts.country
527
+ assert country is not None
528
+ assert country.tracks is not None
529
+ assert country.albums is not None
530
+ assert country.artists is not None
531
+ assert country.playlists is not None
532
+ assert len(country.tracks.edges) > 0
533
+ assert len(country.albums.edges) > 0
534
+ assert len(country.artists.edges) > 0
535
+ assert len(country.playlists.edges) > 0
536
+ first_track = country.tracks.edges[0].node
537
+ assert first_track is not None
538
+ assert first_track.title == "Greedy"
539
+
540
+
541
+ def test_smoke_get_recommendations() -> None:
542
+ """Verify GetRecommendations fixture parses all recommendation categories."""
543
+ data = _load_fixture("get_recommendations.json")
544
+ me = GetRecommendations.model_validate(data).me
545
+ assert me is not None
546
+ reco = me.recommendations
547
+ assert len(reco.playlists.edges) > 0
548
+ assert len(reco.artist_playlists.edges) == 2
549
+ assert reco.artist_playlists.edges[0].node is not None
550
+ assert reco.artist_playlists.edges[0].node.title == "This Is Daft Punk"
551
+ assert len(reco.new_releases.edges) > 0
552
+ assert len(reco.artists.edges) > 0
553
+ assert reco.hot_tracks is not None
554
+ assert len(reco.hot_tracks) > 0
555
+ assert reco.hot_tracks[0].title == "Harder, Better, Faster, Stronger"
556
+
557
+
558
+ def test_smoke_get_recently_played() -> None:
559
+ """Verify GetRecentlyPlayed fixture parses mixed content types."""
560
+ data = _load_fixture("get_recently_played.json")
561
+ me = GetRecentlyPlayed.model_validate(data).me
562
+ assert me is not None
563
+ edges = me.recently_played.edges
564
+ assert len(edges) == 4
565
+ # Check discriminated union types
566
+ node_0 = edges[0].node
567
+ assert node_0 is not None
568
+ assert node_0.typename__ == "Album"
569
+ node_1 = edges[1].node
570
+ assert node_1 is not None
571
+ assert node_1.typename__ == "Playlist"
572
+ node_2 = edges[2].node
573
+ assert node_2 is not None
574
+ assert node_2.typename__ == "Artist"
575
+ node_3 = edges[3].node
576
+ assert node_3 is not None
577
+ assert node_3.typename__ == "Flow"
578
+
579
+
580
+ def test_smoke_get_favorite_artists() -> None:
581
+ """Verify GetFavoriteArtists fixture parses with pagination."""
582
+ data = _load_fixture("get_favorite_artists.json")
583
+ me = GetFavoriteArtists.model_validate(data).me
584
+ assert me is not None
585
+ artists = me.user_favorites.artists
586
+ assert artists is not None
587
+ assert len(artists.edges) == 2
588
+ artist_node = artists.edges[0].node
589
+ assert artist_node is not None
590
+ assert artist_node.name == "Daft Punk"
591
+ assert artists.page_info.has_next_page is True
592
+
593
+
594
+ def test_smoke_get_favorite_albums() -> None:
595
+ """Verify GetFavoriteAlbums fixture parses with pagination."""
596
+ data = _load_fixture("get_favorite_albums.json")
597
+ me = GetFavoriteAlbums.model_validate(data).me
598
+ assert me is not None
599
+ albums = me.user_favorites.albums
600
+ assert albums is not None
601
+ assert len(albums.edges) == 1
602
+ album_node = albums.edges[0].node
603
+ assert album_node is not None
604
+ assert album_node.display_title == "Discovery"
605
+ assert albums.page_info.has_next_page is True
606
+
607
+
608
+ def test_smoke_get_favorite_tracks() -> None:
609
+ """Verify GetFavoriteTracks fixture parses with pagination."""
610
+ data = _load_fixture("get_favorite_tracks.json")
611
+ me = GetFavoriteTracks.model_validate(data).me
612
+ assert me is not None
613
+ tracks = me.user_favorites.tracks
614
+ assert tracks is not None
615
+ assert len(tracks.edges) == 1
616
+ track_node = tracks.edges[0].node
617
+ assert track_node is not None
618
+ assert track_node.title == "Harder, Better, Faster, Stronger"
619
+ assert tracks.page_info.has_next_page is True
620
+
621
+
622
+ def test_smoke_get_favorite_playlists() -> None:
623
+ """Verify GetFavoritePlaylists fixture parses with pagination."""
624
+ data = _load_fixture("get_favorite_playlists.json")
625
+ me = GetFavoritePlaylists.model_validate(data).me
626
+ assert me is not None
627
+ playlists = me.user_favorites.playlists
628
+ assert playlists is not None
629
+ assert len(playlists.edges) == 1
630
+ playlist_node = playlists.edges[0].node
631
+ assert playlist_node is not None
632
+ assert playlist_node.title == "Electronic Hits"
633
+ assert playlists.page_info.has_next_page is True
634
+
635
+
636
+ def test_smoke_search_flows() -> None:
637
+ """Verify SearchFlows fixture parses with flow config nodes."""
638
+ data = _load_fixture("search_flows.json")
639
+ search = SearchFlows.model_validate(data).search
640
+ assert search is not None
641
+ flow_configs = search.results.flow_configs
642
+ assert len(flow_configs.edges) == 5
643
+ first_node = flow_configs.edges[0].node
644
+ assert first_node is not None
645
+ assert first_node.id == "flow_config:chill"
646
+ assert first_node.title == "Chill"
647
+ assert first_node.visuals.hardware_square_icon is not None
648
+ assert len(first_node.visuals.hardware_square_icon.urls) == 1
649
+ assert flow_configs.page_info.has_next_page is True
650
+
651
+
652
+ def test_smoke_get_user_charts() -> None:
653
+ """Verify GetUserCharts fixture parses personal top tracks/artists/albums."""
654
+ data = _load_fixture("get_user_charts.json")
655
+ me = GetUserCharts.model_validate(data).me
656
+ assert me is not None
657
+ charts = me.charts
658
+ assert charts.tracks is not None
659
+ assert len(charts.tracks.edges) == 2
660
+ assert charts.tracks.edges[0].node is not None
661
+ assert charts.tracks.edges[0].node.title == "Harder, Better, Faster, Stronger"
662
+ assert charts.artists is not None
663
+ assert len(charts.artists.edges) == 2
664
+ assert charts.artists.edges[0].node is not None
665
+ assert charts.artists.edges[0].node.name == "Daft Punk"
666
+ assert charts.albums is not None
667
+ assert len(charts.albums.edges) == 1
668
+ assert charts.albums.edges[0].node is not None
669
+ assert charts.albums.edges[0].node.display_title == "Discovery"