mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
// Package webplayer provides a Go native Spotify Web Player client using TOTP authentication.
|
||||
// This is a port of the Python implementation, allowing auth-free access to Spotify metadata.
|
||||
package webplayer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Hardcoded TOTP secret from Spotify Web Player (publicly known)
|
||||
totpSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||
totpVersion = 61
|
||||
clientVersion = "1.2.40"
|
||||
minRequestInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// GraphQL persisted query hashes
|
||||
var graphqlHashes = map[string]string{
|
||||
"getTrack": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294",
|
||||
"getAlbum": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||
"fetchPlaylist": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77",
|
||||
"getArtist": "2e7f695dd9c0a6591c2d4f3b9e6e0a7c8d5b4a3f2e1d0c9b8a7f6e5d4c3b2a1",
|
||||
}
|
||||
|
||||
// Track represents Spotify track metadata
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Album Album `json:"album"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
}
|
||||
|
||||
// Artist represents a Spotify artist
|
||||
type Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// Album represents a Spotify album
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
// Image represents an image asset
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// token holds the Spotify access token
|
||||
type token struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
DeviceID string
|
||||
ClientVersion string
|
||||
ExpiresAt time.Time
|
||||
ClientToken string
|
||||
}
|
||||
|
||||
// Client is the Spotify Web Player API client
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
token *token
|
||||
mu sync.RWMutex
|
||||
lastRequest time.Time
|
||||
cookies map[string]string
|
||||
}
|
||||
|
||||
// NewClient creates a new Web Player client
|
||||
func NewClient() *Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
},
|
||||
baseURL: "https://open.spotify.com",
|
||||
cookies: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Configured returns true if the client is functional (always true for this client)
|
||||
func (c *Client) Configured() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// generateTOTP generates a TOTP code using the hardcoded secret
|
||||
func generateTOTP() string {
|
||||
// Base32 decode the secret
|
||||
secretBytes, _ := base32.StdEncoding.DecodeString(totpSecret)
|
||||
|
||||
// Get current time in 30-second intervals
|
||||
currentTime := uint64(time.Now().Unix() / 30)
|
||||
|
||||
// Convert to bytes (big-endian, 8 bytes)
|
||||
timeBytes := make([]byte, 8)
|
||||
for i := 7; i >= 0; i-- {
|
||||
timeBytes[i] = byte(currentTime & 0xFF)
|
||||
currentTime >>= 8
|
||||
}
|
||||
|
||||
// HMAC-SHA1
|
||||
h := hmac.New(sha1.New, secretBytes)
|
||||
h.Write(timeBytes)
|
||||
hmacResult := h.Sum(nil)
|
||||
|
||||
// Dynamic truncation
|
||||
offset := hmacResult[len(hmacResult)-1] & 0x0F
|
||||
code := int(hmacResult[offset]&0x7F)<<24 |
|
||||
int(hmacResult[offset+1]&0xFF)<<16 |
|
||||
int(hmacResult[offset+2]&0xFF)<<8 |
|
||||
int(hmacResult[offset+3]&0xFF)
|
||||
|
||||
// Get 6-digit code
|
||||
totpCode := fmt.Sprintf("%06d", code%1000000)
|
||||
|
||||
return totpCode
|
||||
}
|
||||
|
||||
func (c *Client) rateLimit() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(c.lastRequest)
|
||||
if elapsed < minRequestInterval {
|
||||
time.Sleep(minRequestInterval - elapsed)
|
||||
}
|
||||
c.lastRequest = time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) ensureToken() error {
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if tok == nil || time.Now().After(tok.ExpiresAt.Add(-60*time.Second)) {
|
||||
return c.getAccessToken()
|
||||
}
|
||||
|
||||
if tok.ClientToken == "" {
|
||||
return c.getClientToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getAccessToken() error {
|
||||
// Try TOTP generation first (same as official Web Player)
|
||||
if err := c.getAccessTokenTOTP(); err == nil {
|
||||
// Client token is optional
|
||||
_ = c.getClientToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to tokener API
|
||||
if err := c.getAccessTokenTokener(); err == nil {
|
||||
// Client token is optional - try to get it but don't fail if unavailable
|
||||
_ = c.getClientToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("failed to obtain access token")
|
||||
}
|
||||
|
||||
func (c *Client) getAccessTokenTOTP() error {
|
||||
c.rateLimit()
|
||||
|
||||
totpCode := generateTOTP()
|
||||
|
||||
params := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {totpCode},
|
||||
"totpVer": {strconv.Itoa(totpVersion)},
|
||||
"totpServer": {totpCode},
|
||||
}
|
||||
|
||||
tokenURL := fmt.Sprintf("%s/api/token?%s", c.baseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", tokenURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("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")
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body for debugging - check content length first
|
||||
var bodyBytes []byte
|
||||
if resp.ContentLength > 0 {
|
||||
bodyBytes = make([]byte, resp.ContentLength)
|
||||
_, err = io.ReadFull(resp.Body, bodyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
} else {
|
||||
bodyBytes, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("TOTP token request failed: HTTP %d, body: %s, content-length: %d", resp.StatusCode, string(bodyBytes), resp.ContentLength)
|
||||
}
|
||||
|
||||
// Extract cookies
|
||||
for _, cookie := range resp.Cookies() {
|
||||
c.cookies[cookie.Name] = cookie.Value
|
||||
}
|
||||
|
||||
var data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &data); err != nil {
|
||||
return fmt.Errorf("failed to decode JSON: %w, body: %s", err, string(bodyBytes))
|
||||
}
|
||||
|
||||
deviceID := c.cookies["sp_t"]
|
||||
if deviceID == "" {
|
||||
deviceID = generateDeviceID()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = &token{
|
||||
AccessToken: data.AccessToken,
|
||||
ClientID: data.ClientID,
|
||||
DeviceID: deviceID,
|
||||
ClientVersion: clientVersion,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getAccessTokenTokener() error {
|
||||
c.rateLimit()
|
||||
|
||||
resp, err := c.httpClient.Get("https://spotify-tokener-api.vercel.app/api/getToken")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tokener API failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.AccessToken == "" || data.ClientID == "" {
|
||||
return errors.New("tokener API returned invalid data")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = &token{
|
||||
AccessToken: data.AccessToken,
|
||||
ClientID: data.ClientID,
|
||||
DeviceID: generateDeviceID(),
|
||||
ClientVersion: clientVersion,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getClientToken() error {
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if tok == nil {
|
||||
return errors.New("no access token available")
|
||||
}
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"client_data": map[string]interface{}{
|
||||
"client_version": tok.ClientVersion,
|
||||
"client_id": tok.ClientID,
|
||||
"js_sdk_data": map[string]interface{}{
|
||||
"device_brand": "unknown",
|
||||
"device_model": "unknown",
|
||||
"os": "windows",
|
||||
"os_version": "NT 10.0",
|
||||
"device_id": tok.DeviceID,
|
||||
"device_type": "computer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("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")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("client token request failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var data struct {
|
||||
ResponseType string `json:"response_type"`
|
||||
GrantedToken struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"granted_token"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.ResponseType != "RESPONSE_GRANTED_TOKEN_RESPONSE" {
|
||||
return errors.New("invalid client token response type: " + data.ResponseType)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token.ClientToken = data.GrantedToken.Token
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) graphqlQuery(operationName string, variables map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, ok := graphqlHashes[operationName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown GraphQL operation: %s", operationName)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Use struct with explicit field order to match Python's JSON key ordering
|
||||
// The SHA256 hash is computed on the exact JSON string
|
||||
payload := struct {
|
||||
Variables map[string]interface{} `json:"variables"`
|
||||
OperationName string `json:"operationName"`
|
||||
Extensions struct {
|
||||
PersistedQuery struct {
|
||||
Version int `json:"version"`
|
||||
Sha256Hash string `json:"sha256Hash"`
|
||||
} `json:"persistedQuery"`
|
||||
} `json:"extensions"`
|
||||
}{
|
||||
Variables: variables,
|
||||
OperationName: operationName,
|
||||
}
|
||||
payload.Extensions.PersistedQuery.Version = 1
|
||||
payload.Extensions.PersistedQuery.Sha256Hash = hash
|
||||
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api-partner.spotify.com/pathfinder/v1/query", bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
if tok.ClientToken != "" {
|
||||
req.Header.Set("Client-Token", tok.ClientToken)
|
||||
}
|
||||
req.Header.Set("Spotify-App-Version", tok.ClientVersion)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("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")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Token expired, refresh and retry
|
||||
c.mu.Lock()
|
||||
c.token = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Retry request
|
||||
c.mu.RLock()
|
||||
tok = c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
if tok.ClientToken != "" {
|
||||
req.Header.Set("Client-Token", tok.ClientToken)
|
||||
}
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GraphQL query failed: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTrack fetches track metadata by ID
|
||||
func (c *Client) GetTrack(trackID string) (*Track, error) {
|
||||
variables := map[string]interface{}{
|
||||
"uri": fmt.Sprintf("spotify:track:%s", trackID),
|
||||
}
|
||||
|
||||
data, err := c.graphqlQuery("getTrack", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackData, ok := getNestedMap(data, "data", "trackUnion")
|
||||
if !ok {
|
||||
return nil, errors.New("track not found in response")
|
||||
}
|
||||
|
||||
if getString(trackData, "__typename") != "Track" {
|
||||
return nil, errors.New("item is not a track")
|
||||
}
|
||||
|
||||
// Extract artists
|
||||
var artists []Artist
|
||||
if firstArtist, ok := getNestedMap(trackData, "firstArtist"); ok {
|
||||
if profile, ok := getNestedMap(firstArtist, "profile"); ok {
|
||||
artists = append(artists, Artist{
|
||||
ID: getString(firstArtist, "id"),
|
||||
Name: getString(profile, "name"),
|
||||
URI: getString(firstArtist, "uri"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if otherArtists, ok := getNestedMap(trackData, "otherArtists"); ok {
|
||||
if items, ok := otherArtists["items"].([]interface{}); ok {
|
||||
for _, item := range items {
|
||||
if artist, ok := item.(map[string]interface{}); ok {
|
||||
if profile, ok := getNestedMap(artist, "profile"); ok {
|
||||
artists = append(artists, Artist{
|
||||
ID: getString(artist, "id"),
|
||||
Name: getString(profile, "name"),
|
||||
URI: getString(artist, "uri"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract album
|
||||
var album Album
|
||||
if albumData, ok := getNestedMap(trackData, "albumOfTrack"); ok {
|
||||
album = Album{
|
||||
ID: getString(albumData, "id"),
|
||||
Name: getString(albumData, "name"),
|
||||
URI: getString(albumData, "uri"),
|
||||
}
|
||||
|
||||
if visualIdentity, ok := getNestedMap(albumData, "visualIdentity"); ok {
|
||||
if avatarImage, ok := getNestedMap(visualIdentity, "avatarImage"); ok {
|
||||
if sources, ok := avatarImage["sources"].([]interface{}); ok && len(sources) > 0 {
|
||||
if img, ok := sources[0].(map[string]interface{}); ok {
|
||||
album.Images = append(album.Images, Image{
|
||||
URL: getString(img, "url"),
|
||||
Width: int(getFloat(img, "width")),
|
||||
Height: int(getFloat(img, "height")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get duration
|
||||
durationMs := 0
|
||||
if duration, ok := getNestedMap(trackData, "duration"); ok {
|
||||
durationMs = int(getFloat(duration, "totalMilliseconds"))
|
||||
}
|
||||
|
||||
// Check explicit
|
||||
explicit := false
|
||||
if contentRating, ok := getNestedMap(trackData, "contentRating"); ok {
|
||||
explicit = getString(contentRating, "label") == "EXPLICIT"
|
||||
}
|
||||
|
||||
track := &Track{
|
||||
ID: getString(trackData, "id"),
|
||||
Name: getString(trackData, "name"),
|
||||
Artists: artists,
|
||||
Album: album,
|
||||
DurationMs: durationMs,
|
||||
Explicit: explicit,
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", trackID),
|
||||
},
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Search searches for tracks (uses public search endpoint)
|
||||
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"q": {query},
|
||||
"type": {"track"},
|
||||
"limit": {strconv.Itoa(limit)},
|
||||
"market": {"US"},
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?%s", params.Encode())
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"images"`
|
||||
} `json:"album"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
} `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tracks []Track
|
||||
for _, item := range data.Tracks.Items {
|
||||
var artists []Artist
|
||||
for _, a := range item.Artists {
|
||||
artists = append(artists, Artist{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
})
|
||||
}
|
||||
|
||||
var images []Image
|
||||
for _, img := range item.Album.Images {
|
||||
images = append(images, Image{
|
||||
URL: img.URL,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
})
|
||||
}
|
||||
|
||||
tracks = append(tracks, Track{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Artists: artists,
|
||||
DurationMs: item.DurationMs,
|
||||
Explicit: item.Explicit,
|
||||
Album: Album{
|
||||
ID: item.Album.ID,
|
||||
Name: item.Album.Name,
|
||||
Images: images,
|
||||
},
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateDeviceID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func getNestedMap(m map[string]interface{}, keys ...string) (map[string]interface{}, bool) {
|
||||
current := m
|
||||
for _, key := range keys {
|
||||
next, ok := current[key].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getFloat(m map[string]interface{}, key string) float64 {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(v, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// URL parsing helpers
|
||||
|
||||
var spotifyIDRegex = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||
|
||||
// ParseSpotifyURL extracts the type and ID from a Spotify URL
|
||||
func ParseSpotifyURL(urlStr string) (itemType, itemID string, err error) {
|
||||
urlStr = strings.TrimSpace(urlStr)
|
||||
if urlStr == "" {
|
||||
return "", "", errors.New("invalid Spotify URL")
|
||||
}
|
||||
if matches := regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`).FindStringSubmatch(urlStr); len(matches) == 3 {
|
||||
return strings.ToLower(matches[1]), matches[2], nil
|
||||
}
|
||||
|
||||
parsed, parseErr := parseSpotifyWebURL(urlStr)
|
||||
if parseErr != nil {
|
||||
return "", "", parseErr
|
||||
}
|
||||
return parsed.itemType, parsed.itemID, nil
|
||||
}
|
||||
|
||||
type parsedSpotifyWebURL struct {
|
||||
itemType string
|
||||
itemID string
|
||||
}
|
||||
|
||||
func parseSpotifyWebURL(raw string) (parsedSpotifyWebURL, error) {
|
||||
if !strings.Contains(raw, "://") {
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "open.spotify.com/") || strings.HasPrefix(lower, "play.spotify.com/") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return parsedSpotifyWebURL{}, err
|
||||
}
|
||||
if value := u.Query().Get("uri"); value != "" {
|
||||
itemType, itemID, err := ParseSpotifyURL(value)
|
||||
if err != nil {
|
||||
return parsedSpotifyWebURL{}, err
|
||||
}
|
||||
return parsedSpotifyWebURL{itemType: itemType, itemID: itemID}, nil
|
||||
}
|
||||
|
||||
host := strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
||||
if host != "open.spotify.com" && host != "play.spotify.com" && host != "embed.spotify.com" {
|
||||
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 4)
|
||||
for _, part := range strings.Split(u.Path, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && spotifyIDRegex.MatchString(parts[3]) {
|
||||
return parsedSpotifyWebURL{itemType: "playlist", itemID: parts[3]}, nil
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
itemType := strings.ToLower(parts[0])
|
||||
switch itemType {
|
||||
case "track", "album", "playlist", "artist":
|
||||
if spotifyIDRegex.MatchString(parts[1]) {
|
||||
return parsedSpotifyWebURL{itemType: itemType, itemID: parts[1]}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package webplayer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSpotifyURLVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantType string
|
||||
wantID string
|
||||
}{
|
||||
{name: "open URL", url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "intl URL", url: "https://open.spotify.com/intl-cs/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||
{name: "URI", url: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", wantType: "playlist", wantID: "37i9dQZF1DXcBWIGoYBM5M"},
|
||||
{name: "embed URI", url: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if itemType != tt.wantType || itemID != tt.wantID {
|
||||
t.Fatalf("got type=%q id=%q, want type=%q id=%q", itemType, itemID, tt.wantType, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebPlayerIntegration tests against real Spotify endpoints
|
||||
// Run with: go test -v -run TestWebPlayerIntegration ./... -tags=integration
|
||||
// Or set WEBPLAYER_TEST=1 environment variable
|
||||
func TestWebPlayerIntegration(t *testing.T) {
|
||||
if os.Getenv("WEBPLAYER_TEST") == "" {
|
||||
t.Skip("Skipping integration test. Set WEBPLAYER_TEST=1 to run")
|
||||
}
|
||||
|
||||
client := NewClient()
|
||||
|
||||
t.Run("GetTrack", func(t *testing.T) {
|
||||
// Test with "Bohemian Rhapsody" - a well-known track
|
||||
track, err := client.GetTrack("7tFiyTwD0nx5a1eklYtX2J")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack failed: %v", err)
|
||||
}
|
||||
|
||||
if track.ID == "" {
|
||||
t.Error("track ID is empty")
|
||||
}
|
||||
if track.Name == "" {
|
||||
t.Error("track name is empty")
|
||||
}
|
||||
if len(track.Artists) == 0 {
|
||||
t.Error("no artists found")
|
||||
}
|
||||
if track.Album.Name == "" {
|
||||
t.Error("album name is empty")
|
||||
}
|
||||
|
||||
t.Logf("Got track: %s by %s (%d artists) from album %s, duration=%dms",
|
||||
track.Name,
|
||||
track.Artists[0].Name,
|
||||
len(track.Artists),
|
||||
track.Album.Name,
|
||||
track.DurationMs,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
tracks, err := client.Search("Bohemian Rhapsody Queen", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) == 0 {
|
||||
t.Error("no tracks found in search results")
|
||||
}
|
||||
|
||||
for i, track := range tracks {
|
||||
t.Logf("Result %d: %s by %s", i+1, track.Name, track.Artists[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ParseSpotifyURL", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
wantType string
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J",
|
||||
wantType: "track",
|
||||
wantID: "7tFiyTwD0nx5a1eklYtX2J",
|
||||
},
|
||||
{
|
||||
url: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx",
|
||||
wantType: "album",
|
||||
wantID: "1GbtB4zTqAsyfZEsm1RZfx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||
if err != nil {
|
||||
t.Errorf("ParseSpotifyURL(%q) error: %v", tt.url, err)
|
||||
continue
|
||||
}
|
||||
if itemType != tt.wantType {
|
||||
t.Errorf("ParseSpotifyURL(%q) type = %q, want %q", tt.url, itemType, tt.wantType)
|
||||
}
|
||||
if itemID != tt.wantID {
|
||||
t.Errorf("ParseSpotifyURL(%q) ID = %q, want %q", tt.url, itemID, tt.wantID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTOTPGeneration verifies TOTP generation produces valid codes
|
||||
func TestTOTPGeneration(t *testing.T) {
|
||||
totp := generateTOTP()
|
||||
|
||||
// TOTP should be 6 digits
|
||||
if len(totp) != 6 {
|
||||
t.Errorf("TOTP length = %d, want 6", len(totp))
|
||||
}
|
||||
|
||||
// Should only contain digits
|
||||
for _, c := range totp {
|
||||
if c < '0' || c > '9' {
|
||||
t.Errorf("TOTP contains non-digit character: %c", c)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Generated TOTP: %s", totp)
|
||||
}
|
||||
Reference in New Issue
Block a user