mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 12:33:03 +00:00
203 lines
5.0 KiB
Go
203 lines
5.0 KiB
Go
// Package songlink provides a client for the Song.link/Odesli API.
|
|
// Song.link offers free cross-platform music URL mapping.
|
|
package songlink
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
apiBase = "https://api.song.link/v1-alpha.1"
|
|
minRequestInterval = 7 * time.Second
|
|
maxRequestsPerMinute = 9
|
|
)
|
|
|
|
// PlatformLink represents a link to a track on a specific platform
|
|
type PlatformLink struct {
|
|
Platform string `json:"platform"`
|
|
URL string `json:"url"`
|
|
EntityType string `json:"entity_type"`
|
|
ID string `json:"id,omitempty"`
|
|
NativeURI string `json:"native_uri,omitempty"`
|
|
}
|
|
|
|
// CrossPlatformLinks holds links for a track across multiple platforms
|
|
type CrossPlatformLinks struct {
|
|
SpotifyID string `json:"spotify_id"`
|
|
ISRC string `json:"isrc,omitempty"`
|
|
Links map[string]PlatformLink `json:"links"`
|
|
}
|
|
|
|
// Client for Song.link API
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
lastRequestTime time.Time
|
|
requestCount int
|
|
countResetTime time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewClient creates a new Song.link client
|
|
func NewClient() *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
countResetTime: time.Now(),
|
|
}
|
|
}
|
|
|
|
// Configured always returns true for Song.link (no API key needed)
|
|
func (c *Client) Configured() bool {
|
|
return true
|
|
}
|
|
|
|
func (c *Client) rateLimit() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Reset counter every minute
|
|
if now.Sub(c.countResetTime) >= time.Minute {
|
|
c.requestCount = 0
|
|
c.countResetTime = now
|
|
}
|
|
|
|
// Check if we've hit the per-minute limit
|
|
if c.requestCount >= maxRequestsPerMinute {
|
|
waitTime := time.Minute - now.Sub(c.countResetTime)
|
|
if waitTime > 0 {
|
|
time.Sleep(waitTime)
|
|
c.requestCount = 0
|
|
c.countResetTime = time.Now()
|
|
}
|
|
}
|
|
|
|
// Ensure minimum interval between requests
|
|
elapsed := now.Sub(c.lastRequestTime)
|
|
if elapsed < minRequestInterval {
|
|
time.Sleep(minRequestInterval - elapsed)
|
|
}
|
|
|
|
c.lastRequestTime = time.Now()
|
|
c.requestCount++
|
|
}
|
|
|
|
// GetLinksFromSpotifyID gets cross-platform links from a Spotify track ID
|
|
func (c *Client) GetLinksFromSpotifyID(spotifyID string) (*CrossPlatformLinks, error) {
|
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID)
|
|
return c.GetLinks(spotifyURL)
|
|
}
|
|
|
|
// GetLinks gets cross-platform links from any music URL
|
|
func (c *Client) GetLinks(musicURL string) (*CrossPlatformLinks, error) {
|
|
c.rateLimit()
|
|
|
|
params := url.Values{
|
|
"url": {musicURL},
|
|
"userCountry": {"US"},
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("%s/links?%s", apiBase, params.Encode())
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", "SpotifyRecAlg/1.0")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
// Rate limited - wait and retry once
|
|
retryAfter := 15
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
fmt.Sscanf(ra, "%d", &retryAfter)
|
|
}
|
|
time.Sleep(time.Duration(retryAfter) * time.Second)
|
|
return c.GetLinks(musicURL)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("song.link API error: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var data struct {
|
|
EntityUniqueID string `json:"entityUniqueId"`
|
|
UserCountry string `json:"userCountry"`
|
|
PageURL string `json:"pageUrl"`
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
EntityUniqueID string `json:"entityUniqueId"`
|
|
} `json:"linksByPlatform"`
|
|
EntitiesByUniqueID map[string]struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artistName"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
APIProvider string `json:"apiProvider"`
|
|
Platforms []string `json:"platforms"`
|
|
} `json:"entitiesByUniqueId"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
links := &CrossPlatformLinks{
|
|
Links: make(map[string]PlatformLink),
|
|
}
|
|
|
|
// Extract Spotify ID
|
|
for uniqueID, entity := range data.EntitiesByUniqueID {
|
|
if entity.APIProvider == "spotify" {
|
|
links.SpotifyID = entity.ID
|
|
}
|
|
if entity.Type == "song" {
|
|
// ISRC can sometimes be derived from the unique ID format
|
|
_ = uniqueID
|
|
}
|
|
}
|
|
|
|
// Platform name mapping
|
|
platformNames := map[string]string{
|
|
"spotify": "spotify",
|
|
"tidal": "tidal",
|
|
"qobuz": "qobuz",
|
|
"amazonMusic": "amazonMusic",
|
|
"amazonStore": "amazon",
|
|
"deezer": "deezer",
|
|
"appleMusic": "appleMusic",
|
|
"youtube": "youtube",
|
|
"youtubeMusic": "youtubeMusic",
|
|
"soundcloud": "soundcloud",
|
|
"napster": "napster",
|
|
"pandora": "pandora",
|
|
}
|
|
|
|
for platform, linkData := range data.LinksByPlatform {
|
|
if name, ok := platformNames[platform]; ok {
|
|
links.Links[name] = PlatformLink{
|
|
Platform: platform,
|
|
URL: linkData.URL,
|
|
EntityType: "track",
|
|
}
|
|
}
|
|
}
|
|
|
|
return links, nil
|
|
}
|