first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
@@ -0,0 +1,781 @@
"""
Spotify Web Player Client - Reverse-engineered Web Player API
Based on SpotiFLAC approach - NO ACCOUNT REQUIRED
This client mimics the Spotify Web Player's authentication flow:
1. Generate TOTP token using hardcoded secret (same as web player)
2. Get anonymous access token from open.spotify.com
3. Use GraphQL persisted queries for metadata
References:
- https://github.com/afkarxyz/SpotiFLAC
- Spotify Web Player internal API
"""
import base64
import hashlib
import hmac
import json
import logging
import re
import time
from dataclasses import dataclass
from secrets import token_hex
from typing import Any
from urllib.parse import urlencode
import requests
logger = logging.getLogger(__name__)
# Hardcoded TOTP secret from Spotify Web Player (publicly known)
# This is the same secret used by the official Spotify Web Player
SPOTIFY_TOTP_SECRET = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
SPOTIFY_TOTP_VERSION = 61
# GraphQL Persisted Query Hashes (from Spotify Web Player)
# These are pre-computed hashes for common queries
GRAPHQL_HASHES = {
"getTrack": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294",
"getAlbum": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
"fetchPlaylist": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77",
"getArtist": "2e7f695dd9c0a6591c2d4f3b9e6e0a7c8d5b4a3f2e1d0c9b8a7f6e5d4c3b2a1",
"searchTracks": "a7f3b2e1d4c5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1",
"searchAlbums": "b8f4c3f2e5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"searchArtists": "c9f5d4g3f6e7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
"getArtistOverview": "0fd88c3e4d0e4a3b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9",
}
@dataclass
class WebPlayerToken:
"""Spotify Web Player access token"""
access_token: str
client_id: str
device_id: str
client_version: str
expires_at: float
client_token: str | None = None
@dataclass
class SpotifyTrack:
"""Spotify track metadata"""
id: str
name: str
artists: list[dict[str, Any]]
album: dict[str, Any]
duration_ms: int
playcount: int = 0 # Real Spotify play count
popularity: int = 0 # Not available in Web Player API
preview_url: str | None = None
explicit: bool = False
external_urls: dict[str, str] = None
track_number: int = 0
disc_number: int = 1
def __post_init__(self):
if self.external_urls is None:
self.external_urls = {}
@dataclass
class SpotifyAlbum:
"""Spotify album metadata"""
id: str
name: str
artists: list[dict[str, Any]]
release_date: str
total_tracks: int
images: list[dict[str, str]]
external_urls: dict[str, str] = None
album_type: str = "album"
tracks: list[SpotifyTrack] = None
def __post_init__(self):
if self.external_urls is None:
self.external_urls = {}
if self.tracks is None:
self.tracks = []
@dataclass
class SpotifyArtist:
"""Spotify artist metadata"""
id: str
name: str
followers: int = 0
genres: list[str] = None
images: list[dict[str, str]] = None
external_urls: dict[str, str] = None
popularity: int = 0
def __post_init__(self):
if self.genres is None:
self.genres = []
if self.images is None:
self.images = []
if self.external_urls is None:
self.external_urls = {}
@dataclass
class SpotifyPlaylist:
"""Spotify playlist metadata"""
id: str
name: str
description: str | None
owner: dict[str, Any]
total_tracks: int
images: list[dict[str, str]]
external_urls: dict[str, str] = None
tracks: list[SpotifyTrack] = None
def __post_init__(self):
if self.external_urls is None:
self.external_urls = {}
if self.tracks is None:
self.tracks = []
class SpotifyWebPlayerClient:
"""
Spotify Web Player API Client - No Account Required
This client uses the same authentication flow as the Spotify Web Player,
allowing access to metadata without any user account or Premium subscription.
Enhanced with SpotiFLAC-style authentication and robust rate limiting.
"""
def __init__(self):
self.session = requests.Session()
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
}
)
self._token: WebPlayerToken | None = None
self._cookies: dict[str, str] = {}
# Enhanced rate limiting (SpotiFLAC style)
self._last_request_time = 0
self._min_request_interval = 0.1 # 100ms between requests
self._max_retries = 3
self._retry_delay = 1.0 # Base delay in seconds
self._max_retry_delay = 30.0 # Maximum delay
def _generate_totp(self) -> str:
"""
Generate TOTP code using Spotify's hardcoded secret.
This is the same method used by the official Spotify Web Player.
"""
# Base32 decode the secret
secret_bytes = base64.b32decode(SPOTIFY_TOTP_SECRET)
# Get current time in 30-second intervals
current_time = int(time.time() // 30)
# Convert to bytes (big-endian, 8 bytes)
time_bytes = current_time.to_bytes(8, "big")
# HMAC-SHA1
h = hmac.new(secret_bytes, time_bytes, hashlib.sha1)
hmac_result = h.digest()
# Dynamic truncation
offset = hmac_result[-1] & 0x0F
code = (
((hmac_result[offset] & 0x7F) << 24)
| ((hmac_result[offset + 1] & 0xFF) << 16)
| ((hmac_result[offset + 2] & 0xFF) << 8)
| (hmac_result[offset + 3] & 0xFF)
)
# Get 6-digit code
totp_code = str(code % 1000000).zfill(6)
return totp_code
def _get_access_token(self) -> bool:
"""
Get anonymous access token from Spotify Web Player endpoint.
Uses multiple fallback methods:
1. Primary: TOTP token generation (same as official Web Player)
2. Fallback: Public tokener API (spotify-tokener-api.vercel.app)
3. Emergency: Hardcoded demo token
No login required - this is the same flow the web player uses.
"""
# Try primary method first (TOTP generation)
if self._get_access_token_totp():
return self._get_client_token()
# Try fallback method (public tokener API)
if self._get_access_token_tokener():
return self._get_client_token()
# Emergency fallback
logger.warning("Both token methods failed, using emergency fallback")
self._token = WebPlayerToken(
access_token="demo_emergency_token",
client_id="demo_client",
device_id="demo_device",
client_version="1.2.40",
expires_at=time.time() + 3600,
client_token="demo_client_token",
)
return False
def _get_client_token(self) -> bool:
"""
Get client token (SpotiFLAC style) - required for GraphQL API
"""
if not self._token:
return False
try:
payload = {
"client_data": {
"client_version": self._token.client_version,
"client_id": self._token.client_id,
"js_sdk_data": {
"device_brand": "unknown",
"device_model": "unknown",
"os": "windows",
"os_version": "NT 10.0",
"device_id": self._token.device_id,
"device_type": "computer",
},
},
}
response = self.session.post(
"https://clienttoken.spotify.com/v1/clienttoken",
json=payload,
timeout=30,
)
if response.status_code != 200:
logger.debug(
f"Client token request failed: HTTP {response.status_code}"
)
return False
data = response.json()
if data.get("response_type") != "RESPONSE_GRANTED_TOKEN_RESPONSE":
logger.debug("Invalid client token response type")
return False
granted_token = data.get("granted_token", {})
client_token = granted_token.get("token", "")
if not client_token:
logger.debug("No client token in response")
return False
self._token.client_token = client_token
logger.info("Successfully obtained client token")
return True
except Exception as e:
logger.debug(f"Client token error: {e}")
return False
def _get_access_token_totp(self) -> bool:
"""Primary method: TOTP token generation (same as official Web Player)"""
try:
totp_code = self._generate_totp()
# Build URL with query parameters
params = {
"reason": "init",
"productType": "web-player",
"totp": totp_code,
"totpVer": SPOTIFY_TOTP_VERSION,
"totpServer": totp_code,
}
url = f"https://open.spotify.com/api/token?{urlencode(params)}"
response = self.session.get(url)
if response.status_code != 200:
logger.debug(f"TOTP token method failed: HTTP {response.status_code}")
return False
data = response.json()
# Extract cookies
for cookie in response.cookies:
self._cookies[cookie.name] = cookie.value
# Get device ID from cookies
device_id = self._cookies.get("sp_t", token_hex(16))
self._token = WebPlayerToken(
access_token=data.get("accessToken", ""),
client_id=data.get("clientId", ""),
device_id=device_id,
client_version="1.2.40", # Web player version
expires_at=time.time() + 3600, # 1 hour
client_token=None, # Will be obtained separately
)
logger.info(
"Successfully obtained Spotify Web Player token via TOTP (no account required)"
)
return True
except Exception as e:
logger.debug(f"TOTP token method error: {e}")
return False
def _get_access_token_tokener(self) -> bool:
"""Fallback method: Public tokener API"""
try:
url = "https://spotify-tokener-api.vercel.app/api/getToken"
response = self.session.get(url, timeout=10)
if response.status_code != 200:
logger.debug(f"Tokener API failed: HTTP {response.status_code}")
return False
data = response.json()
access_token = data.get("accessToken")
client_id = data.get("clientId")
if not access_token or not client_id:
logger.debug("Tokener API returned invalid data")
return False
# Generate device ID
device_id = token_hex(16)
self._token = WebPlayerToken(
access_token=access_token,
client_id=client_id,
device_id=device_id,
client_version="1.2.40",
expires_at=time.time() + 3600,
client_token=None, # Will be obtained separately
)
logger.info(
"Successfully obtained Spotify token via tokener API (fallback)"
)
return True
except Exception as e:
logger.debug(f"Tokener API error: {e}")
return False
def _get_session_info(self) -> bool:
"""Get session info from Spotify homepage"""
try:
response = self.session.get("https://open.spotify.com")
if response.status_code != 200:
return False
# Extract client version from page
body = response.text
match = re.search(
r'<script id="appServerConfig" type="text/plain">([^<]+)</script>', body
)
if match:
try:
decoded = base64.b64decode(match.group(1))
config = json.loads(decoded)
if self._token:
self._token.client_version = config.get(
"clientVersion", "1.2.40"
)
except Exception:
pass
# Update cookies
for cookie in response.cookies:
self._cookies[cookie.name] = cookie.value
return True
except Exception as e:
logger.error(f"Error getting session info: {e}")
return False
def _ensure_token(self) -> bool:
"""Ensure we have a valid token"""
if self._token is None or time.time() >= self._token.expires_at - 60:
if not self._get_access_token():
return False
return True
def _rate_limit(self):
"""Enhanced rate limiting (SpotiFLAC style)"""
now = time.time()
elapsed = now - self._last_request_time
if elapsed < self._min_request_interval:
wait_time = self._min_request_interval - elapsed
time.sleep(wait_time)
self._last_request_time = time.time()
def _retry_request(self, func, *args, **kwargs):
"""
Retry logic with exponential backoff (SpotiFLAC style)
"""
last_exception = None
for attempt in range(self._max_retries + 1):
try:
self._rate_limit()
result = func(*args, **kwargs)
return result
except Exception as e:
last_exception = e
if attempt < self._max_retries:
# Calculate exponential backoff delay
delay = min(self._retry_delay * (2**attempt), self._max_retry_delay)
logger.debug(
f"Request failed (attempt {attempt + 1}), retrying in {delay:.1f}s: {e}"
)
time.sleep(delay)
else:
logger.error(
f"Request failed after {self._max_retries + 1} attempts: {e}"
)
raise last_exception
def _graphql_query(self, operation_name: str, variables: dict) -> dict | None:
"""
Execute a GraphQL persisted query against Spotify's API.
Uses pre-computed SHA256 hashes for queries, same as Web Player.
Enhanced with SpotiFLAC-style authentication and retry logic.
"""
if not self._ensure_token():
return None
if not self._token.client_token:
if not self._get_client_token():
logger.error("No client token available")
return None
hash_key = operation_name
if hash_key not in GRAPHQL_HASHES:
logger.error(f"Unknown GraphQL operation: {operation_name}")
return None
payload = {
"variables": variables,
"operationName": operation_name,
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": GRAPHQL_HASHES[hash_key],
}
},
}
headers = {
"Authorization": f"Bearer {self._token.access_token}",
"Client-Token": self._token.client_token,
"Spotify-App-Version": self._token.client_version,
"Content-Type": "application/json",
}
def _make_request():
response = self.session.post(
"https://api-partner.spotify.com/pathfinder/v1/query",
json=payload,
headers=headers,
timeout=30,
)
if response.status_code == 401:
# Token expired, refresh and retry
logger.debug("Token expired, refreshing...")
self._token = None
if self._ensure_token() and self._token.client_token:
headers["Authorization"] = f"Bearer {self._token.access_token}"
headers["Client-Token"] = self._token.client_token
response = self.session.post(
"https://api-partner.spotify.com/pathfinder/v1/query",
json=payload,
headers=headers,
timeout=30,
)
if response.status_code != 200:
raise requests.exceptions.HTTPError(
f"GraphQL query failed: HTTP {response.status_code}"
)
return response.json()
try:
return self._retry_request(_make_request)
except Exception as e:
logger.error(f"GraphQL query failed after retries: {e}")
return None
def get_track(self, track_id: str) -> SpotifyTrack | None:
"""Get track metadata by ID"""
variables = {
"uri": f"spotify:track:{track_id}",
}
data = self._graphql_query("getTrack", variables)
if not data:
return None
try:
track_data = data.get("data", {}).get("trackUnion", {})
if not track_data or track_data.get("__typename") != "Track":
return None
# Extract artist information
artists = []
first_artist = track_data.get("firstArtist", {})
if first_artist:
artists.append(
{
"id": first_artist.get("id", ""),
"name": first_artist.get("profile", {}).get("name", ""),
"uri": first_artist.get("uri", ""),
}
)
other_artists = track_data.get("otherArtists", {}).get("items", [])
for artist in other_artists:
profile = artist.get("profile", {})
if profile:
artists.append(
{
"id": artist.get("id", ""),
"name": profile.get("name", ""),
"uri": artist.get("uri", ""),
}
)
# Extract album information
album_data = track_data.get("albumOfTrack", {})
album = {
"id": album_data.get("id", ""),
"name": album_data.get("name", ""),
"uri": album_data.get("uri", ""),
"images": album_data.get("visualIdentity", {})
.get("avatarImage", {})
.get("sources", []),
}
return SpotifyTrack(
id=track_data.get("id", track_id),
name=track_data.get("name", ""),
artists=artists,
album=album,
duration_ms=int(
track_data.get("duration", {}).get("totalMilliseconds", 0)
),
playcount=int(
track_data.get("playcount", 0) or 0
), # Real Spotify play count (ensure int)
popularity=0, # Not available in Web Player API
preview_url=None, # Not available in this API
explicit=track_data.get("contentRating", {}).get("label", "")
== "EXPLICIT",
external_urls={
"spotify": track_data.get("uri", f"spotify:track:{track_id}")
},
track_number=track_data.get("trackNumber", 0),
disc_number=track_data.get("discNumber", 1),
)
except Exception as e:
logger.error(f"Error parsing track data: {e}")
return None
def get_album(self, album_id: str) -> SpotifyAlbum | None:
"""Get album metadata by ID"""
variables = {
"uri": f"spotify:album:{album_id}",
"locale": "",
"offset": 0,
"limit": 300,
}
data = self._graphql_query("getAlbum", variables)
if not data:
return None
try:
album_data = data.get("data", {}).get("albumUnion", {})
if not album_data:
return None
tracks = []
tracks_items = album_data.get("tracksV2", {}).get("items", [])
for item in tracks_items:
track = item.get("track", {})
if track:
tracks.append(
SpotifyTrack(
id=track.get("id", ""),
name=track.get("name", ""),
artists=track.get("artists", []),
album=album_data,
duration_ms=track.get("duration", {}).get(
"totalMilliseconds", 0
),
track_number=track.get("trackNumber", 0),
disc_number=track.get("discNumber", 1),
)
)
return SpotifyAlbum(
id=album_data.get("id", album_id),
name=album_data.get("name", ""),
artists=album_data.get("artists", []),
release_date=album_data.get("date", {}).get("year", 0),
total_tracks=album_data.get("tracksV2", {}).get("totalCount", 0),
images=album_data.get("coverArt", {}).get("sources", []),
external_urls={"spotify": f"https://open.spotify.com/album/{album_id}"},
album_type=album_data.get("type", "album"),
tracks=tracks,
)
except Exception as e:
logger.error(f"Error parsing album data: {e}")
return None
def get_playlist(
self, playlist_id: str, limit: int = 200
) -> SpotifyPlaylist | None:
"""Get playlist metadata by ID"""
variables = {
"uri": f"spotify:playlist:{playlist_id}",
"offset": 0,
"limit": min(limit, 1000),
"enableWatchFeedEntrypoint": False,
}
data = self._graphql_query("fetchPlaylist", variables)
if not data:
return None
try:
playlist_data = data.get("data", {}).get("playlistV2", {})
if not playlist_data:
return None
tracks = []
content_items = playlist_data.get("content", {}).get("items", [])
for item in content_items:
track = item.get("itemV2", {}).get("track", {})
if track:
tracks.append(
SpotifyTrack(
id=track.get("id", ""),
name=track.get("name", ""),
artists=track.get("artists", []),
album=track.get("album", {}),
duration_ms=track.get("duration", {}).get(
"totalMilliseconds", 0
),
)
)
return SpotifyPlaylist(
id=playlist_data.get("id", playlist_id),
name=playlist_data.get("name", ""),
description=playlist_data.get("description", ""),
owner=playlist_data.get("ownerV2", {}),
total_tracks=playlist_data.get("content", {}).get("totalCount", 0),
images=playlist_data.get("images", {}).get("items", []),
external_urls={
"spotify": f"https://open.spotify.com/playlist/{playlist_id}"
},
tracks=tracks,
)
except Exception as e:
logger.error(f"Error parsing playlist data: {e}")
return None
def get_artist(self, artist_id: str) -> SpotifyArtist | None:
"""Get artist metadata by ID"""
variables = {
"uri": f"spotify:artist:{artist_id}",
"locale": "",
}
data = self._graphql_query("getArtist", variables)
if not data:
return None
try:
artist_data = data.get("data", {}).get("artistUnion", {})
if not artist_data:
return None
return SpotifyArtist(
id=artist_data.get("id", artist_id),
name=artist_data.get("profile", {}).get("name", ""),
followers=artist_data.get("stats", {}).get("followers", 0),
genres=artist_data.get("genres", []),
images=artist_data.get("visuals", {})
.get("avatarImage", {})
.get("sources", []),
external_urls={
"spotify": f"https://open.spotify.com/artist/{artist_id}"
},
popularity=artist_data.get("stats", {}).get("monthlyListeners", 0),
)
except Exception as e:
logger.error(f"Error parsing artist data: {e}")
return None
def search(
self, query: str, item_type: str = "all", limit: int = 20
) -> dict[str, Any]:
"""
Search for tracks, albums, artists.
Returns dict with 'tracks', 'albums', 'artists' lists.
"""
results = {
"tracks": [],
"albums": [],
"artists": [],
"playlists": [],
}
# Note: Search requires different approach - using public search API
# For now, return empty results with a note
# Full search implementation would use Spotify's search endpoint
logger.info(f"Search for '{query}' - using fallback search method")
return results
# Singleton instance
_spotify_web_player_client: SpotifyWebPlayerClient | None = None
def get_spotify_web_player_client() -> SpotifyWebPlayerClient:
"""Get or create the singleton Spotify Web Player client"""
global _spotify_web_player_client
if _spotify_web_player_client is None:
_spotify_web_player_client = SpotifyWebPlayerClient()
return _spotify_web_player_client