Skip to content

Commit 09f2519

Browse files
committed
feat: Bypass Spotify's 100-track limit by integrating SpotAPI for unlimited playlist fetching
1 parent d86ef01 commit 09f2519

3 files changed

Lines changed: 93 additions & 148 deletions

File tree

backend/api/clients/spotify.py

Lines changed: 74 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import httpx
2-
import re
3-
import json
41
import logging
5-
from typing import List, Dict, Any, Optional
2+
from typing import List, Optional, Tuple
63
from dataclasses import dataclass
4+
from itertools import chain
5+
import asyncio
6+
from concurrent.futures import ThreadPoolExecutor
7+
8+
from spotapi import Public
79

810
logger = logging.getLogger(__name__)
911

@@ -18,161 +20,90 @@ class SpotifyTrack:
1820
class 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

backend/api/services/spotify.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,28 @@ async def process_spotify_playlist(playlist_uuid: str, progress_id: str, should_
2323
})
2424

2525
# specific to spotify: get tracks
26-
spotify_tracks = await client.get_playlist_tracks(playlist_uuid)
26+
# Returns (tracks, is_limited)
27+
spotify_tracks, is_limited = await client.get_playlist_tracks(playlist_uuid)
2728

2829
if not spotify_tracks:
2930
raise Exception("No tracks found or playlist is private/invalid.")
3031

3132
total_tracks = len(spotify_tracks)
3233

34+
limit_msg = " [Truncated to 100 due to guest limit]" if is_limited else ""
35+
3336
# If validating, show starting validation message
3437
if should_validate:
3538
await queue.put({
3639
"type": "info",
37-
"message": f"Found {total_tracks} tracks. Starting validation...",
40+
"message": f"Found {total_tracks} tracks{limit_msg}. Starting validation...",
3841
"progress": 0,
3942
"total": total_tracks
4043
})
4144
else:
4245
await queue.put({
4346
"type": "info",
44-
"message": f"Found {total_tracks} tracks. Processing...",
47+
"message": f"Found {total_tracks} tracks{limit_msg}. Processing...",
4548
"progress": 0,
4649
"total": total_tracks
4750
})
@@ -102,11 +105,12 @@ def __init__(self):
102105

103106
await queue.put({
104107
"type": "complete",
105-
"message": f"Process complete: {found_count}/{total_tracks} matched on Tidal" if should_validate else f"Fetched {total_tracks} from Spotify",
108+
"message": f"Process complete: {found_count}/{total_tracks} matched" if should_validate else f"Fetched {total_tracks} from Spotify",
106109
"progress": total_tracks,
107110
"total": total_tracks,
108111
"tracks": validated_tracks,
109-
"found_count": found_count
112+
"found_count": found_count,
113+
"is_limited": is_limited
110114
})
111115

112116
except Exception as e:

frontend/src/data/releaseNotes.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
export const releaseNotes = [
2+
{
3+
version: "1.2.4",
4+
date: "2025-12-20",
5+
title: "Spotify 100-Track Limit Bypass",
6+
changes: [
7+
"Replaced guest token API with SpotAPI library for unlimited playlist fetching.",
8+
"Spotify playlists now fetch all tracks without the previous 100-track limitation.",
9+
"Improved reliability using Spotify's partner API endpoint."
10+
]
11+
},
212
{
313
version: "1.2.3",
414
date: "2025-12-20",

0 commit comments

Comments
 (0)