mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user