mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
782 lines
26 KiB
Python
782 lines
26 KiB
Python
"""
|
|
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
|