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 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 ""