mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
1390 lines
39 KiB
Go
1390 lines
39 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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"
|
|
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
|
|
)
|
|
|
|
var (
|
|
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
|
)
|
|
|
|
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
|
|
type SpotifyMetadataClient struct {
|
|
httpClient *http.Client
|
|
clientID string
|
|
clientSecret string
|
|
cachedToken string
|
|
tokenExpiresAt time.Time
|
|
rng *rand.Rand
|
|
rngMu sync.Mutex
|
|
userAgent string
|
|
}
|
|
|
|
// NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials.
|
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|
src := rand.NewSource(time.Now().UnixNano())
|
|
|
|
// Decode client ID from base64
|
|
clientID := ""
|
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
clientID = string(decoded)
|
|
}
|
|
|
|
// Decode client secret from base64
|
|
clientSecret := ""
|
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
clientSecret = string(decoded)
|
|
}
|
|
|
|
c := &SpotifyMetadataClient{
|
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
rng: rand.New(src),
|
|
}
|
|
c.userAgent = c.randomUserAgent()
|
|
return c
|
|
}
|
|
|
|
// TrackMetadata mirrors the filtered track payload returned by the Python script.
|
|
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"`
|
|
}
|
|
|
|
// ArtistSimple holds basic artist info for clickable artists
|
|
type ArtistSimple struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ExternalURL string `json:"external_urls"`
|
|
}
|
|
|
|
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
|
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"`
|
|
AlbumType string `json:"album_type,omitempty"`
|
|
AlbumID string `json:"album_id,omitempty"`
|
|
AlbumURL string `json:"album_url,omitempty"`
|
|
ArtistID string `json:"artist_id,omitempty"`
|
|
ArtistURL string `json:"artist_url,omitempty"`
|
|
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
|
}
|
|
|
|
type TrackResponse struct {
|
|
Track TrackMetadata `json:"track"`
|
|
}
|
|
|
|
type AlbumInfoMetadata struct {
|
|
TotalTracks int `json:"total_tracks"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
Artists string `json:"artists"`
|
|
Images string `json:"images"`
|
|
Batch string `json:"batch,omitempty"`
|
|
ArtistID string `json:"artist_id,omitempty"`
|
|
ArtistURL string `json:"artist_url,omitempty"`
|
|
}
|
|
|
|
type AlbumResponsePayload struct {
|
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
|
}
|
|
|
|
type PlaylistInfoMetadata struct {
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Owner struct {
|
|
DisplayName string `json:"display_name"`
|
|
Name string `json:"name"`
|
|
Images string `json:"images"`
|
|
} `json:"owner"`
|
|
Batch string `json:"batch,omitempty"`
|
|
}
|
|
|
|
type PlaylistResponsePayload struct {
|
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
|
}
|
|
|
|
type ArtistInfoMetadata struct {
|
|
Name string `json:"name"`
|
|
Followers int `json:"followers"`
|
|
Genres []string `json:"genres"`
|
|
Images string `json:"images"`
|
|
ExternalURL string `json:"external_urls"`
|
|
DiscographyType string `json:"discography_type"`
|
|
TotalAlbums int `json:"total_albums"`
|
|
Batch string `json:"batch,omitempty"`
|
|
}
|
|
|
|
type DiscographyAlbumMetadata struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AlbumType string `json:"album_type"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Artists string `json:"artists"`
|
|
Images string `json:"images"`
|
|
ExternalURL string `json:"external_urls"`
|
|
}
|
|
|
|
type ArtistDiscographyPayload struct {
|
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
|
AlbumList []DiscographyAlbumMetadata `json:"album_list"`
|
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
|
}
|
|
|
|
type ArtistResponsePayload struct {
|
|
Artist struct {
|
|
Name string `json:"name"`
|
|
Followers int `json:"followers"`
|
|
Genres []string `json:"genres"`
|
|
Images string `json:"images"`
|
|
ExternalURL string `json:"external_urls"`
|
|
Popularity int `json:"popularity"`
|
|
} `json:"artist"`
|
|
}
|
|
|
|
type spotifyURI struct {
|
|
Type string
|
|
ID string
|
|
DiscographyGroup string
|
|
}
|
|
|
|
type accessTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn interface{} `json:"expires_in"` // Can be number or string
|
|
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"`
|
|
AlbumType string `json:"album_type"`
|
|
ReleaseDate string `json:"release_date"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Artists []artist `json:"artists"`
|
|
}
|
|
|
|
type trackSimplified 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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type playlistTrackItem struct {
|
|
Track *trackFull `json:"track"`
|
|
}
|
|
|
|
type playlistResponse struct {
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
Owner struct {
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"owner"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Tracks struct {
|
|
Items []playlistTrackItem `json:"items"`
|
|
Next string `json:"next"`
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
type albumResponse 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 []trackSimplified `json:"items"`
|
|
Next string `json:"next"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
type artistResponse struct {
|
|
Name string `json:"name"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
Genres []string `json:"genres"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Popularity int `json:"popularity"`
|
|
}
|
|
|
|
type playlistRaw struct {
|
|
Data playlistResponse
|
|
BatchEnabled bool
|
|
BatchCount int
|
|
}
|
|
|
|
type albumRaw struct {
|
|
Data albumResponse
|
|
Token string
|
|
BatchEnabled bool
|
|
BatchCount int
|
|
}
|
|
|
|
type discographyRaw struct {
|
|
Artist artistResponse
|
|
Albums []albumSimplified
|
|
Token string
|
|
Discography string
|
|
BatchEnabled bool
|
|
BatchCount int
|
|
}
|
|
|
|
// GetFilteredSpotifyData is a convenience wrapper that mirrors the Python module's entry point.
|
|
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
|
client := NewSpotifyMetadataClient()
|
|
return client.GetFilteredData(ctx, spotifyURL, batch, delay)
|
|
}
|
|
|
|
// GetFilteredData fetches, normalises, and formats Spotify payloads for the given URL.
|
|
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
|
|
}
|
|
|
|
raw, err := c.getRawSpotifyData(ctx, parsed, token, batch, delay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c.processSpotifyData(ctx, raw)
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) {
|
|
switch parsed.Type {
|
|
case "playlist":
|
|
return c.fetchPlaylist(ctx, parsed.ID, token, batch, delay)
|
|
case "album":
|
|
return c.fetchAlbum(ctx, parsed.ID, token, batch, delay)
|
|
case "track":
|
|
return c.fetchTrack(ctx, parsed.ID, token)
|
|
case "artist_discography":
|
|
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
|
|
case "artist":
|
|
// Automatically fetch discography for artist URLs to get full data (albums + tracks)
|
|
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
|
return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
|
switch payload := raw.(type) {
|
|
case *playlistRaw:
|
|
return c.formatPlaylistData(payload), nil
|
|
case *albumRaw:
|
|
return c.formatAlbumData(ctx, payload)
|
|
case *trackFull:
|
|
trackPayload := formatTrackData(payload)
|
|
return trackPayload, nil
|
|
case *discographyRaw:
|
|
return c.formatArtistDiscographyData(ctx, payload)
|
|
case *artistResponse:
|
|
formatted := formatArtistData(payload)
|
|
return formatted, nil
|
|
default:
|
|
return nil, errors.New("unknown raw payload type")
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string, batch bool, delay time.Duration) (*playlistRaw, error) {
|
|
var data playlistResponse
|
|
if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracksURL := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=100", playlistID)
|
|
var items []playlistTrackItem
|
|
batchDelay := time.Duration(0)
|
|
if batch {
|
|
batchDelay = delay
|
|
}
|
|
batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) > 0 {
|
|
data.Tracks.Items = items
|
|
}
|
|
|
|
return &playlistRaw{
|
|
Data: data,
|
|
BatchEnabled: batch,
|
|
BatchCount: batches,
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string, batch bool, delay time.Duration) (*albumRaw, error) {
|
|
var data albumResponse
|
|
if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracksURL := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID))
|
|
var items []trackSimplified
|
|
batchDelay := time.Duration(0)
|
|
if batch {
|
|
batchDelay = delay
|
|
}
|
|
batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) > 0 {
|
|
data.Tracks.Items = items
|
|
}
|
|
|
|
return &albumRaw{
|
|
Data: data,
|
|
Token: token,
|
|
BatchEnabled: batch,
|
|
BatchCount: batches,
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*trackFull, error) {
|
|
var data trackFull
|
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (*discographyRaw, error) {
|
|
var artistData artistResponse
|
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, parsed.ID), token, &artistData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
includeGroups := parsed.DiscographyGroup
|
|
if includeGroups == "" || includeGroups == "all" {
|
|
includeGroups = "album,single,compilation"
|
|
}
|
|
|
|
albumsURL := fmt.Sprintf("%s?include_groups=%s&limit=50", fmt.Sprintf(artistAlbumsBaseURL, parsed.ID), includeGroups)
|
|
var albums []albumSimplified
|
|
batchDelay := time.Duration(0)
|
|
if batch {
|
|
batchDelay = delay
|
|
}
|
|
batches, err := fetchPaging(ctx, c, albumsURL, token, batchDelay, &albums)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &discographyRaw{
|
|
Artist: artistData,
|
|
Albums: albums,
|
|
Token: token,
|
|
Discography: parsed.DiscographyGroup,
|
|
BatchEnabled: batch,
|
|
BatchCount: batches,
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*artistResponse, error) {
|
|
var artistData artistResponse
|
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
|
return nil, err
|
|
}
|
|
return &artistData, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistResponsePayload {
|
|
var info PlaylistInfoMetadata
|
|
info.Tracks.Total = raw.Data.Tracks.Total
|
|
info.Followers.Total = raw.Data.Followers.Total
|
|
info.Owner.DisplayName = raw.Data.Owner.DisplayName
|
|
info.Owner.Name = raw.Data.Name
|
|
info.Owner.Images = firstImageURL(raw.Data.Images)
|
|
if raw.BatchEnabled {
|
|
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
|
}
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items))
|
|
for _, item := range raw.Data.Tracks.Items {
|
|
if item.Track == nil {
|
|
continue
|
|
}
|
|
var artistID, artistURL string
|
|
if len(item.Track.Artists) > 0 {
|
|
artistID = item.Track.Artists[0].ID
|
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", item.Track.Artists[0].ID)
|
|
}
|
|
artistsData := make([]ArtistSimple, 0, len(item.Track.Artists))
|
|
for _, a := range item.Track.Artists {
|
|
artistsData = append(artistsData, ArtistSimple{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
|
})
|
|
}
|
|
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: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.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,
|
|
ArtistID: artistID,
|
|
ArtistURL: artistURL,
|
|
ArtistsData: artistsData,
|
|
})
|
|
}
|
|
|
|
return PlaylistResponsePayload{
|
|
PlaylistInfo: info,
|
|
TrackList: tracks,
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
|
|
albumImage := firstImageURL(raw.Data.Images)
|
|
var artistID, artistURL string
|
|
if len(raw.Data.Artists) > 0 {
|
|
artistID = raw.Data.Artists[0].ID
|
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", raw.Data.Artists[0].ID)
|
|
}
|
|
info := AlbumInfoMetadata{
|
|
TotalTracks: raw.Data.TotalTracks,
|
|
Name: raw.Data.Name,
|
|
ReleaseDate: raw.Data.ReleaseDate,
|
|
Artists: joinArtists(raw.Data.Artists),
|
|
Images: albumImage,
|
|
ArtistID: artistID,
|
|
ArtistURL: artistURL,
|
|
}
|
|
if raw.BatchEnabled {
|
|
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
|
}
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items))
|
|
cache := make(map[string]string)
|
|
for _, item := range raw.Data.Tracks.Items {
|
|
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
|
|
tracks = append(tracks, AlbumTrackMetadata{
|
|
SpotifyID: item.ID,
|
|
Artists: joinArtists(item.Artists),
|
|
Name: item.Name,
|
|
AlbumName: raw.Data.Name,
|
|
AlbumArtist: joinArtists(raw.Data.Artists),
|
|
DurationMS: item.DurationMS,
|
|
Images: albumImage,
|
|
ReleaseDate: raw.Data.ReleaseDate,
|
|
TrackNumber: item.TrackNumber,
|
|
TotalTracks: raw.Data.TotalTracks,
|
|
DiscNumber: item.DiscNumber,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
ISRC: isrc,
|
|
})
|
|
}
|
|
|
|
return &AlbumResponsePayload{
|
|
AlbumInfo: info,
|
|
TrackList: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *discographyRaw) (*ArtistDiscographyPayload, error) {
|
|
artistImage := firstImageURL(raw.Artist.Images)
|
|
discType := raw.Discography
|
|
if discType == "" {
|
|
discType = "all"
|
|
}
|
|
|
|
info := ArtistInfoMetadata{
|
|
Name: raw.Artist.Name,
|
|
Followers: raw.Artist.Followers.Total,
|
|
Genres: raw.Artist.Genres,
|
|
Images: artistImage,
|
|
ExternalURL: raw.Artist.ExternalURL.Spotify,
|
|
DiscographyType: discType,
|
|
TotalAlbums: len(raw.Albums),
|
|
}
|
|
if raw.BatchEnabled {
|
|
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
|
}
|
|
|
|
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Albums))
|
|
allTracks := make([]AlbumTrackMetadata, 0)
|
|
isrcCache := make(map[string]string)
|
|
|
|
for _, alb := range raw.Albums {
|
|
albumImage := firstImageURL(alb.Images)
|
|
albumList = append(albumList, DiscographyAlbumMetadata{
|
|
ID: alb.ID,
|
|
Name: alb.Name,
|
|
AlbumType: alb.AlbumType,
|
|
ReleaseDate: alb.ReleaseDate,
|
|
TotalTracks: alb.TotalTracks,
|
|
Artists: joinArtists(alb.Artists),
|
|
Images: albumImage,
|
|
ExternalURL: alb.ExternalURL.Spotify,
|
|
})
|
|
|
|
tracks, err := c.collectAlbumTracks(ctx, alb.ID, raw.Token)
|
|
if err != nil {
|
|
fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
|
|
continue
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
|
|
var artistID, artistURL string
|
|
if len(tr.Artists) > 0 {
|
|
artistID = tr.Artists[0].ID
|
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", tr.Artists[0].ID)
|
|
}
|
|
artistsData := make([]ArtistSimple, 0, len(tr.Artists))
|
|
for _, a := range tr.Artists {
|
|
artistsData = append(artistsData, ArtistSimple{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
|
})
|
|
}
|
|
allTracks = append(allTracks, AlbumTrackMetadata{
|
|
SpotifyID: tr.ID,
|
|
Artists: joinArtists(tr.Artists),
|
|
Name: tr.Name,
|
|
AlbumName: alb.Name,
|
|
AlbumArtist: joinArtists(alb.Artists),
|
|
AlbumType: alb.AlbumType,
|
|
DurationMS: tr.DurationMS,
|
|
Images: albumImage,
|
|
ReleaseDate: alb.ReleaseDate,
|
|
TrackNumber: tr.TrackNumber,
|
|
TotalTracks: alb.TotalTracks,
|
|
DiscNumber: tr.DiscNumber,
|
|
ExternalURL: tr.ExternalURL.Spotify,
|
|
ISRC: isrc,
|
|
AlbumID: alb.ID,
|
|
AlbumURL: alb.ExternalURL.Spotify,
|
|
ArtistID: artistID,
|
|
ArtistURL: artistURL,
|
|
ArtistsData: artistsData,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &ArtistDiscographyPayload{
|
|
ArtistInfo: info,
|
|
AlbumList: albumList,
|
|
TrackList: allTracks,
|
|
}, nil
|
|
}
|
|
|
|
func formatArtistData(raw *artistResponse) ArtistResponsePayload {
|
|
if raw == nil {
|
|
return ArtistResponsePayload{}
|
|
}
|
|
payload := ArtistResponsePayload{}
|
|
payload.Artist.Name = raw.Name
|
|
payload.Artist.Followers = raw.Followers.Total
|
|
payload.Artist.Genres = raw.Genres
|
|
payload.Artist.Images = firstImageURL(raw.Images)
|
|
payload.Artist.ExternalURL = raw.ExternalURL.Spotify
|
|
payload.Artist.Popularity = raw.Popularity
|
|
return payload
|
|
}
|
|
|
|
func formatTrackData(raw *trackFull) TrackResponse {
|
|
if raw == nil {
|
|
return TrackResponse{}
|
|
}
|
|
return TrackResponse{
|
|
Track: TrackMetadata{
|
|
SpotifyID: raw.ID,
|
|
Artists: joinArtists(raw.Artists),
|
|
Name: raw.Name,
|
|
AlbumName: raw.Album.Name,
|
|
AlbumArtist: joinArtists(raw.Album.Artists),
|
|
DurationMS: raw.DurationMS,
|
|
Images: firstImageURL(raw.Album.Images),
|
|
ReleaseDate: raw.Album.ReleaseDate,
|
|
TrackNumber: raw.TrackNumber,
|
|
TotalTracks: raw.Album.TotalTracks,
|
|
DiscNumber: raw.DiscNumber,
|
|
ExternalURL: raw.ExternalURL.Spotify,
|
|
ISRC: raw.ExternalID.ISRC,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) collectAlbumTracks(ctx context.Context, albumID, token string) ([]trackSimplified, error) {
|
|
url := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID))
|
|
var tracks []trackSimplified
|
|
_, err := fetchPaging(ctx, c, url, token, 0, &tracks)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string, cache map[string]string) string {
|
|
if trackID == "" || token == "" {
|
|
return ""
|
|
}
|
|
if isrc, ok := cache[trackID]; ok {
|
|
return isrc
|
|
}
|
|
|
|
var data struct {
|
|
ExternalID externalID `json:"external_ids"`
|
|
}
|
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
|
return ""
|
|
}
|
|
cache[trackID] = data.ExternalID.ISRC
|
|
return cache[trackID]
|
|
}
|
|
|
|
func fetchPaging[T any](ctx context.Context, client *SpotifyMetadataClient, nextURL, token string, delay time.Duration, dest *[]T) (int, error) {
|
|
batches := 0
|
|
for nextURL != "" {
|
|
select {
|
|
case <-ctx.Done():
|
|
return batches, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
var page struct {
|
|
Items []T `json:"items"`
|
|
Next string `json:"next"`
|
|
}
|
|
if err := client.getJSON(ctx, nextURL, token, &page); err != nil {
|
|
return batches, err
|
|
}
|
|
|
|
*dest = append(*dest, page.Items...)
|
|
nextURL = stripLocaleParam(page.Next)
|
|
batches++
|
|
|
|
if nextURL != "" && delay > 0 {
|
|
if err := sleepWithContext(ctx, delay); err != nil {
|
|
return batches, err
|
|
}
|
|
}
|
|
}
|
|
return batches, nil
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error {
|
|
for {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
headers := c.baseHeaders()
|
|
for key, values := range headers {
|
|
for _, v := range values {
|
|
req.Header.Add(key, v)
|
|
}
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
if err := sleepWithContext(ctx, parseRetryAfter(resp.Header.Get("Retry-After"))); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("spotify API returned status %d for %s", resp.StatusCode, endpoint)
|
|
}
|
|
|
|
return json.Unmarshal(body, dst)
|
|
}
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) baseHeaders() http.Header {
|
|
h := http.Header{}
|
|
h.Set("User-Agent", c.userAgent)
|
|
h.Set("Accept", "application/json")
|
|
h.Set("Accept-Language", "en-US,en;q=0.9")
|
|
h.Set("sec-ch-ua-platform", "\"Windows\"")
|
|
h.Set("sec-fetch-dest", "empty")
|
|
h.Set("sec-fetch-mode", "cors")
|
|
h.Set("sec-fetch-site", "same-origin")
|
|
h.Set("Referer", "https://open.spotify.com/")
|
|
h.Set("Origin", "https://open.spotify.com")
|
|
return h
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|
c.rngMu.Lock()
|
|
defer c.rngMu.Unlock()
|
|
|
|
macMajor := c.randRange(11, 15)
|
|
macMinor := c.randRange(4, 9)
|
|
webkitMajor := c.randRange(530, 537)
|
|
webkitMinor := c.randRange(30, 37)
|
|
chromeMajor := c.randRange(80, 105)
|
|
chromeBuild := c.randRange(3000, 4500)
|
|
chromePatch := c.randRange(60, 125)
|
|
safariMajor := c.randRange(530, 537)
|
|
safariMinor := c.randRange(30, 36)
|
|
|
|
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 (c *SpotifyMetadataClient) randRange(min, max int) int {
|
|
if max <= min {
|
|
return min
|
|
}
|
|
return c.rng.Intn(max-min) + min
|
|
}
|
|
|
|
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
|
|
// Return cached token if still valid
|
|
if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
|
|
return c.cachedToken, nil
|
|
}
|
|
|
|
// Prepare request body for Client Credentials Flow
|
|
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
|
|
}
|
|
|
|
// Set Basic Auth header
|
|
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. Status code: %d, Response: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var token accessTokenResponse
|
|
if err := json.Unmarshal(body, &token); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if token.AccessToken == "" {
|
|
return "", errors.New("failed to get access token: empty token received")
|
|
}
|
|
|
|
// Cache the token
|
|
c.cachedToken = token.AccessToken
|
|
// Official API returns expires_in in seconds
|
|
if expiresIn, ok := token.ExpiresIn.(float64); ok {
|
|
c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry
|
|
}
|
|
|
|
return token.AccessToken, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if len(parts) >= 3 && parts[0] == "artist" {
|
|
if len(parts) >= 3 && parts[2] == "discography" {
|
|
discType := "all"
|
|
if len(parts) >= 4 {
|
|
candidate := parts[3]
|
|
if candidate == "all" || candidate == "album" || candidate == "single" || candidate == "compilation" {
|
|
discType = candidate
|
|
}
|
|
}
|
|
return spotifyURI{Type: "artist_discography", ID: parts[1], DiscographyGroup: discType}, nil
|
|
}
|
|
return spotifyURI{Type: "artist", ID: parts[1]}, 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 stripLocaleParam(raw string) string {
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(raw, "&locale="); idx != -1 {
|
|
return raw[:idx]
|
|
}
|
|
if idx := strings.Index(raw, "?locale="); idx != -1 {
|
|
return raw[:idx]
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func firstImageURL(images []image) string {
|
|
if len(images) == 0 {
|
|
return ""
|
|
}
|
|
return images[0].URL
|
|
}
|
|
|
|
func joinArtists(artists []artist) string {
|
|
if len(artists) == 0 {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(artists))
|
|
for _, a := range artists {
|
|
if a.Name != "" {
|
|
names = append(names, a.Name)
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseRetryAfter(value string) time.Duration {
|
|
if value == "" {
|
|
return 5 * time.Second
|
|
}
|
|
secs, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return 5 * time.Second
|
|
}
|
|
return time.Duration(secs+1) * time.Second
|
|
}
|
|
|
|
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
|
if d <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
|
|
// SearchResult represents a single search result item
|
|
type SearchResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // track, album, artist, playlist
|
|
Artists string `json:"artists,omitempty"`
|
|
AlbumName string `json:"album_name,omitempty"`
|
|
Images string `json:"images"`
|
|
ReleaseDate string `json:"release_date,omitempty"`
|
|
ExternalURL string `json:"external_urls"`
|
|
Duration int `json:"duration_ms,omitempty"`
|
|
TotalTracks int `json:"total_tracks,omitempty"`
|
|
Owner string `json:"owner,omitempty"` // for playlists
|
|
}
|
|
|
|
// SearchResponse contains search results grouped by type
|
|
type SearchResponse struct {
|
|
Tracks []SearchResult `json:"tracks"`
|
|
Albums []SearchResult `json:"albums"`
|
|
Artists []SearchResult `json:"artists"`
|
|
Playlists []SearchResult `json:"playlists"`
|
|
}
|
|
|
|
// Spotify API search response structures
|
|
type searchTracksResponse struct {
|
|
Tracks struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DurationMS int `json:"duration_ms"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Artists []artist `json:"artists"`
|
|
Album struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
ReleaseDate string `json:"release_date"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
} `json:"album"`
|
|
} `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
type searchAlbumsResponse struct {
|
|
Albums struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AlbumType string `json:"album_type"`
|
|
TotalTracks int `json:"total_tracks"`
|
|
ReleaseDate string `json:"release_date"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Artists []artist `json:"artists"`
|
|
} `json:"items"`
|
|
} `json:"albums"`
|
|
}
|
|
|
|
type searchArtistsResponse struct {
|
|
Artists struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Followers struct {
|
|
Total int `json:"total"`
|
|
} `json:"followers"`
|
|
} `json:"items"`
|
|
} `json:"artists"`
|
|
}
|
|
|
|
type searchPlaylistsResponse struct {
|
|
Playlists struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Images []image `json:"images"`
|
|
ExternalURL externalURL `json:"external_urls"`
|
|
Owner struct {
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"owner"`
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
} `json:"items"`
|
|
} `json:"playlists"`
|
|
}
|
|
|
|
// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists
|
|
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
|
if query == "" {
|
|
return nil, errors.New("search query cannot be empty")
|
|
}
|
|
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
}
|
|
|
|
// URL encode the query
|
|
encodedQuery := url.QueryEscape(query)
|
|
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit)
|
|
|
|
response := &SearchResponse{
|
|
Tracks: make([]SearchResult, 0),
|
|
Albums: make([]SearchResult, 0),
|
|
Artists: make([]SearchResult, 0),
|
|
Playlists: make([]SearchResult, 0),
|
|
}
|
|
|
|
// Fetch tracks
|
|
var tracksResp searchTracksResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil {
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
|
|
for _, item := range tracksResp.Tracks.Items {
|
|
response.Tracks = append(response.Tracks, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "track",
|
|
Artists: joinArtists(item.Artists),
|
|
AlbumName: item.Album.Name,
|
|
Images: firstImageURL(item.Album.Images),
|
|
ReleaseDate: item.Album.ReleaseDate,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
Duration: item.DurationMS,
|
|
})
|
|
}
|
|
|
|
// Fetch albums
|
|
var albumsResp searchAlbumsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil {
|
|
for _, item := range albumsResp.Albums.Items {
|
|
response.Albums = append(response.Albums, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "album",
|
|
Artists: joinArtists(item.Artists),
|
|
Images: firstImageURL(item.Images),
|
|
ReleaseDate: item.ReleaseDate,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
TotalTracks: item.TotalTracks,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fetch artists
|
|
var artistsResp searchArtistsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil {
|
|
for _, item := range artistsResp.Artists.Items {
|
|
response.Artists = append(response.Artists, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "artist",
|
|
Images: firstImageURL(item.Images),
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fetch playlists
|
|
var playlistsResp searchPlaylistsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil {
|
|
for _, item := range playlistsResp.Playlists.Items {
|
|
response.Playlists = append(response.Playlists, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "playlist",
|
|
Images: firstImageURL(item.Images),
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
Owner: item.Owner.DisplayName,
|
|
TotalTracks: item.Tracks.Total,
|
|
})
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// SearchSpotify is a convenience wrapper for the Search method
|
|
func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
|
client := NewSpotifyMetadataClient()
|
|
return client.Search(ctx, query, limit)
|
|
}
|
|
|
|
// SearchByType searches for a specific type (track, album, artist, playlist) with offset support
|
|
func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
|
|
if query == "" {
|
|
return nil, errors.New("search query cannot be empty")
|
|
}
|
|
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
if offset < 0 || offset > 1000 {
|
|
offset = 0
|
|
}
|
|
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
}
|
|
|
|
encodedQuery := url.QueryEscape(query)
|
|
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset)
|
|
|
|
results := make([]SearchResult, 0)
|
|
|
|
switch searchType {
|
|
case "track":
|
|
var resp searchTracksResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
for _, item := range resp.Tracks.Items {
|
|
results = append(results, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "track",
|
|
Artists: joinArtists(item.Artists),
|
|
AlbumName: item.Album.Name,
|
|
Images: firstImageURL(item.Album.Images),
|
|
ReleaseDate: item.Album.ReleaseDate,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
Duration: item.DurationMS,
|
|
})
|
|
}
|
|
case "album":
|
|
var resp searchAlbumsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
for _, item := range resp.Albums.Items {
|
|
results = append(results, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "album",
|
|
Artists: joinArtists(item.Artists),
|
|
Images: firstImageURL(item.Images),
|
|
ReleaseDate: item.ReleaseDate,
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
TotalTracks: item.TotalTracks,
|
|
})
|
|
}
|
|
case "artist":
|
|
var resp searchArtistsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
for _, item := range resp.Artists.Items {
|
|
results = append(results, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "artist",
|
|
Images: firstImageURL(item.Images),
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
})
|
|
}
|
|
case "playlist":
|
|
var resp searchPlaylistsResponse
|
|
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
for _, item := range resp.Playlists.Items {
|
|
results = append(results, SearchResult{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Type: "playlist",
|
|
Images: firstImageURL(item.Images),
|
|
ExternalURL: item.ExternalURL.Spotify,
|
|
Owner: item.Owner.DisplayName,
|
|
TotalTracks: item.Tracks.Total,
|
|
})
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("invalid search type: %s", searchType)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// SearchSpotifyByType is a convenience wrapper for SearchByType
|
|
func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
|
|
client := NewSpotifyMetadataClient()
|
|
return client.SearchByType(ctx, query, searchType, limit, offset)
|
|
}
|