mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
1091 lines
29 KiB
Go
1091 lines
29 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
|
artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists"
|
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
|
|
|
artistCacheTTL = 10 * time.Minute
|
|
searchCacheTTL = 5 * time.Minute
|
|
albumCacheTTL = 10 * time.Minute
|
|
)
|
|
|
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
|
|
|
type cacheEntry struct {
|
|
data interface{}
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func (e *cacheEntry) isExpired() bool {
|
|
return time.Now().After(e.expiresAt)
|
|
}
|
|
|
|
type SpotifyMetadataClient struct {
|
|
httpClient *http.Client
|
|
clientID string
|
|
clientSecret string
|
|
cachedToken string
|
|
tokenExpiresAt time.Time
|
|
tokenMu sync.Mutex
|
|
rng *rand.Rand
|
|
rngMu sync.Mutex
|
|
userAgent string
|
|
|
|
artistCache map[string]*cacheEntry
|
|
searchCache map[string]*cacheEntry
|
|
albumCache map[string]*cacheEntry
|
|
cacheMu sync.RWMutex
|
|
}
|
|
|
|
var (
|
|
customClientID string
|
|
customClientSecret string
|
|
credentialsMu sync.RWMutex
|
|
)
|
|
|
|
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
|
|
|
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
|
credentialsMu.Lock()
|
|
defer credentialsMu.Unlock()
|
|
customClientID = ""
|
|
customClientSecret = ""
|
|
}
|
|
|
|
func HasSpotifyCredentials() bool {
|
|
return false
|
|
}
|
|
|
|
func getCredentials() (string, string, error) {
|
|
return "", "", ErrNoSpotifyCredentials
|
|
}
|
|
|
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|
clientID, clientSecret, err := getCredentials()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
src := rand.NewSource(time.Now().UnixNano())
|
|
|
|
c := &SpotifyMetadataClient{
|
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
rng: rand.New(src),
|
|
artistCache: make(map[string]*cacheEntry),
|
|
searchCache: make(map[string]*cacheEntry),
|
|
albumCache: make(map[string]*cacheEntry),
|
|
}
|
|
c.userAgent = c.randomUserAgent()
|
|
return c, nil
|
|
}
|
|
|
|
type TrackMetadata struct {
|
|
SpotifyID string `json:"spotify_id,omitempty"`
|
|
Artists string `json:"artists"`
|
|
Name string `json:"name"`
|
|
AlbumName string `json:"album_name"`
|
|
AlbumArtist string `json:"album_artist,omitempty"`
|
|
DurationMS int `json:"duration_ms"`
|
|
Images string `json:"images"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TrackNumber int `json:"track_number"`
|
|
TotalTracks int `json:"total_tracks,omitempty"`
|
|
DiscNumber int `json:"disc_number,omitempty"`
|
|
ExternalURL string `json:"external_urls"`
|
|
ISRC string `json:"isrc"`
|
|
AlbumID string `json:"album_id,omitempty"`
|
|
ArtistID string `json:"artist_id,omitempty"`
|
|
AlbumType string `json:"album_type,omitempty"`
|
|
}
|
|
|
|
type AlbumTrackMetadata struct {
|
|
SpotifyID string `json:"spotify_id,omitempty"`
|
|
Artists string `json:"artists"`
|
|
Name string `json:"name"`
|
|
AlbumName string `json:"album_name"`
|
|
AlbumArtist string `json:"album_artist,omitempty"`
|
|
DurationMS int `json:"duration_ms"`
|
|
Images string `json:"images"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TrackNumber int `json:"track_number"`
|
|
TotalTracks int `json:"total_tracks,omitempty"`
|
|
DiscNumber int `json:"disc_number,omitempty"`
|
|
ExternalURL string `json:"external_urls"`
|
|
ISRC string `json:"isrc"`
|
|
AlbumID string `json:"album_id,omitempty"`
|
|
AlbumURL string `json:"album_url,omitempty"`
|
|
AlbumType string `json:"album_type,omitempty"`
|
|
}
|
|
|
|
type AlbumInfoMetadata struct {
|
|
TotalTracks int `json:"total_tracks"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
Artists string `json:"artists"`
|
|
ArtistId string `json:"artist_id,omitempty"`
|
|
Images string `json:"images"`
|
|
Genre string `json:"genre,omitempty"`
|
|
Label string `json:"label,omitempty"`
|
|
Copyright string `json:"copyright,omitempty"`
|
|
}
|
|
|
|
type AlbumResponsePayload struct {
|
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
|
}
|
|
|
|
type PlaylistInfoMetadata struct {
|
|
Name string `json:"name,omitempty"`
|
|
Images string `json:"images,omitempty"`
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
Owner struct {
|
|
DisplayName string `json:"display_name"`
|
|
Name string `json:"name"`
|
|
Images string `json:"images"`
|
|
} `json:"owner"`
|
|
}
|
|
|
|
type PlaylistResponsePayload struct {
|
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
|
}
|
|
|
|
type ArtistInfoMetadata struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images string `json:"images"`
|
|
Followers int `json:"followers"`
|
|
Popularity int `json:"popularity"`
|
|
}
|
|
|
|
type ArtistAlbumMetadata struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Images string `json:"images"`
|
|
AlbumType string `json:"album_type"`
|
|
Artists string `json:"artists"`
|
|
}
|
|
|
|
type ArtistResponsePayload struct {
|
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
|
}
|
|
|
|
type TrackResponse struct {
|
|
Track TrackMetadata `json:"track"`
|
|
}
|
|
|
|
type SearchResult struct {
|
|
Tracks []TrackMetadata `json:"tracks"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type SearchArtistResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images string `json:"images"`
|
|
Followers int `json:"followers"`
|
|
Popularity int `json:"popularity"`
|
|
}
|
|
|
|
type SearchAlbumResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Artists string `json:"artists"`
|
|
Images string `json:"images"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
AlbumType string `json:"album_type"`
|
|
}
|
|
|
|
type SearchPlaylistResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Owner string `json:"owner"`
|
|
Images string `json:"images"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
}
|
|
|
|
type SearchAllResult struct {
|
|
Tracks []TrackMetadata `json:"tracks"`
|
|
Artists []SearchArtistResult `json:"artists"`
|
|
Albums []SearchAlbumResult `json:"albums"`
|
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
|
}
|
|
|
|
type spotifyURI struct {
|
|
Type string
|
|
ID string
|
|
}
|
|
|
|
type accessTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn interface{} `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
type image struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type externalURL struct {
|
|
Spotify string `json:"spotify"`
|
|
}
|
|
|
|
type externalID struct {
|
|
ISRC string `json:"isrc"`
|
|
}
|
|
|
|
type artist struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type albumSimplified struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Artists []artist `json:"artists"`
|
|
AlbumType string `json:"album_type"`
|
|
}
|
|
|
|
type trackFull struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DurationMS int `json:"duration_ms"`
|
|
TrackNumber int `json:"track_number"`
|
|
DiscNumber int `json:"disc_number"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
ExternalID externalID `json:"external_ids"`
|
|
Album albumSimplified `json:"album"`
|
|
Artists []artist `json:"artists"`
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
|
parsed, err := parseSpotifyURI(spotifyURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch parsed.Type {
|
|
case "track":
|
|
return c.fetchTrack(ctx, parsed.ID, token)
|
|
case "album":
|
|
return c.fetchAlbum(ctx, parsed.ID, token)
|
|
case "playlist":
|
|
return c.fetchPlaylist(ctx, parsed.ID, token)
|
|
case "artist":
|
|
return c.fetchArtist(ctx, parsed.ID, token)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
|
|
|
var response struct {
|
|
Tracks struct {
|
|
Items []trackFull `json:"items"`
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &SearchResult{
|
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
|
Total: response.Tracks.Total,
|
|
}
|
|
|
|
for _, track := range response.Tracks.Items {
|
|
var firstArtistID string
|
|
if len(track.Artists) > 0 {
|
|
firstArtistID = track.Artists[0].ID
|
|
}
|
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
|
SpotifyID: track.ID,
|
|
Artists: joinArtists(track.Artists),
|
|
Name: track.Name,
|
|
AlbumName: track.Album.Name,
|
|
AlbumArtist: joinArtists(track.Album.Artists),
|
|
DurationMS: track.DurationMS,
|
|
Images: firstImageURL(track.Album.Images),
|
|
ReleaseDate: track.Album.ReleaseDate,
|
|
TrackNumber: track.TrackNumber,
|
|
TotalTracks: track.Album.TotalTracks,
|
|
DiscNumber: track.DiscNumber,
|
|
ExternalURL: track.ExternalURL.Spotify,
|
|
ISRC: track.ExternalID.ISRC,
|
|
AlbumID: track.Album.ID,
|
|
ArtistID: firstArtistID,
|
|
AlbumType: track.Album.AlbumType,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
|
|
|
c.cacheMu.RLock()
|
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
|
c.cacheMu.RUnlock()
|
|
return entry.data.(*SearchAllResult), nil
|
|
}
|
|
c.cacheMu.RUnlock()
|
|
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
|
|
|
var response struct {
|
|
Tracks struct {
|
|
Items []trackFull `json:"items"`
|
|
} `json:"tracks"`
|
|
Artists struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Popularity int `json:"popularity"`
|
|
} `json:"items"`
|
|
} `json:"artists"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &SearchAllResult{
|
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
|
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
|
}
|
|
|
|
for _, track := range response.Tracks.Items {
|
|
var firstArtistID string
|
|
if len(track.Artists) > 0 {
|
|
firstArtistID = track.Artists[0].ID
|
|
}
|
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
|
SpotifyID: track.ID,
|
|
Artists: joinArtists(track.Artists),
|
|
Name: track.Name,
|
|
AlbumName: track.Album.Name,
|
|
AlbumArtist: joinArtists(track.Album.Artists),
|
|
DurationMS: track.DurationMS,
|
|
Images: firstImageURL(track.Album.Images),
|
|
ReleaseDate: track.Album.ReleaseDate,
|
|
TrackNumber: track.TrackNumber,
|
|
TotalTracks: track.Album.TotalTracks,
|
|
DiscNumber: track.DiscNumber,
|
|
ExternalURL: track.ExternalURL.Spotify,
|
|
ISRC: track.ExternalID.ISRC,
|
|
AlbumID: track.Album.ID,
|
|
ArtistID: firstArtistID,
|
|
AlbumType: track.Album.AlbumType,
|
|
})
|
|
}
|
|
|
|
artistCount := len(response.Artists.Items)
|
|
if artistCount > artistLimit {
|
|
artistCount = artistLimit
|
|
}
|
|
|
|
for i := 0; i < artistCount; i++ {
|
|
artist := response.Artists.Items[i]
|
|
result.Artists = append(result.Artists, SearchArtistResult{
|
|
ID: artist.ID,
|
|
Name: artist.Name,
|
|
Images: firstImageURL(artist.Images),
|
|
Followers: artist.Followers.Total,
|
|
Popularity: artist.Popularity,
|
|
})
|
|
}
|
|
|
|
c.cacheMu.Lock()
|
|
c.searchCache[cacheKey] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(searchCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
|
var data trackFull
|
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TrackResponse{
|
|
Track: TrackMetadata{
|
|
SpotifyID: data.ID,
|
|
Artists: joinArtists(data.Artists),
|
|
Name: data.Name,
|
|
AlbumName: data.Album.Name,
|
|
AlbumArtist: joinArtists(data.Album.Artists),
|
|
DurationMS: data.DurationMS,
|
|
Images: firstImageURL(data.Album.Images),
|
|
ReleaseDate: data.Album.ReleaseDate,
|
|
TrackNumber: data.TrackNumber,
|
|
TotalTracks: data.Album.TotalTracks,
|
|
DiscNumber: data.DiscNumber,
|
|
ExternalURL: data.ExternalURL.Spotify,
|
|
ISRC: data.ExternalID.ISRC,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
|
c.cacheMu.RLock()
|
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
|
c.cacheMu.RUnlock()
|
|
return entry.data.(*AlbumResponsePayload), nil
|
|
}
|
|
c.cacheMu.RUnlock()
|
|
|
|
type trackItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DurationMS int `json:"duration_ms"`
|
|
TrackNumber int `json:"track_number"`
|
|
DiscNumber int `json:"disc_number"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Artists []artist `json:"artists"`
|
|
}
|
|
|
|
var data struct {
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Images []image `json:"images"`
|
|
Artists []artist `json:"artists"`
|
|
Tracks struct {
|
|
Items []trackItem `json:"items"`
|
|
Next string `json:"next"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
albumImage := firstImageURL(data.Images)
|
|
|
|
var firstArtistId string
|
|
if len(data.Artists) > 0 {
|
|
firstArtistId = data.Artists[0].ID
|
|
}
|
|
|
|
info := AlbumInfoMetadata{
|
|
TotalTracks: data.TotalTracks,
|
|
Name: data.Name,
|
|
ReleaseDate: data.ReleaseDate,
|
|
Artists: joinArtists(data.Artists),
|
|
ArtistId: firstArtistId,
|
|
Images: albumImage,
|
|
}
|
|
|
|
allTrackItems := data.Tracks.Items
|
|
nextURL := data.Tracks.Next
|
|
|
|
for nextURL != "" {
|
|
var pageData struct {
|
|
Items []trackItem `json:"items"`
|
|
Next string `json:"next"`
|
|
}
|
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
|
break
|
|
}
|
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
|
nextURL = pageData.Next
|
|
}
|
|
|
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
|
|
|
trackIDs := make([]string, len(allTrackItems))
|
|
for i, item := range allTrackItems {
|
|
trackIDs[i] = item.ID
|
|
}
|
|
|
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
|
for _, item := range allTrackItems {
|
|
isrc := isrcMap[item.ID]
|
|
|
|
tracks = append(tracks, AlbumTrackMetadata{
|
|
SpotifyID: item.ID,
|
|
Artists: joinArtists(item.Artists),
|
|
Name: item.Name,
|
|
AlbumName: data.Name,
|
|
AlbumArtist: joinArtists(data.Artists),
|
|
DurationMS: item.DurationMS,
|
|
Images: albumImage,
|
|
ReleaseDate: data.ReleaseDate,
|
|
TrackNumber: item.TrackNumber,
|
|
TotalTracks: data.TotalTracks,
|
|
DiscNumber: item.DiscNumber,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
ISRC: isrc,
|
|
AlbumID: albumID,
|
|
})
|
|
}
|
|
|
|
result := &AlbumResponsePayload{
|
|
AlbumInfo: info,
|
|
TrackList: tracks,
|
|
}
|
|
|
|
c.cacheMu.Lock()
|
|
c.albumCache[albumID] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(albumCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
|
const maxParallelISRC = 10
|
|
|
|
result := make(map[string]string)
|
|
var resultMu sync.Mutex
|
|
|
|
if len(trackIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
sem := make(chan struct{}, maxParallelISRC)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, trackID := range trackIDs {
|
|
wg.Add(1)
|
|
go func(id string) {
|
|
defer wg.Done()
|
|
|
|
select {
|
|
case sem <- struct{}{}:
|
|
defer func() { <-sem }()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
|
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
|
|
|
resultMu.Lock()
|
|
result[id] = isrc
|
|
resultMu.Unlock()
|
|
}(trackID)
|
|
}
|
|
|
|
wg.Wait()
|
|
return result
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
|
var data struct {
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
Owner struct {
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"owner"`
|
|
Tracks struct {
|
|
Items []struct {
|
|
Track *trackFull `json:"track"`
|
|
} `json:"items"`
|
|
Total int `json:"total"`
|
|
Next string `json:"next"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info PlaylistInfoMetadata
|
|
info.Tracks.Total = data.Tracks.Total
|
|
info.Owner.DisplayName = data.Owner.DisplayName
|
|
info.Owner.Name = data.Name
|
|
info.Owner.Images = firstImageURL(data.Images)
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
|
|
|
for _, item := range data.Tracks.Items {
|
|
if item.Track == nil {
|
|
continue
|
|
}
|
|
tracks = append(tracks, AlbumTrackMetadata{
|
|
SpotifyID: item.Track.ID,
|
|
Artists: joinArtists(item.Track.Artists),
|
|
Name: item.Track.Name,
|
|
AlbumName: item.Track.Album.Name,
|
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
|
DurationMS: item.Track.DurationMS,
|
|
Images: firstImageURL(item.Track.Album.Images),
|
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
|
TrackNumber: item.Track.TrackNumber,
|
|
TotalTracks: item.Track.Album.TotalTracks,
|
|
DiscNumber: item.Track.DiscNumber,
|
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
|
ISRC: item.Track.ExternalID.ISRC,
|
|
AlbumID: item.Track.Album.ID,
|
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
|
})
|
|
}
|
|
|
|
nextURL := data.Tracks.Next
|
|
|
|
for nextURL != "" {
|
|
var pageData struct {
|
|
Items []struct {
|
|
Track *trackFull `json:"track"`
|
|
} `json:"items"`
|
|
Next string `json:"next"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
|
break
|
|
}
|
|
|
|
for _, item := range pageData.Items {
|
|
if item.Track == nil {
|
|
continue
|
|
}
|
|
tracks = append(tracks, AlbumTrackMetadata{
|
|
SpotifyID: item.Track.ID,
|
|
Artists: joinArtists(item.Track.Artists),
|
|
Name: item.Track.Name,
|
|
AlbumName: item.Track.Album.Name,
|
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
|
DurationMS: item.Track.DurationMS,
|
|
Images: firstImageURL(item.Track.Album.Images),
|
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
|
TrackNumber: item.Track.TrackNumber,
|
|
TotalTracks: item.Track.Album.TotalTracks,
|
|
DiscNumber: item.Track.DiscNumber,
|
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
|
ISRC: item.Track.ExternalID.ISRC,
|
|
AlbumID: item.Track.Album.ID,
|
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
|
})
|
|
}
|
|
|
|
nextURL = pageData.Next
|
|
}
|
|
|
|
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
|
|
|
return &PlaylistResponsePayload{
|
|
PlaylistInfo: info,
|
|
TrackList: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
|
c.cacheMu.RLock()
|
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
|
c.cacheMu.RUnlock()
|
|
return entry.data.(*ArtistResponsePayload), nil
|
|
}
|
|
c.cacheMu.RUnlock()
|
|
|
|
var artistData struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Popularity int `json:"popularity"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
artistInfo := ArtistInfoMetadata{
|
|
ID: artistData.ID,
|
|
Name: artistData.Name,
|
|
Images: firstImageURL(artistData.Images),
|
|
Followers: artistData.Followers.Total,
|
|
Popularity: artistData.Popularity,
|
|
}
|
|
|
|
albums := make([]ArtistAlbumMetadata, 0)
|
|
offset := 0
|
|
limit := 50
|
|
|
|
for {
|
|
albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d",
|
|
fmt.Sprintf(artistAlbumsURL, artistID), limit, offset)
|
|
|
|
var albumsData struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Images []image `json:"images"`
|
|
AlbumType string `json:"album_type"`
|
|
Artists []artist `json:"artists"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
} `json:"items"`
|
|
Next string `json:"next"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, album := range albumsData.Items {
|
|
albums = append(albums, ArtistAlbumMetadata{
|
|
ID: album.ID,
|
|
Name: album.Name,
|
|
ReleaseDate: album.ReleaseDate,
|
|
TotalTracks: album.TotalTracks,
|
|
Images: firstImageURL(album.Images),
|
|
AlbumType: album.AlbumType,
|
|
Artists: joinArtists(album.Artists),
|
|
})
|
|
}
|
|
|
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
|
break
|
|
}
|
|
offset += limit
|
|
|
|
if offset > 500 {
|
|
break
|
|
}
|
|
}
|
|
|
|
result := &ArtistResponsePayload{
|
|
ArtistInfo: artistInfo,
|
|
Albums: albums,
|
|
}
|
|
|
|
c.cacheMu.Lock()
|
|
c.artistCache[artistID] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(artistCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var data struct {
|
|
Artists []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Popularity int `json:"popularity"`
|
|
} `json:"artists"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
maxItems := len(data.Artists)
|
|
if limit > 0 && limit < maxItems {
|
|
maxItems = limit
|
|
}
|
|
|
|
result := make([]SearchArtistResult, 0, maxItems)
|
|
for i := 0; i < maxItems; i++ {
|
|
artist := data.Artists[i]
|
|
result = append(result, SearchArtistResult{
|
|
ID: artist.ID,
|
|
Name: artist.Name,
|
|
Images: firstImageURL(artist.Images),
|
|
Followers: artist.Followers.Total,
|
|
Popularity: artist.Popularity,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
|
var data struct {
|
|
ExternalID externalID `json:"external_ids"`
|
|
}
|
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
|
return ""
|
|
}
|
|
return data.ExternalID.ISRC
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
|
|
if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
|
|
return c.cachedToken, nil
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("grant_type", "client_credentials")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req.SetBasicAuth(c.clientID, c.clientSecret)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("failed to get access token: %d", resp.StatusCode)
|
|
}
|
|
|
|
var token accessTokenResponse
|
|
if err := json.Unmarshal(body, &token); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
c.cachedToken = token.AccessToken
|
|
if expiresIn, ok := token.ExpiresIn.(float64); ok {
|
|
c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
|
|
}
|
|
|
|
return token.AccessToken, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
|
req.Header.Set("sec-fetch-dest", "empty")
|
|
req.Header.Set("sec-fetch-mode", "cors")
|
|
req.Header.Set("sec-fetch-site", "same-origin")
|
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
|
req.Header.Set("Origin", "https://open.spotify.com")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("spotify API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return json.Unmarshal(body, dst)
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|
c.rngMu.Lock()
|
|
defer c.rngMu.Unlock()
|
|
|
|
macMajor := c.rng.Intn(4) + 11
|
|
macMinor := c.rng.Intn(5) + 4
|
|
webkitMajor := c.rng.Intn(7) + 530
|
|
webkitMinor := c.rng.Intn(7) + 30
|
|
chromeMajor := c.rng.Intn(25) + 80
|
|
chromeBuild := c.rng.Intn(1500) + 3000
|
|
chromePatch := c.rng.Intn(65) + 60
|
|
safariMajor := c.rng.Intn(7) + 530
|
|
safariMinor := c.rng.Intn(6) + 30
|
|
|
|
return fmt.Sprintf(
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
|
macMajor, macMinor,
|
|
webkitMajor, webkitMinor,
|
|
chromeMajor, chromeBuild, chromePatch,
|
|
safariMajor, safariMinor,
|
|
)
|
|
}
|
|
|
|
func parseSpotifyURI(input string) (spotifyURI, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
if strings.HasPrefix(trimmed, "spotify:") {
|
|
parts := strings.Split(trimmed, ":")
|
|
if len(parts) == 3 {
|
|
switch parts[1] {
|
|
case "album", "track", "playlist", "artist":
|
|
return spotifyURI{Type: parts[1], ID: parts[2]}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return spotifyURI{}, err
|
|
}
|
|
|
|
if parsed.Host == "embed.spotify.com" {
|
|
if parsed.RawQuery == "" {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
qs, _ := url.ParseQuery(parsed.RawQuery)
|
|
embedded := qs.Get("uri")
|
|
if embedded == "" {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
return parseSpotifyURI(embedded)
|
|
}
|
|
|
|
if parsed.Scheme == "" && parsed.Host == "" {
|
|
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
|
if id == "" {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
return spotifyURI{Type: "playlist", ID: id}, nil
|
|
}
|
|
|
|
if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
parts := cleanPathParts(parsed.Path)
|
|
if len(parts) == 0 {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
// Skip embed prefix if present
|
|
if parts[0] == "embed" {
|
|
parts = parts[1:]
|
|
}
|
|
if len(parts) == 0 {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
if strings.HasPrefix(parts[0], "intl-") {
|
|
parts = parts[1:]
|
|
}
|
|
if len(parts) == 0 {
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
if len(parts) == 2 {
|
|
switch parts[0] {
|
|
case "album", "track", "playlist", "artist":
|
|
return spotifyURI{Type: parts[0], ID: parts[1]}, nil
|
|
}
|
|
}
|
|
|
|
if len(parts) == 4 && parts[2] == "playlist" {
|
|
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
|
}
|
|
|
|
return spotifyURI{}, errInvalidSpotifyURL
|
|
}
|
|
|
|
func cleanPathParts(path string) []string {
|
|
raw := strings.Split(path, "/")
|
|
parts := make([]string, 0, len(raw))
|
|
for _, part := range raw {
|
|
if part != "" {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func joinArtists(artists []artist) string {
|
|
names := make([]string, len(artists))
|
|
for i, a := range artists {
|
|
names[i] = a.Name
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func firstImageURL(images []image) string {
|
|
if len(images) > 0 {
|
|
return images[0].URL
|
|
}
|
|
return ""
|
|
}
|