innerspot 0.0.1.dev2__py3-none-any.whl
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.
- innerspot/__init__.py +515 -0
- innerspot/_auth.py +467 -0
- innerspot/_base.py +52 -0
- innerspot/_cache.py +75 -0
- innerspot/_constants.py +14 -0
- innerspot/_utils.py +133 -0
- innerspot/_version.py +34 -0
- innerspot/errors.py +55 -0
- innerspot/models.py +331 -0
- innerspot/proto/__init__.py +0 -0
- innerspot/proto/definitions/content_ratings.proto +25 -0
- innerspot/proto/definitions/entity_extension_data.proto +30 -0
- innerspot/proto/definitions/extended_metadata.proto +53 -0
- innerspot/proto/definitions/extension_kind.proto +238 -0
- innerspot/proto/definitions/metadata.proto +344 -0
- innerspot/proto/generated/__init__.py +0 -0
- innerspot/proto/generated/content_ratings_pb2.py +42 -0
- innerspot/proto/generated/entity_extension_data_pb2.py +45 -0
- innerspot/proto/generated/extended_metadata_pb2.py +54 -0
- innerspot/proto/generated/extension_kind_pb2.py +36 -0
- innerspot/proto/generated/metadata_pb2.py +109 -0
- innerspot/providers/__init__.py +5 -0
- innerspot/providers/partner.py +728 -0
- innerspot/providers/scraping.py +203 -0
- innerspot/providers/spclient.py +691 -0
- innerspot/py.typed +0 -0
- innerspot-0.0.1.dev2.dist-info/METADATA +149 -0
- innerspot-0.0.1.dev2.dist-info/RECORD +30 -0
- innerspot-0.0.1.dev2.dist-info/WHEEL +4 -0
- innerspot-0.0.1.dev2.dist-info/licenses/LICENSE +21 -0
innerspot/__init__.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Generator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._auth import SpotifyAuthBase, SpotifyEmbed, SpotifyPKCE, SpotifyWeb
|
|
9
|
+
from ._cache import CacheFileHandler, CacheHandler, MemoryCacheHandler, TokenInfo
|
|
10
|
+
from ._utils import (
|
|
11
|
+
api_href,
|
|
12
|
+
id_from_uri,
|
|
13
|
+
parse_spotify_id,
|
|
14
|
+
resolve_image_url,
|
|
15
|
+
web_url,
|
|
16
|
+
)
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import (
|
|
19
|
+
AccessDeniedError,
|
|
20
|
+
AuthError,
|
|
21
|
+
GraphQLError,
|
|
22
|
+
InnerSpotError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
)
|
|
25
|
+
from .models import (
|
|
26
|
+
Album,
|
|
27
|
+
AlbumStub,
|
|
28
|
+
Artist,
|
|
29
|
+
ArtistWithAlbums,
|
|
30
|
+
Copyright,
|
|
31
|
+
ExternalIDs,
|
|
32
|
+
ExternalURLs,
|
|
33
|
+
Followers,
|
|
34
|
+
HomeItem,
|
|
35
|
+
HomeSection,
|
|
36
|
+
Lyrics,
|
|
37
|
+
LyricsLine,
|
|
38
|
+
PaginatedResult,
|
|
39
|
+
Playlist,
|
|
40
|
+
PlaylistOwner,
|
|
41
|
+
PlaylistTrack,
|
|
42
|
+
RadioStation,
|
|
43
|
+
SearchResults,
|
|
44
|
+
SimplifiedAlbum,
|
|
45
|
+
SimplifiedArtist,
|
|
46
|
+
SpotifyImage,
|
|
47
|
+
Track,
|
|
48
|
+
TrackStub,
|
|
49
|
+
User,
|
|
50
|
+
)
|
|
51
|
+
from .providers import PartnerProvider, ScrapingProvider, SpClientProvider
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
"SpotifyClient",
|
|
56
|
+
"SpotifyAuthBase",
|
|
57
|
+
"SpotifyWeb",
|
|
58
|
+
"SpotifyEmbed",
|
|
59
|
+
"SpotifyPKCE",
|
|
60
|
+
"InnerSpotError",
|
|
61
|
+
"AuthError",
|
|
62
|
+
"AccessDeniedError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"GraphQLError",
|
|
65
|
+
"SpClientProvider",
|
|
66
|
+
"PartnerProvider",
|
|
67
|
+
"ScrapingProvider",
|
|
68
|
+
"CacheHandler",
|
|
69
|
+
"CacheFileHandler",
|
|
70
|
+
"MemoryCacheHandler",
|
|
71
|
+
"TokenInfo",
|
|
72
|
+
"Album",
|
|
73
|
+
"AlbumStub",
|
|
74
|
+
"ArtistWithAlbums",
|
|
75
|
+
"Artist",
|
|
76
|
+
"Copyright",
|
|
77
|
+
"ExternalIDs",
|
|
78
|
+
"ExternalURLs",
|
|
79
|
+
"Followers",
|
|
80
|
+
"HomeItem",
|
|
81
|
+
"HomeSection",
|
|
82
|
+
"Lyrics",
|
|
83
|
+
"LyricsLine",
|
|
84
|
+
"PaginatedResult",
|
|
85
|
+
"Playlist",
|
|
86
|
+
"PlaylistOwner",
|
|
87
|
+
"PlaylistTrack",
|
|
88
|
+
"RadioStation",
|
|
89
|
+
"SearchResults",
|
|
90
|
+
"SimplifiedAlbum",
|
|
91
|
+
"SimplifiedArtist",
|
|
92
|
+
"SpotifyImage",
|
|
93
|
+
"Track",
|
|
94
|
+
"TrackStub",
|
|
95
|
+
"User",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
logger = logging.getLogger(__name__)
|
|
99
|
+
|
|
100
|
+
_BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SpotifyClient:
|
|
104
|
+
"""High-level Spotify client with direct provider access and convenience methods.
|
|
105
|
+
|
|
106
|
+
Providers are accessible directly:
|
|
107
|
+
client.spclient — SpClientProvider (protobuf/JSON internal API)
|
|
108
|
+
client.partner — PartnerProvider (GraphQL partner API)
|
|
109
|
+
client.scraping — ScrapingProvider (HTML page scraping)
|
|
110
|
+
|
|
111
|
+
Convenience methods (get_track, get_album, etc.) try providers in order
|
|
112
|
+
with automatic fallback.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, auth: SpotifyAuthBase | None = None) -> None:
|
|
116
|
+
self._http = httpx.Client(
|
|
117
|
+
headers={"User-Agent": _BOT_UA},
|
|
118
|
+
follow_redirects=True,
|
|
119
|
+
timeout=15,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self._auth = auth or SpotifyWeb()
|
|
123
|
+
|
|
124
|
+
self.spclient = SpClientProvider(self._http, self._auth)
|
|
125
|
+
self.partner = PartnerProvider(self._http, self._auth)
|
|
126
|
+
self.scraping = ScrapingProvider(self._http, self._auth)
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
self._http.close()
|
|
130
|
+
|
|
131
|
+
def __enter__(self) -> "SpotifyClient":
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, *_: object) -> None:
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
# ── Convenience methods with fallback ──
|
|
138
|
+
|
|
139
|
+
def get_track(self, track_id: str) -> Track:
|
|
140
|
+
"""Get track metadata. Tries: spclient → partner → scraping."""
|
|
141
|
+
return self._fallback(
|
|
142
|
+
track_id,
|
|
143
|
+
self.spclient.get_track,
|
|
144
|
+
self.partner.get_track,
|
|
145
|
+
self.scraping.get_track,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def get_album(
|
|
149
|
+
self, album_id: str, offset: int = 0, limit: int = 300, **kwargs
|
|
150
|
+
) -> Album:
|
|
151
|
+
"""Get album metadata. Tries: spclient → partner.
|
|
152
|
+
|
|
153
|
+
When using spclient, all tracks are returned (offset/limit ignored).
|
|
154
|
+
When using partner, tracks are paginated.
|
|
155
|
+
"""
|
|
156
|
+
return self._fallback(
|
|
157
|
+
album_id,
|
|
158
|
+
self.spclient.get_album,
|
|
159
|
+
lambda aid: self.partner.get_album(
|
|
160
|
+
aid, offset=offset, limit=limit, **kwargs
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def get_artist(self, artist_id: str) -> Artist:
|
|
165
|
+
"""Get artist metadata. Tries: spclient → partner."""
|
|
166
|
+
return self._fallback(
|
|
167
|
+
artist_id,
|
|
168
|
+
self.spclient.get_artist,
|
|
169
|
+
self.partner.get_artist,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def get_playlist(
|
|
173
|
+
self, playlist_id: str, offset: int = 0, limit: int = 400, **kwargs
|
|
174
|
+
) -> Playlist:
|
|
175
|
+
"""Get playlist via partner GraphQL API. Tracks are paginated."""
|
|
176
|
+
return self.partner.get_playlist(
|
|
177
|
+
playlist_id, offset=offset, limit=limit, **kwargs
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def get_user(self, user_id: str) -> User:
|
|
181
|
+
"""Get user profile. Falls back to stub User on failure."""
|
|
182
|
+
try:
|
|
183
|
+
return self.spclient.user_profile(user_id)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
return User(
|
|
187
|
+
id=user_id,
|
|
188
|
+
display_name=user_id,
|
|
189
|
+
uri=f"spotify:user:{user_id}",
|
|
190
|
+
external_urls=ExternalURLs(
|
|
191
|
+
spotify=f"https://open.spotify.com/user/{user_id}"
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def get_lyrics(self, track_id: str) -> Lyrics:
|
|
196
|
+
"""Get lyrics for a track (synced or unsynced).
|
|
197
|
+
|
|
198
|
+
Requires authenticated token — use SpotifyWeb(sp_dc=...) or SpotifyPKCE().
|
|
199
|
+
"""
|
|
200
|
+
return self.spclient.color_lyrics(track_id)
|
|
201
|
+
|
|
202
|
+
def get_artists(self, artist_ids: list[str]) -> dict[str, Artist]:
|
|
203
|
+
"""Fetch multiple artists in a single protobuf call."""
|
|
204
|
+
return self.spclient.get_artists(artist_ids)
|
|
205
|
+
|
|
206
|
+
def get_artist_discography(
|
|
207
|
+
self, artist_id: str, include_groups: str = "album,single"
|
|
208
|
+
) -> ArtistWithAlbums:
|
|
209
|
+
"""Get artist with full discography."""
|
|
210
|
+
return self.spclient.get_artist_discography(artist_id, include_groups)
|
|
211
|
+
|
|
212
|
+
def get_artist_top_tracks(self, artist_id: str, **kwargs) -> list[TrackStub]:
|
|
213
|
+
"""Get artist's top tracks via partner GraphQL."""
|
|
214
|
+
return self.partner.get_artist_top_tracks(artist_id, **kwargs)
|
|
215
|
+
|
|
216
|
+
def get_artist_albums(
|
|
217
|
+
self, artist_id: str, offset: int = 0, limit: int = 50, **kwargs
|
|
218
|
+
) -> list[AlbumStub]:
|
|
219
|
+
"""Get artist's albums via partner GraphQL. Paginated."""
|
|
220
|
+
return self.partner.get_artist_albums(
|
|
221
|
+
artist_id, offset=offset, limit=limit, **kwargs
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def search(self, query: str, limit: int = 20, **kwargs) -> SearchResults:
|
|
225
|
+
"""Search for tracks, albums, artists, playlists. Works anonymously."""
|
|
226
|
+
return self._fallback(
|
|
227
|
+
None,
|
|
228
|
+
lambda _: self.partner.search(query, limit=limit, **kwargs),
|
|
229
|
+
lambda _: self._search_results_from_spclient(
|
|
230
|
+
self.spclient.search(query, limit=limit)
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get_isrc_track(self, isrc: str) -> Track:
|
|
235
|
+
results = self.search(query=f"isrc:{isrc}")
|
|
236
|
+
items = results.tracks.items
|
|
237
|
+
if not items:
|
|
238
|
+
raise ValueError(f"No track found for ISRC: {isrc}")
|
|
239
|
+
best = max(
|
|
240
|
+
items, key=lambda x: x.get("popularity", 0) if isinstance(x, dict) else 0
|
|
241
|
+
)
|
|
242
|
+
return self.get_track(self._extract_id(best))
|
|
243
|
+
|
|
244
|
+
def get_radio(
|
|
245
|
+
self,
|
|
246
|
+
seed_uri: str,
|
|
247
|
+
count: int = 200,
|
|
248
|
+
prev_tracks: str | None = None,
|
|
249
|
+
) -> RadioStation:
|
|
250
|
+
"""Get a full radio station from a seed URI."""
|
|
251
|
+
return self.spclient.radio(seed_uri, count=count, prev_tracks=prev_tracks)
|
|
252
|
+
|
|
253
|
+
def get_chart(self, playlist_id: str) -> dict:
|
|
254
|
+
"""Get chart playlist with track rankings via context-resolve."""
|
|
255
|
+
return self.spclient.context_resolve(f"spotify:playlist:{playlist_id}")
|
|
256
|
+
|
|
257
|
+
def get_inspired_playlist(self, seed_uri: str) -> list[str]:
|
|
258
|
+
"""Get Spotify-generated playlist URIs from a seed."""
|
|
259
|
+
return self.spclient.inspired_playlist(seed_uri)
|
|
260
|
+
|
|
261
|
+
def search_genre(self, genre: str, limit: int = 50) -> SearchResults:
|
|
262
|
+
"""Search for popular tracks and artists in a genre."""
|
|
263
|
+
return self.search(f"genre:{genre}", limit=limit)
|
|
264
|
+
|
|
265
|
+
def get_charts(self) -> list[HomeItem]:
|
|
266
|
+
"""Get all available chart playlists from the home feed."""
|
|
267
|
+
charts: dict[str, HomeItem] = {}
|
|
268
|
+
for section in self.get_home_sections(limit=50):
|
|
269
|
+
if not section.title or "chart" not in section.title.lower():
|
|
270
|
+
continue
|
|
271
|
+
for item in section.items:
|
|
272
|
+
if item.uri and ":playlist:" in item.uri:
|
|
273
|
+
pid = item.uri.split(":")[-1]
|
|
274
|
+
if pid not in charts:
|
|
275
|
+
charts[pid] = item
|
|
276
|
+
return list(charts.values())
|
|
277
|
+
|
|
278
|
+
def get_home_sections(self, limit: int = 20) -> list[HomeSection]:
|
|
279
|
+
"""Get all home page sections with their items."""
|
|
280
|
+
return self.partner.get_home_sections(limit=limit)
|
|
281
|
+
|
|
282
|
+
def get_playlist_with_tracks(self, playlist_id: str) -> Playlist:
|
|
283
|
+
"""Get a playlist with ALL tracks via context-resolve."""
|
|
284
|
+
data = self.spclient.context_resolve(f"spotify:playlist:{playlist_id}")
|
|
285
|
+
meta = data.get("metadata", {})
|
|
286
|
+
tracks = [
|
|
287
|
+
PlaylistTrack(
|
|
288
|
+
added_at=t.raw.get("added_at") if t.raw else None,
|
|
289
|
+
track=t,
|
|
290
|
+
)
|
|
291
|
+
for t in self._iter_context_tracks(data)
|
|
292
|
+
]
|
|
293
|
+
return Playlist(
|
|
294
|
+
id=playlist_id,
|
|
295
|
+
name=meta.get("context_description")
|
|
296
|
+
or meta.get("context_long_description"),
|
|
297
|
+
uri=data.get("uri") or f"spotify:playlist:{playlist_id}",
|
|
298
|
+
href=api_href("playlists", playlist_id),
|
|
299
|
+
external_urls=ExternalURLs(spotify=web_url("playlist", playlist_id)),
|
|
300
|
+
images=[SpotifyImage(url=meta["image_url"])]
|
|
301
|
+
if meta.get("image_url")
|
|
302
|
+
else [],
|
|
303
|
+
owner=PlaylistOwner(display_name=meta.get("context_owner")),
|
|
304
|
+
tracks=tracks,
|
|
305
|
+
total_tracks=len(tracks),
|
|
306
|
+
raw=data,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def iter_user_playlists(
|
|
310
|
+
self,
|
|
311
|
+
user_id: str,
|
|
312
|
+
) -> Generator[Playlist, None, None]:
|
|
313
|
+
try:
|
|
314
|
+
resp = self._http.get(
|
|
315
|
+
f"https://open.spotify.com/user/{user_id}",
|
|
316
|
+
headers={"User-Agent": _BOT_UA},
|
|
317
|
+
)
|
|
318
|
+
resp.raise_for_status()
|
|
319
|
+
pids = list(
|
|
320
|
+
dict.fromkeys(
|
|
321
|
+
re.findall(
|
|
322
|
+
r"open\.spotify\.com/playlist/([a-zA-Z0-9]{22})", resp.text
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
for pid in pids:
|
|
327
|
+
try:
|
|
328
|
+
yield self.get_playlist(pid)
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
logger.warning("iter_user_playlists failed: %s", exc)
|
|
333
|
+
|
|
334
|
+
def get_featured_playlists(self) -> list[HomeItem]:
|
|
335
|
+
"""Get featured/popular playlists from the home feed."""
|
|
336
|
+
return [
|
|
337
|
+
item
|
|
338
|
+
for section in self.get_home_sections(limit=50)
|
|
339
|
+
for item in section.items
|
|
340
|
+
if item.uri and ":playlist:" in item.uri
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
def get_new_releases(self) -> list[AlbumStub]:
|
|
344
|
+
"""Get new release albums via spclient search (tag:new)."""
|
|
345
|
+
data = self.spclient.search("tag:new", limit=50)
|
|
346
|
+
releases = []
|
|
347
|
+
for hit in data.get("results", {}).get("albums", {}).get("hits", []):
|
|
348
|
+
album_id = id_from_uri(hit.get("uri", ""))
|
|
349
|
+
image_url = resolve_image_url(hit.get("image", ""))
|
|
350
|
+
artists = [
|
|
351
|
+
SimplifiedArtist(
|
|
352
|
+
id=id_from_uri(a["uri"]),
|
|
353
|
+
uri=a["uri"],
|
|
354
|
+
name=a.get("name", ""),
|
|
355
|
+
)
|
|
356
|
+
for a in hit.get("artists", [])
|
|
357
|
+
if a.get("uri")
|
|
358
|
+
]
|
|
359
|
+
releases.append(
|
|
360
|
+
AlbumStub(
|
|
361
|
+
id=album_id,
|
|
362
|
+
name=hit.get("name"),
|
|
363
|
+
uri=hit.get("uri"),
|
|
364
|
+
href=api_href("albums", album_id),
|
|
365
|
+
external_urls=ExternalURLs(spotify=web_url("album", album_id)),
|
|
366
|
+
images=[SpotifyImage(url=image_url)] if image_url else [],
|
|
367
|
+
artists=artists,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
return releases
|
|
371
|
+
|
|
372
|
+
def iter_genre_tracks(
|
|
373
|
+
self, genre: str, count: int = 200
|
|
374
|
+
) -> Generator[TrackStub, None, None]:
|
|
375
|
+
"""Yield tracks for a genre via radio-apollo."""
|
|
376
|
+
yield from self._iter_radio(f"spotify:genre:{genre}", count)
|
|
377
|
+
|
|
378
|
+
def iter_artist_radio(
|
|
379
|
+
self, artist_id: str, count: int = 200
|
|
380
|
+
) -> Generator[TrackStub, None, None]:
|
|
381
|
+
"""Yield recommended tracks similar to an artist."""
|
|
382
|
+
yield from self._iter_radio(f"spotify:artist:{artist_id}", count)
|
|
383
|
+
|
|
384
|
+
def iter_track_radio(
|
|
385
|
+
self, track_id: str, count: int = 200
|
|
386
|
+
) -> Generator[TrackStub, None, None]:
|
|
387
|
+
"""Yield recommended tracks similar to a track."""
|
|
388
|
+
yield from self._iter_radio(f"spotify:track:{track_id}", count)
|
|
389
|
+
|
|
390
|
+
def get_chart_tracks(self, chart_playlist_id: str) -> list[TrackStub]:
|
|
391
|
+
"""Get all tracks from a chart playlist."""
|
|
392
|
+
playlist = self.get_playlist_with_tracks(chart_playlist_id)
|
|
393
|
+
return [pt.track for pt in playlist.tracks if pt.track]
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def parse_id(value: str) -> tuple[str, str]:
|
|
397
|
+
"""Parse a Spotify URI, URL, or raw ID. Returns (id, type)."""
|
|
398
|
+
return parse_spotify_id(value)
|
|
399
|
+
|
|
400
|
+
# ── Private helpers ──
|
|
401
|
+
|
|
402
|
+
def _fallback(self, arg, *providers):
|
|
403
|
+
"""Try providers in order, return first success."""
|
|
404
|
+
last_exc: Exception | None = None
|
|
405
|
+
for fn in providers:
|
|
406
|
+
try:
|
|
407
|
+
return fn(arg)
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
logger.warning("%s failed: %s", fn.__qualname__, exc)
|
|
410
|
+
last_exc = exc
|
|
411
|
+
raise last_exc or RuntimeError("All providers failed")
|
|
412
|
+
|
|
413
|
+
def _iter_radio(
|
|
414
|
+
self, seed_uri: str, count: int
|
|
415
|
+
) -> Generator[TrackStub, None, None]:
|
|
416
|
+
yielded = 0
|
|
417
|
+
prev_ids: deque[str] = deque(maxlen=100)
|
|
418
|
+
while yielded < count:
|
|
419
|
+
page_size = min(count - yielded, 200)
|
|
420
|
+
station = self.get_radio(
|
|
421
|
+
seed_uri,
|
|
422
|
+
count=page_size,
|
|
423
|
+
prev_tracks=",".join(prev_ids) if prev_ids else None,
|
|
424
|
+
)
|
|
425
|
+
if not station.tracks:
|
|
426
|
+
break
|
|
427
|
+
for t in station.tracks:
|
|
428
|
+
yield self._parse_radio_track(t)
|
|
429
|
+
prev_ids.append(t.original_gid or t.id or "")
|
|
430
|
+
yielded += 1
|
|
431
|
+
if yielded >= count:
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
def _iter_context_tracks(self, data: dict) -> Generator[TrackStub, None, None]:
|
|
435
|
+
for page in data.get("pages", []):
|
|
436
|
+
for t in page.get("tracks", []):
|
|
437
|
+
track_id = id_from_uri(t.get("uri", ""))
|
|
438
|
+
if track_id:
|
|
439
|
+
yield TrackStub(
|
|
440
|
+
id=track_id,
|
|
441
|
+
uri=t.get("uri"),
|
|
442
|
+
href=api_href("tracks", track_id),
|
|
443
|
+
external_urls=ExternalURLs(spotify=web_url("track", track_id)),
|
|
444
|
+
raw=t.get("metadata", {}),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _parse_radio_track(self, t) -> TrackStub:
|
|
448
|
+
meta = t.metadata
|
|
449
|
+
artists = []
|
|
450
|
+
i = 0
|
|
451
|
+
while True:
|
|
452
|
+
key = "artist_name" if i == 0 else f"artist_name:{i}"
|
|
453
|
+
uri_key = "artist_uri" if i == 0 else f"artist_uri:{i}"
|
|
454
|
+
name = meta.get(key)
|
|
455
|
+
if name is None:
|
|
456
|
+
break
|
|
457
|
+
a_uri = meta.get(uri_key)
|
|
458
|
+
if not a_uri:
|
|
459
|
+
i += 1
|
|
460
|
+
continue
|
|
461
|
+
a_id = id_from_uri(a_uri)
|
|
462
|
+
artists.append(
|
|
463
|
+
SimplifiedArtist(
|
|
464
|
+
id=a_id,
|
|
465
|
+
uri=a_uri,
|
|
466
|
+
name=name,
|
|
467
|
+
href=api_href("artists", a_id),
|
|
468
|
+
external_urls=ExternalURLs(spotify=web_url("artist", a_id)),
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
i += 1
|
|
472
|
+
|
|
473
|
+
track_id = t.id
|
|
474
|
+
album_uri = t.album_uri
|
|
475
|
+
album_id = id_from_uri(album_uri) if album_uri else None
|
|
476
|
+
image_url = resolve_image_url(meta.get("image_url", ""))
|
|
477
|
+
|
|
478
|
+
return TrackStub(
|
|
479
|
+
id=track_id,
|
|
480
|
+
name=meta.get("title"),
|
|
481
|
+
uri=t.uri,
|
|
482
|
+
href=api_href("tracks", track_id),
|
|
483
|
+
external_urls=ExternalURLs(spotify=web_url("track", track_id)),
|
|
484
|
+
explicit=meta.get("is_explicit") == "true",
|
|
485
|
+
artists=artists,
|
|
486
|
+
album=AlbumStub(
|
|
487
|
+
id=album_id,
|
|
488
|
+
name=meta.get("album_title"),
|
|
489
|
+
uri=album_uri,
|
|
490
|
+
href=api_href("albums", album_id),
|
|
491
|
+
external_urls=ExternalURLs(spotify=web_url("album", album_id)),
|
|
492
|
+
images=[SpotifyImage(url=image_url)] if image_url else [],
|
|
493
|
+
),
|
|
494
|
+
raw=meta,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _search_results_from_spclient(self, data: dict) -> SearchResults:
|
|
498
|
+
results = data.get("results", {})
|
|
499
|
+
|
|
500
|
+
def _hits(section: dict) -> PaginatedResult:
|
|
501
|
+
hits = section.get("hits", [])
|
|
502
|
+
return PaginatedResult(items=hits, total=len(hits))
|
|
503
|
+
|
|
504
|
+
return SearchResults(
|
|
505
|
+
tracks=_hits(results.get("tracks", {})),
|
|
506
|
+
albums=_hits(results.get("albums", {})),
|
|
507
|
+
artists=_hits(results.get("artists", {})),
|
|
508
|
+
playlists=_hits(results.get("playlists", {})),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def _extract_id(item) -> str:
|
|
513
|
+
if isinstance(item, dict):
|
|
514
|
+
return id_from_uri(item.get("uri", "")) or item.get("id", "")
|
|
515
|
+
return item.id or ""
|