mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 12:33:03 +00:00
816 lines
20 KiB
Go
816 lines
20 KiB
Go
// 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")
|
|
}
|