1- import httpx
2- import re
3- import json
41import logging
5- from typing import List , Dict , Any , Optional
2+ from typing import List , Optional , Tuple
63from dataclasses import dataclass
4+ from itertools import chain
5+ import asyncio
6+ from concurrent .futures import ThreadPoolExecutor
7+
8+ from spotapi import Public
79
810logger = logging .getLogger (__name__ )
911
@@ -18,161 +20,90 @@ class SpotifyTrack:
1820class SpotifyClient :
1921 """
2022 Client for accessing Spotify playlist data without user credentials.
21- Uses the 'Embed' page to extract a guest access token, then uses the official API .
23+ Uses SpotAPI library which accesses Spotify's partner API for full playlist access .
2224 """
2325
2426 def __init__ (self ):
25- self .client = httpx .AsyncClient (
26- headers = {
27- "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
28- },
29- timeout = 30.0 ,
30- follow_redirects = True
31- )
32- self .access_token = None
27+ self ._executor = ThreadPoolExecutor (max_workers = 2 )
3328
3429 async def close (self ):
35- await self .client . aclose ( )
30+ self ._executor . shutdown ( wait = False )
3631
37- async def _get_guest_token (self , playlist_id : str ) -> Optional [str ]:
38- """Fetch the embed page and extract the guest access token from __NEXT_DATA__"""
39- embed_url = f"https://open.spotify.com/embed/playlist/{ playlist_id } "
40- logger .info (f"Fetching guest token from { embed_url } " )
32+ def _fetch_playlist_sync (self , playlist_id : str ) -> List [SpotifyTrack ]:
33+ """
34+ Synchronous method to fetch all tracks using SpotAPI.
35+ SpotAPI uses the partner API which has no 100 track limit.
36+ """
37+ tracks = []
4138
4239 try :
43- response = await self .client .get (embed_url )
44- response .raise_for_status ()
45- html = response .text
46-
47- match = re .search (r'<script id="__NEXT_DATA__" type="application/json">\s*(.*?)\s*</script>' , html , re .DOTALL )
48- if not match :
49- logger .error ("Could not find __NEXT_DATA__ in Spotify embed page" )
50- return None
51-
52- data = json .loads (match .group (1 ))
40+ # Get all chunks from SpotAPI (handles pagination internally, up to 343 per chunk)
41+ chunks = list (Public .playlist_info (playlist_id ))
42+ all_items = list (chain .from_iterable ([chunk ['items' ] for chunk in chunks ]))
5343
54- # Navigate path to token: props.pageProps.state.settings.session.accessToken
55- try :
56- token = data ['props' ]['pageProps' ]['state' ]['settings' ]['session' ]['accessToken' ]
57- logger .info ("Successfully extracted Spotify guest access token" )
58- return token
59- except KeyError as e :
60- logger .error (f"Failed to extract token from JSON structure: { e } " )
61- return None
44+ for item in all_items :
45+ track_data = item .get ('itemV2' , {}).get ('data' , {})
46+ typename = track_data .get ('__typename' )
6247
63- except Exception as e :
64- logger .error (f"Error fetching guest token: { e } " )
65- return None
66-
67- async def get_playlist_tracks (self , playlist_id : str ) -> List [SpotifyTrack ]:
68- """Fetch all tracks from a playlist using guest token and API"""
69-
70- # 1. Get Token
71- token = await self ._get_guest_token (playlist_id )
72- if not token :
73- logger .warning ("Could not get guest token, falling back to scraped embed data (limit 100 tracks)" )
74- return await self ._scrape_embed_tracks_fallback (playlist_id )
75-
76- return await self ._fetch_tracks_from_api (playlist_id , token )
77-
78- async def _fetch_tracks_from_api (self , playlist_id : str , token : str ) -> List [SpotifyTrack ]:
79- """Use official API with guest token to get all tracks"""
80- url = f"https://api.spotify.com/v1/playlists/{ playlist_id } /tracks"
81- headers = {"Authorization" : f"Bearer { token } " }
82-
83- all_tracks = []
84- offset = 0
85- limit = 100
86-
87- try :
88- while True :
89- logger .info (f"Fetching Spotify tracks offset={ offset } ..." )
90- params = {
91- "offset" : offset ,
92- "limit" : limit ,
93- "additional_types" : "track"
94- }
48+ # Skip non-track items (LocalTrack, RestrictedContent, NotFound, Episode)
49+ if typename != 'Track' :
50+ continue
9551
96- response = await self .client .get (url , headers = headers , params = params )
97- response .raise_for_status ()
98- data = response .json ()
52+ # Extract track info
53+ name = track_data .get ('name' , 'Unknown Title' )
9954
100- items = data .get ("items" , [])
101- if not items :
102- break
103-
104- for item in items :
105- track_obj = item .get ("track" )
106- # Handle local files or null tracks
107- if not track_obj or track_obj .get ("is_local" ):
108- continue
109-
110- artists = [a ["name" ] for a in track_obj .get ("artists" , [])]
111- artist_str = ", " .join (artists ) if artists else "Unknown Artist"
112-
113- all_tracks .append (SpotifyTrack (
114- title = track_obj .get ("name" , "Unknown Title" ),
115- artist = artist_str ,
116- album = track_obj .get ("album" , {}).get ("name" ),
117- duration_ms = track_obj .get ("duration_ms" ),
118- spotify_id = track_obj .get ("id" )
119- ))
55+ # Get artists
56+ artists_data = track_data .get ('artists' , {}).get ('items' , [])
57+ if artists_data :
58+ artist_names = [a .get ('profile' , {}).get ('name' , '' ) for a in artists_data ]
59+ artist_str = ", " .join (filter (None , artist_names )) or "Unknown Artist"
60+ else :
61+ artist_str = "Unknown Artist"
12062
121- if not data .get ("next" ):
122- break
123-
124- offset += limit
63+ # Get album
64+ album_data = track_data .get ('albumOfTrack' , {})
65+ album_name = album_data .get ('name' )
12566
126- logger .info (f"Fetched { len (all_tracks )} tracks from Spotify API" )
127- return all_tracks
128-
129- except Exception as e :
130- logger .error (f"Error fetching tracks from API: { e } " )
131- logger .warning ("Falling back to embed scraping" )
132- return await self ._scrape_embed_tracks_fallback (playlist_id )
133-
134- async def _scrape_embed_tracks_fallback (self , playlist_id : str ) -> List [SpotifyTrack ]:
135- """Fallback: Parse tracks directly from the embed JSON (limit 100)"""
136- embed_url = f"https://open.spotify.com/embed/playlist/{ playlist_id } "
137- try :
138- response = await self .client .get (embed_url )
139- response .raise_for_status ()
140- html = response .text
141-
142- match = re .search (r'<script id="__NEXT_DATA__" type="application/json">\s*(.*?)\s*</script>' , html , re .DOTALL )
143- if not match :
144- return []
145-
146- data = json .loads (match .group (1 ))
147-
148- # Try to find track list
149- # Path: props.pageProps.state.data.entity.trackList
150- items = []
151- try :
152- items = data ['props' ]['pageProps' ]['state' ]['data' ]['entity' ]['trackList' ]
153- except KeyError :
154- logger .error ("Could not find trackList in fallback data" )
155- return []
67+ # Get duration
68+ duration_data = track_data .get ('trackDuration' , {})
69+ duration_ms = duration_data .get ('totalMilliseconds' )
70+ if duration_ms :
71+ duration_ms = int (duration_ms )
15672
157- tracks = []
158- for item in items :
159- # Structure: {'title': '...', 'subtitle': '...', ...}
160- # Title = Title, Subtitle = Artist
161- title = item .get ('title' )
162- artist = item .get ('subtitle' )
73+ # Get Spotify ID from URI (format: spotify:track:XXXXX)
74+ uri = track_data .get ('uri' , '' )
75+ spotify_id = uri .split (':' )[- 1 ] if uri .startswith ('spotify:track:' ) else None
16376
164- if title and artist :
165- tracks .append (SpotifyTrack (
166- title = title ,
167- artist = artist ,
168- album = None , # Album missing in this view
169- duration_ms = item .get ('duration' ),
170- spotify_id = item .get ('uid' ) # UID is not spotify ID strictly but usable
171- ))
172-
173- logger .info (f"Scraped { len (tracks )} tracks from embed data (fallback)" )
77+ tracks .append (SpotifyTrack (
78+ title = name ,
79+ artist = artist_str ,
80+ album = album_name ,
81+ duration_ms = duration_ms ,
82+ spotify_id = spotify_id
83+ ))
84+
85+ logger .info (f"Fetched { len (tracks )} tracks from Spotify via SpotAPI" )
17486 return tracks
17587
17688 except Exception as e :
177- logger .error (f"Error in fallback scraping: { e } " )
178- return []
89+ logger .error (f"Error fetching playlist with SpotAPI: { e } " )
90+ raise
91+
92+ async def get_playlist_tracks (self , playlist_id : str ) -> Tuple [List [SpotifyTrack ], bool ]:
93+ """
94+ Fetch all tracks from a playlist using SpotAPI.
95+ Returns: (tracks, is_limited)
96+ is_limited is always False with SpotAPI as it has no practical limit.
97+ """
98+ loop = asyncio .get_event_loop ()
99+
100+ try :
101+ tracks = await loop .run_in_executor (
102+ self ._executor ,
103+ self ._fetch_playlist_sync ,
104+ playlist_id
105+ )
106+ return tracks , False
107+ except Exception as e :
108+ logger .error (f"Failed to fetch playlist: { e } " )
109+ return [], False
0 commit comments