mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
)
|
||||
|
||||
func mapSpotifyTrack(track spotify.Track, features spotify.AudioFeatures, mb musicbrainz.Recording, missingFeatures bool) recommendation.Track {
|
||||
artist := ""
|
||||
if len(track.Artists) > 0 {
|
||||
artist = track.Artists[0].Name
|
||||
}
|
||||
|
||||
spotifyURL := "https://open.spotify.com/track/" + track.ID
|
||||
external := map[string]string{
|
||||
"source": ProviderSpotify,
|
||||
"spotify_id": track.ID,
|
||||
"spotify": spotifyURL,
|
||||
"spotify_url": spotifyURL,
|
||||
}
|
||||
if value := strings.TrimSpace(track.ExternalURLs["spotify"]); value != "" {
|
||||
external["spotify"] = value
|
||||
external["spotify_url"] = value
|
||||
}
|
||||
if isrc := strings.ToUpper(strings.TrimSpace(track.ExternalIDs["isrc"])); isrc != "" {
|
||||
external["isrc"] = isrc
|
||||
}
|
||||
if len(track.Album.Images) > 0 && track.Album.Images[0].URL != "" {
|
||||
external["image_url"] = track.Album.Images[0].URL
|
||||
external["spotify_image_url"] = track.Album.Images[0].URL
|
||||
}
|
||||
if missingFeatures {
|
||||
external["features_missing"] = "true"
|
||||
}
|
||||
if mb.ID != "" {
|
||||
external["musicbrainz_recording_id"] = mb.ID
|
||||
}
|
||||
if mb.ArtistID != "" {
|
||||
external["musicbrainz_artist_id"] = mb.ArtistID
|
||||
}
|
||||
if mb.ISRC != "" && external["isrc"] == "" {
|
||||
external["isrc"] = mb.ISRC
|
||||
}
|
||||
|
||||
genres := mergeStrings(nil, mb.Genres...)
|
||||
genres = mergeStrings(genres, mb.Tags...)
|
||||
|
||||
return recommendation.Track{
|
||||
ID: "spotify:track:" + track.ID,
|
||||
Title: track.Name,
|
||||
Artist: artist,
|
||||
Album: track.Album.Name,
|
||||
Genres: genres,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
DurationMS: track.DurationMS,
|
||||
Popularity: clamp01(float64(track.Popularity) / 100),
|
||||
Explicit: track.Explicit,
|
||||
Features: recommendation.AudioFeatures{
|
||||
Danceability: features.Danceability,
|
||||
Energy: features.Energy,
|
||||
Loudness: features.Loudness,
|
||||
Speechiness: features.Speechiness,
|
||||
Acousticness: features.Acousticness,
|
||||
Instrumentalness: features.Instrumentalness,
|
||||
Liveness: features.Liveness,
|
||||
Valence: features.Valence,
|
||||
Tempo: features.Tempo,
|
||||
TimeSignature: features.TimeSignature,
|
||||
Key: features.Key,
|
||||
Mode: features.Mode,
|
||||
},
|
||||
External: external,
|
||||
DiscoveryAllowed: true,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeStrings(values []string, next ...string) []string {
|
||||
seen := make(map[string]struct{}, len(values)+len(next))
|
||||
out := make([]string, 0, len(values)+len(next))
|
||||
for _, value := range append(values, next...) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://musicbrainz.org/ws/2"
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Contact string
|
||||
Version string
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
MinDelay time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
appName string
|
||||
contact string
|
||||
version string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
minDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
lastCall time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
type Recording struct {
|
||||
ID string
|
||||
Title string
|
||||
Artist string
|
||||
ArtistID string
|
||||
ISRC string
|
||||
Genres []string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
minDelay := cfg.MinDelay
|
||||
if minDelay <= 0 {
|
||||
minDelay = time.Second
|
||||
}
|
||||
version := strings.TrimSpace(cfg.Version)
|
||||
if version == "" {
|
||||
version = "0.1.0"
|
||||
}
|
||||
return &Client{
|
||||
appName: strings.TrimSpace(cfg.AppName),
|
||||
contact: strings.TrimSpace(cfg.Contact),
|
||||
version: version,
|
||||
baseURL: baseURL,
|
||||
httpClient: httpClient,
|
||||
minDelay: minDelay,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c.appName != "" && c.contact != ""
|
||||
}
|
||||
|
||||
func (c *Client) LastError() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastError
|
||||
}
|
||||
|
||||
func (c *Client) LookupByISRC(ctx context.Context, isrc string) (Recording, []byte, error) {
|
||||
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if isrc == "" {
|
||||
return Recording{}, nil, errors.New("isrc is required")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("fmt", "json")
|
||||
params.Set("inc", "artist-credits+isrcs+tags")
|
||||
payload, err := c.get(ctx, "/isrc/"+url.PathEscape(isrc), params)
|
||||
if err != nil {
|
||||
return Recording{}, payload, err
|
||||
}
|
||||
recording, err := parseISRCRecording(payload, isrc)
|
||||
return recording, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) SearchRecording(ctx context.Context, title, artist string) (Recording, []byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
artist = strings.TrimSpace(artist)
|
||||
if title == "" {
|
||||
return Recording{}, nil, errors.New("title is required")
|
||||
}
|
||||
query := `recording:"` + escapeQuery(title) + `"`
|
||||
if artist != "" {
|
||||
query += ` AND artist:"` + escapeQuery(artist) + `"`
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("fmt", "json")
|
||||
params.Set("query", query)
|
||||
params.Set("limit", "1")
|
||||
payload, err := c.get(ctx, "/recording", params)
|
||||
if err != nil {
|
||||
return Recording{}, payload, err
|
||||
}
|
||||
recording, err := parseSearchRecording(payload)
|
||||
return recording, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
if !c.Configured() {
|
||||
err := errors.New("musicbrainz app name and contact are required")
|
||||
c.setLastError(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := c.baseURL + path
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.userAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return payload, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
err := fmt.Errorf("musicbrainz request failed with status %d", resp.StatusCode)
|
||||
c.setLastError(err.Error())
|
||||
return payload, err
|
||||
}
|
||||
c.setLastError("")
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *Client) wait(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
wait := c.minDelay - time.Since(c.lastCall)
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
c.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
c.mu.Lock()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
c.mu.Lock()
|
||||
}
|
||||
c.lastCall = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) userAgent() string {
|
||||
return fmt.Sprintf("%s/%s (%s)", c.appName, c.version, c.contact)
|
||||
}
|
||||
|
||||
func (c *Client) setLastError(message string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lastError = message
|
||||
}
|
||||
|
||||
func parseISRCRecording(payload []byte, isrc string) (Recording, error) {
|
||||
var decoded struct {
|
||||
Recordings []recordingJSON `json:"recordings"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return Recording{}, fmt.Errorf("decode musicbrainz isrc: %w", err)
|
||||
}
|
||||
if len(decoded.Recordings) == 0 {
|
||||
return Recording{}, errors.New("musicbrainz isrc lookup returned no recordings")
|
||||
}
|
||||
return decoded.Recordings[0].toRecording(isrc), nil
|
||||
}
|
||||
|
||||
func parseSearchRecording(payload []byte) (Recording, error) {
|
||||
var decoded struct {
|
||||
Recordings []recordingJSON `json:"recordings"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return Recording{}, fmt.Errorf("decode musicbrainz recording search: %w", err)
|
||||
}
|
||||
if len(decoded.Recordings) == 0 {
|
||||
return Recording{}, errors.New("musicbrainz recording search returned no matches")
|
||||
}
|
||||
return decoded.Recordings[0].toRecording(""), nil
|
||||
}
|
||||
|
||||
type recordingJSON struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ArtistCredit []struct {
|
||||
Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"artist-credit"`
|
||||
ISRCs []string `json:"isrcs"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
func (r recordingJSON) toRecording(fallbackISRC string) Recording {
|
||||
out := Recording{ID: r.ID, Title: r.Title, ISRC: fallbackISRC}
|
||||
if len(r.ArtistCredit) > 0 {
|
||||
out.Artist = r.ArtistCredit[0].Artist.Name
|
||||
out.ArtistID = r.ArtistCredit[0].Artist.ID
|
||||
}
|
||||
if out.ISRC == "" && len(r.ISRCs) > 0 {
|
||||
out.ISRC = strings.ToUpper(r.ISRCs[0])
|
||||
}
|
||||
for _, genre := range r.Genres {
|
||||
if genre.Name != "" {
|
||||
out.Genres = append(out.Genres, genre.Name)
|
||||
}
|
||||
}
|
||||
for _, tag := range r.Tags {
|
||||
if tag.Name != "" {
|
||||
out.Tags = append(out.Tags, tag.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeQuery(value string) string {
|
||||
return strings.ReplaceAll(value, `"`, `\"`)
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/urlparser"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
)
|
||||
|
||||
type ServiceConfig struct {
|
||||
DefaultMarket string
|
||||
CacheTTL time.Duration
|
||||
Version string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
spotify *spotify.Client
|
||||
webplayer *webplayer.Client
|
||||
songlink *songlink.Client
|
||||
urlparser *urlparser.Parser
|
||||
musicbrainz *musicbrainz.Client
|
||||
defaultMarket string
|
||||
cacheTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, spotifyClient *spotify.Client, webplayerClient *webplayer.Client, songlinkClient *songlink.Client, musicBrainzClient *musicbrainz.Client, cfg ServiceConfig) *Service {
|
||||
cacheTTL := cfg.CacheTTL
|
||||
if cacheTTL <= 0 {
|
||||
cacheTTL = 24 * time.Hour
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
spotify: spotifyClient,
|
||||
webplayer: webplayerClient,
|
||||
songlink: songlinkClient,
|
||||
urlparser: urlparser.NewParser(),
|
||||
musicbrainz: musicBrainzClient,
|
||||
defaultMarket: strings.ToUpper(strings.TrimSpace(cfg.DefaultMarket)),
|
||||
cacheTTL: cacheTTL,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ImportSpotify(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||
// Try official Spotify API first (more reliable, has audio features)
|
||||
if s.spotify != nil && s.spotify.Configured() {
|
||||
return s.importFromOfficialAPI(ctx, req)
|
||||
}
|
||||
|
||||
// Fall back to native webplayer client (auth-free, no API keys needed)
|
||||
if s.webplayer != nil && s.webplayer.Configured() {
|
||||
return s.importFromWebPlayer(ctx, req)
|
||||
}
|
||||
|
||||
return ImportResponse{}, spotify.ErrNotConfigured
|
||||
}
|
||||
|
||||
func (s *Service) importFromOfficialAPI(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||
persist := true
|
||||
if req.Persist != nil {
|
||||
persist = *req.Persist
|
||||
}
|
||||
limit := capLimit(req.Limit, 100)
|
||||
market := s.market(req.Market)
|
||||
parsed, sourceWarnings, err := s.resolveSpotifySource(ctx, req.Source)
|
||||
if err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
|
||||
job := ImportJob{
|
||||
ID: newID("import"),
|
||||
Provider: ProviderSpotify,
|
||||
SourceType: parsed.Type,
|
||||
SourceValue: parsed.ID,
|
||||
Market: market,
|
||||
Status: "running",
|
||||
StartedAt: s.now(),
|
||||
}
|
||||
if persist {
|
||||
if err := s.store.CreateImportJob(ctx, job); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tracks, skipped, warnings, err := s.importSpotifyTracks(ctx, parsed, market, limit, boolDefault(req.EnrichMusicBrainz, true), req.AllowMissingFields)
|
||||
warnings = append(sourceWarnings, warnings...)
|
||||
if err != nil {
|
||||
job.Status = "failed"
|
||||
job.Warnings = append(warnings, err.Error())
|
||||
job.FinishedAt = s.now()
|
||||
if persist {
|
||||
_ = s.store.FinishImportJob(ctx, job)
|
||||
}
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
|
||||
imported, updated := 0, 0
|
||||
if persist && len(tracks) > 0 {
|
||||
existingIDs := make([]string, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
existingIDs = append(existingIDs, track.ID)
|
||||
}
|
||||
existing, err := s.store.GetTracksByIDs(ctx, existingIDs)
|
||||
if err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, track := range existing {
|
||||
existingSet[track.ID] = struct{}{}
|
||||
}
|
||||
for _, track := range tracks {
|
||||
if _, ok := existingSet[track.ID]; ok {
|
||||
updated++
|
||||
} else {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
if err := s.store.UpsertTracks(ctx, tracks); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
if err := s.upsertTrackEnrichments(ctx, tracks); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
job.Status = "succeeded"
|
||||
job.ImportedTracks = imported
|
||||
job.UpdatedTracks = updated
|
||||
job.Skipped = skipped
|
||||
job.Warnings = warnings
|
||||
job.FinishedAt = s.now()
|
||||
if persist {
|
||||
if err := s.store.FinishImportJob(ctx, job); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return ImportResponse{
|
||||
ImportID: job.ID,
|
||||
ImportedTracks: imported,
|
||||
UpdatedTracks: updated,
|
||||
Skipped: skipped,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveSpotifySource(ctx context.Context, source Source) (spotify.ParsedSource, []string, error) {
|
||||
_ = ctx
|
||||
parsed, err := spotify.ParseSource(source.Type, source.Value)
|
||||
if err == nil {
|
||||
return parsed, nil, nil
|
||||
}
|
||||
|
||||
if strings.ToLower(strings.TrimSpace(source.Type)) != "url" {
|
||||
return spotify.ParsedSource{}, nil, err
|
||||
}
|
||||
parsedURL := s.urlparser.ParseURL(source.Value)
|
||||
if parsedURL == nil || parsedURL.Service == urlparser.Spotify {
|
||||
return spotify.ParsedSource{}, nil, err
|
||||
}
|
||||
if s.songlink == nil || !s.songlink.Configured() {
|
||||
return spotify.ParsedSource{}, nil, err
|
||||
}
|
||||
|
||||
links, linkErr := s.songlink.GetLinks(parsedURL.URL)
|
||||
if linkErr != nil {
|
||||
return spotify.ParsedSource{}, nil, fmt.Errorf("could not resolve %s URL to Spotify: %w", parsedURL.Service, linkErr)
|
||||
}
|
||||
if strings.TrimSpace(links.SpotifyID) == "" {
|
||||
return spotify.ParsedSource{}, nil, fmt.Errorf("could not resolve %s URL to a Spotify track", parsedURL.Service)
|
||||
}
|
||||
|
||||
spotifyID := strings.TrimSpace(links.SpotifyID)
|
||||
return spotify.ParsedSource{
|
||||
Type: "track",
|
||||
ID: spotifyID,
|
||||
URL: "https://open.spotify.com/track/" + spotifyID,
|
||||
}, []string{"resolved " + string(parsedURL.Service) + " URL to Spotify via Song.link"}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchSpotify(ctx context.Context, req SearchRequest) (SearchResponse, error) {
|
||||
if s.spotify == nil || !s.spotify.Configured() {
|
||||
// Try webplayer search if available (auth-free)
|
||||
if s.webplayer != nil && s.webplayer.Configured() {
|
||||
return s.searchViaWebPlayer(ctx, req)
|
||||
}
|
||||
return SearchResponse{}, spotify.ErrNotConfigured
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if itemType == "" {
|
||||
itemType = "track"
|
||||
}
|
||||
if !validSearchType(itemType) {
|
||||
return SearchResponse{}, errors.New("search type must be track, album, artist, or playlist")
|
||||
}
|
||||
limit := capSearchLimit(req.Limit)
|
||||
market := s.market(req.Market)
|
||||
result, _, warnings, err := s.spotifySearch(ctx, req.Query, itemType, market, limit)
|
||||
if err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
ids, idWarnings := s.trackIDsFromSearch(ctx, result, itemType, market, limit)
|
||||
warnings = append(warnings, idWarnings...)
|
||||
tracks := make([]recommendation.Track, 0, len(ids))
|
||||
skipped := 0
|
||||
for _, id := range ids {
|
||||
track, trackWarnings, ok := s.buildTrack(ctx, id, market, boolDefault(req.EnrichMusicBrainz, true), req.AllowMissingFields)
|
||||
warnings = append(warnings, trackWarnings...)
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
persisted := 0
|
||||
if req.Persist && len(tracks) > 0 {
|
||||
if err := s.store.UpsertTracks(ctx, tracks); err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
if err := s.upsertTrackEnrichments(ctx, tracks); err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
persisted = len(tracks)
|
||||
}
|
||||
return SearchResponse{Tracks: tracks, Persisted: persisted, Skipped: skipped, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (s *Service) trackIDsFromSearch(ctx context.Context, result spotify.SearchResult, itemType, market string, limit int) ([]string, []string) {
|
||||
var warnings []string
|
||||
ids := make([]string, 0, limit)
|
||||
addID := func(id string) {
|
||||
if id == "" || len(ids) >= limit {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
switch itemType {
|
||||
case "track":
|
||||
for _, item := range result.Tracks.Items {
|
||||
addID(item.ID)
|
||||
}
|
||||
case "album":
|
||||
for _, album := range result.Albums.Items {
|
||||
refs, _, cacheWarnings, err := s.spotifyAlbumTracks(ctx, album.ID, market, limit-len(ids))
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("spotify album %s skipped: %v", album.ID, err))
|
||||
continue
|
||||
}
|
||||
for _, ref := range refs {
|
||||
addID(ref.ID)
|
||||
}
|
||||
}
|
||||
case "artist":
|
||||
for _, artist := range result.Artists.Items {
|
||||
items, _, cacheWarnings, err := s.spotifyArtistTopTracks(ctx, artist.ID, market)
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("spotify artist %s skipped: %v", artist.ID, err))
|
||||
continue
|
||||
}
|
||||
for _, item := range items {
|
||||
addID(item.ID)
|
||||
}
|
||||
}
|
||||
case "playlist":
|
||||
for _, playlist := range result.Playlists.Items {
|
||||
refs, _, cacheWarnings, err := s.spotifyPlaylistTracks(ctx, playlist.ID, market, limit-len(ids))
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("spotify playlist %s skipped: %v", playlist.ID, err))
|
||||
continue
|
||||
}
|
||||
for _, ref := range refs {
|
||||
addID(ref.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, warnings
|
||||
}
|
||||
|
||||
func (s *Service) EnrichMusicBrainz(ctx context.Context, req EnrichRequest) (EnrichResponse, error) {
|
||||
if s.musicbrainz == nil || !s.musicbrainz.Configured() {
|
||||
return EnrichResponse{}, errors.New("musicbrainz app name and contact are required")
|
||||
}
|
||||
tracks, err := s.store.GetTracksByIDs(ctx, req.TrackIDs)
|
||||
if err != nil {
|
||||
return EnrichResponse{}, err
|
||||
}
|
||||
byID := make(map[string]recommendation.Track, len(tracks))
|
||||
for _, track := range tracks {
|
||||
byID[track.ID] = track
|
||||
}
|
||||
var warnings []string
|
||||
updated, skipped := 0, 0
|
||||
for _, id := range req.TrackIDs {
|
||||
track, ok := byID[id]
|
||||
if !ok {
|
||||
skipped++
|
||||
warnings = append(warnings, "track not found: "+id)
|
||||
continue
|
||||
}
|
||||
if !req.Force && track.External["musicbrainz_recording_id"] != "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
mb, raw, warn, ok := s.enrichTrack(ctx, track)
|
||||
if warn != "" {
|
||||
warnings = append(warnings, warn)
|
||||
}
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if track.External == nil {
|
||||
track.External = map[string]string{}
|
||||
}
|
||||
track.External["musicbrainz_recording_id"] = mb.ID
|
||||
if mb.ArtistID != "" {
|
||||
track.External["musicbrainz_artist_id"] = mb.ArtistID
|
||||
}
|
||||
if mb.ISRC != "" && track.External["isrc"] == "" {
|
||||
track.External["isrc"] = mb.ISRC
|
||||
}
|
||||
track.Genres = mergeStrings(track.Genres, mb.Genres...)
|
||||
track.Genres = mergeStrings(track.Genres, mb.Tags...)
|
||||
if err := s.store.UpsertTrack(ctx, track); err != nil {
|
||||
return EnrichResponse{}, err
|
||||
}
|
||||
if err := s.store.UpsertTrackEnrichment(ctx, TrackEnrichment{
|
||||
TrackID: track.ID,
|
||||
Provider: ProviderMusicBrainz,
|
||||
MusicBrainzRecordingID: mb.ID,
|
||||
MusicBrainzArtistID: mb.ArtistID,
|
||||
ISRC: mb.ISRC,
|
||||
Payload: raw,
|
||||
UpdatedAt: s.now(),
|
||||
}); err != nil {
|
||||
return EnrichResponse{}, err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return EnrichResponse{Updated: updated, Skipped: skipped, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Status(ctx context.Context) StatusResponse {
|
||||
stats, _ := s.store.ProviderCacheStats(ctx)
|
||||
now := s.now()
|
||||
spotifyStatus := ProviderStatus{CheckedAt: now}
|
||||
if s.spotify != nil {
|
||||
spotifyStatus.Configured = s.spotify.Configured()
|
||||
spotifyStatus.TokenMode = s.spotify.TokenMode()
|
||||
spotifyStatus.Available = s.spotify.Configured() && s.spotify.LastError() == ""
|
||||
spotifyStatus.LastError = s.spotify.LastError()
|
||||
}
|
||||
mbStatus := ProviderStatus{CheckedAt: now}
|
||||
if s.musicbrainz != nil {
|
||||
mbStatus.Configured = s.musicbrainz.Configured()
|
||||
mbStatus.TokenMode = "user_agent"
|
||||
mbStatus.Available = s.musicbrainz.Configured() && s.musicbrainz.LastError() == ""
|
||||
mbStatus.LastError = s.musicbrainz.LastError()
|
||||
}
|
||||
return StatusResponse{Spotify: spotifyStatus, MusicBrainz: mbStatus, Cache: stats}
|
||||
}
|
||||
|
||||
func (s *Service) importSpotifyTracks(ctx context.Context, parsed spotify.ParsedSource, market string, limit int, enrichMB, allowMissing bool) ([]recommendation.Track, int, []string, error) {
|
||||
ids := []string{parsed.ID}
|
||||
var warnings []string
|
||||
switch parsed.Type {
|
||||
case "track":
|
||||
case "album":
|
||||
refs, _, cacheWarnings, err := s.spotifyAlbumTracks(ctx, parsed.ID, market, limit)
|
||||
if err != nil {
|
||||
return nil, 0, warnings, err
|
||||
}
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
ids = ids[:0]
|
||||
for _, ref := range refs {
|
||||
if ref.ID != "" {
|
||||
ids = append(ids, ref.ID)
|
||||
}
|
||||
}
|
||||
case "playlist":
|
||||
refs, _, cacheWarnings, err := s.spotifyPlaylistTracks(ctx, parsed.ID, market, limit)
|
||||
if err != nil {
|
||||
return nil, 0, warnings, err
|
||||
}
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
ids = ids[:0]
|
||||
for _, ref := range refs {
|
||||
if ref.ID != "" {
|
||||
ids = append(ids, ref.ID)
|
||||
}
|
||||
}
|
||||
case "artist":
|
||||
items, _, cacheWarnings, err := s.spotifyArtistTopTracks(ctx, parsed.ID, market)
|
||||
if err != nil {
|
||||
return nil, 0, warnings, err
|
||||
}
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
ids = ids[:0]
|
||||
for _, item := range items {
|
||||
if item.ID != "" {
|
||||
ids = append(ids, item.ID)
|
||||
if limit > 0 && len(ids) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, 0, warnings, errors.New("unsupported Spotify source type")
|
||||
}
|
||||
|
||||
tracks := make([]recommendation.Track, 0, len(ids))
|
||||
skipped := 0
|
||||
for _, id := range ids {
|
||||
track, trackWarnings, ok := s.buildTrack(ctx, id, market, enrichMB, allowMissing)
|
||||
warnings = append(warnings, trackWarnings...)
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
return tracks, skipped, warnings, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildTrack(ctx context.Context, id, market string, enrichMB, allowMissing bool) (recommendation.Track, []string, bool) {
|
||||
var warnings []string
|
||||
item, _, cacheWarnings, err := s.spotifyTrack(ctx, id, market)
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("spotify track %s skipped: %v", id, err))
|
||||
return recommendation.Track{}, warnings, false
|
||||
}
|
||||
features, _, cacheWarnings, err := s.spotifyAudioFeatures(ctx, id)
|
||||
warnings = append(warnings, cacheWarnings...)
|
||||
missingFeatures := false
|
||||
if err != nil {
|
||||
if !allowMissing {
|
||||
warnings = append(warnings, fmt.Sprintf("spotify track %s skipped: audio features unavailable", id))
|
||||
return recommendation.Track{}, warnings, false
|
||||
}
|
||||
missingFeatures = true
|
||||
warnings = append(warnings, fmt.Sprintf("spotify track %s imported without audio features", id))
|
||||
}
|
||||
var mb musicbrainz.Recording
|
||||
if enrichMB {
|
||||
recording, _, warn, ok := s.enrichSpotifyTrack(ctx, item)
|
||||
if warn != "" {
|
||||
warnings = append(warnings, warn)
|
||||
}
|
||||
if ok {
|
||||
mb = recording
|
||||
}
|
||||
}
|
||||
return mapSpotifyTrack(item, features, mb, missingFeatures), warnings, true
|
||||
}
|
||||
|
||||
func (s *Service) upsertTrackEnrichments(ctx context.Context, tracks []recommendation.Track) error {
|
||||
for _, track := range tracks {
|
||||
if track.External["musicbrainz_recording_id"] == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpsertTrackEnrichment(ctx, TrackEnrichment{
|
||||
TrackID: track.ID,
|
||||
Provider: ProviderMusicBrainz,
|
||||
MusicBrainzRecordingID: track.External["musicbrainz_recording_id"],
|
||||
MusicBrainzArtistID: track.External["musicbrainz_artist_id"],
|
||||
ISRC: track.External["isrc"],
|
||||
UpdatedAt: s.now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) enrichSpotifyTrack(ctx context.Context, track spotify.Track) (musicbrainz.Recording, []byte, string, bool) {
|
||||
if s.musicbrainz == nil || !s.musicbrainz.Configured() {
|
||||
return musicbrainz.Recording{}, nil, "", false
|
||||
}
|
||||
if isrc := strings.ToUpper(strings.TrimSpace(track.ExternalIDs["isrc"])); isrc != "" {
|
||||
mb, raw, warnings, err := s.musicBrainzISRC(ctx, isrc)
|
||||
if err == nil {
|
||||
return mb, raw, "", true
|
||||
}
|
||||
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz isrc lookup failed for "+isrc), false
|
||||
}
|
||||
artist := ""
|
||||
if len(track.Artists) > 0 {
|
||||
artist = track.Artists[0].Name
|
||||
}
|
||||
mb, raw, warnings, err := s.musicBrainzSearch(ctx, track.Name, artist)
|
||||
if err != nil {
|
||||
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz search failed for "+track.Name), false
|
||||
}
|
||||
return mb, raw, "", true
|
||||
}
|
||||
|
||||
func (s *Service) enrichTrack(ctx context.Context, track recommendation.Track) (musicbrainz.Recording, []byte, string, bool) {
|
||||
if isrc := strings.TrimSpace(track.External["isrc"]); isrc != "" {
|
||||
mb, raw, warnings, err := s.musicBrainzISRC(ctx, isrc)
|
||||
if err == nil {
|
||||
return mb, raw, "", true
|
||||
}
|
||||
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz isrc lookup failed for "+isrc), false
|
||||
}
|
||||
mb, raw, warnings, err := s.musicBrainzSearch(ctx, track.Title, track.Artist)
|
||||
if err != nil {
|
||||
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz search failed for "+track.ID), false
|
||||
}
|
||||
return mb, raw, "", true
|
||||
}
|
||||
|
||||
func (s *Service) spotifyTrack(ctx context.Context, id, market string) (spotify.Track, []byte, []string, error) {
|
||||
var out spotify.Track
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "track", id, market, func(context.Context) ([]byte, error) {
|
||||
_, raw, err := s.spotify.GetTrack(ctx, id, market)
|
||||
return raw, err
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) spotifyAudioFeatures(ctx context.Context, id string) (spotify.AudioFeatures, []byte, []string, error) {
|
||||
var out spotify.AudioFeatures
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "audio_features", id, "", func(context.Context) ([]byte, error) {
|
||||
_, raw, err := s.spotify.GetAudioFeatures(ctx, id)
|
||||
return raw, err
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) spotifySearch(ctx context.Context, query, itemType, market string, limit int) (spotify.SearchResult, []byte, []string, error) {
|
||||
var out spotify.SearchResult
|
||||
itemID := itemType + ":" + query + ":" + fmt.Sprint(limit)
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "search", itemID, market, func(context.Context) ([]byte, error) {
|
||||
_, raw, err := s.spotify.Search(ctx, query, itemType, market, limit)
|
||||
return raw, err
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) spotifyAlbumTracks(ctx context.Context, id, market string, limit int) ([]spotify.TrackRef, []byte, []string, error) {
|
||||
var out []spotify.TrackRef
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "album_tracks", id+":"+fmt.Sprint(limit), market, func(context.Context) ([]byte, error) {
|
||||
refs, _, err := s.spotify.GetAlbumTracks(ctx, id, market, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(refs)
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) spotifyPlaylistTracks(ctx context.Context, id, market string, limit int) ([]spotify.TrackRef, []byte, []string, error) {
|
||||
var out []spotify.TrackRef
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "playlist_tracks", id+":"+fmt.Sprint(limit), market, func(context.Context) ([]byte, error) {
|
||||
refs, _, err := s.spotify.GetPlaylistTracks(ctx, id, market, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(refs)
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) spotifyArtistTopTracks(ctx context.Context, id, market string) ([]spotify.Track, []byte, []string, error) {
|
||||
var out []spotify.Track
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "artist_top_tracks", id, market, func(context.Context) ([]byte, error) {
|
||||
tracks, _, err := s.spotify.GetArtistTopTracks(ctx, id, market)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(tracks)
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) musicBrainzISRC(ctx context.Context, isrc string) (musicbrainz.Recording, []byte, []string, error) {
|
||||
var out musicbrainz.Recording
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderMusicBrainz, "isrc", isrc, "", func(context.Context) ([]byte, error) {
|
||||
recording, raw, err := s.musicbrainz.LookupByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return json.Marshal(recording)
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) musicBrainzSearch(ctx context.Context, title, artist string) (musicbrainz.Recording, []byte, []string, error) {
|
||||
var out musicbrainz.Recording
|
||||
itemID := title + ":" + artist
|
||||
payload, warnings, err := s.cachedJSON(ctx, ProviderMusicBrainz, "recording_search", itemID, "", func(context.Context) ([]byte, error) {
|
||||
recording, raw, err := s.musicbrainz.SearchRecording(ctx, title, artist)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return json.Marshal(recording)
|
||||
}, &out)
|
||||
return out, payload, warnings, err
|
||||
}
|
||||
|
||||
func (s *Service) cachedJSON(ctx context.Context, providerName, itemType, itemID, market string, fetch func(context.Context) ([]byte, error), out any) ([]byte, []string, error) {
|
||||
var warnings []string
|
||||
now := s.now()
|
||||
cached, ok, err := s.store.GetProviderCache(ctx, providerName, itemType, itemID, market)
|
||||
if err != nil {
|
||||
return nil, warnings, err
|
||||
}
|
||||
if ok && cached.Fresh(now) {
|
||||
if err := json.Unmarshal(cached.Payload, out); err != nil {
|
||||
return cached.Payload, warnings, err
|
||||
}
|
||||
return cached.Payload, warnings, nil
|
||||
}
|
||||
payload, err := fetch(ctx)
|
||||
if err != nil {
|
||||
if ok && len(cached.Payload) > 0 {
|
||||
warnings = append(warnings, fmt.Sprintf("using stale %s %s cache after provider error", providerName, itemType))
|
||||
if decodeErr := json.Unmarshal(cached.Payload, out); decodeErr != nil {
|
||||
return cached.Payload, warnings, decodeErr
|
||||
}
|
||||
return cached.Payload, warnings, nil
|
||||
}
|
||||
_ = s.store.UpsertProviderCache(ctx, CacheEntry{
|
||||
Provider: providerName,
|
||||
ItemType: itemType,
|
||||
ItemID: itemID,
|
||||
Market: market,
|
||||
FetchedAt: now,
|
||||
ExpiresAt: now,
|
||||
LastError: err.Error(),
|
||||
})
|
||||
return payload, warnings, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, out); err != nil {
|
||||
return payload, warnings, err
|
||||
}
|
||||
if err := s.store.UpsertProviderCache(ctx, CacheEntry{
|
||||
Provider: providerName,
|
||||
ItemType: itemType,
|
||||
ItemID: itemID,
|
||||
Market: market,
|
||||
Payload: payload,
|
||||
FetchedAt: now,
|
||||
ExpiresAt: now.Add(s.cacheTTL),
|
||||
}); err != nil {
|
||||
return payload, warnings, err
|
||||
}
|
||||
return payload, warnings, nil
|
||||
}
|
||||
|
||||
func (s *Service) market(value string) string {
|
||||
if value = strings.ToUpper(strings.TrimSpace(value)); value != "" {
|
||||
return value
|
||||
}
|
||||
return s.defaultMarket
|
||||
}
|
||||
|
||||
func capSearchLimit(value int) int {
|
||||
if value <= 0 {
|
||||
return 5
|
||||
}
|
||||
if value > 10 {
|
||||
return 10
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validSearchType(value string) bool {
|
||||
switch value {
|
||||
case "track", "album", "artist", "playlist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func capLimit(value, maxValue int) int {
|
||||
if value <= 0 {
|
||||
return maxValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return prefix + "_" + strings.ReplaceAll(time.Now().UTC().Format(time.RFC3339Nano), ":", "")
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func appendWarning(warnings []string, fallback string) string {
|
||||
if len(warnings) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return warnings[0]
|
||||
}
|
||||
|
||||
// importFromWebPlayer imports tracks using the native auth-free webplayer client
|
||||
func (s *Service) importFromWebPlayer(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||
persist := true
|
||||
if req.Persist != nil {
|
||||
persist = *req.Persist
|
||||
}
|
||||
|
||||
// Parse the URL to get the Spotify track ID
|
||||
itemType, itemID, err := webplayer.ParseSpotifyURL(req.Source.Value)
|
||||
if err != nil {
|
||||
parsedURL := s.urlparser.ParseURL(req.Source.Value)
|
||||
if parsedURL == nil || parsedURL.Service == urlparser.Spotify || s.songlink == nil || !s.songlink.Configured() {
|
||||
return ImportResponse{}, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||
}
|
||||
links, linkErr := s.songlink.GetLinks(parsedURL.URL)
|
||||
if linkErr != nil {
|
||||
return ImportResponse{}, fmt.Errorf("could not resolve %s URL to Spotify: %w", parsedURL.Service, linkErr)
|
||||
}
|
||||
if strings.TrimSpace(links.SpotifyID) == "" {
|
||||
return ImportResponse{}, fmt.Errorf("could not resolve %s URL to a Spotify track", parsedURL.Service)
|
||||
}
|
||||
itemType = "track"
|
||||
itemID = strings.TrimSpace(links.SpotifyID)
|
||||
}
|
||||
|
||||
if itemType != "track" {
|
||||
return ImportResponse{}, fmt.Errorf("unsupported item type: %s (only tracks supported for web player import)", itemType)
|
||||
}
|
||||
|
||||
job := ImportJob{
|
||||
ID: newID("import"),
|
||||
Provider: ProviderSpotify,
|
||||
SourceType: itemType,
|
||||
SourceValue: itemID,
|
||||
Status: "running",
|
||||
StartedAt: s.now(),
|
||||
}
|
||||
if persist {
|
||||
if err := s.store.CreateImportJob(ctx, job); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch track from web player (auth-free using TOTP)
|
||||
wpTrack, err := s.webplayer.GetTrack(itemID)
|
||||
if err != nil {
|
||||
job.Status = "failed"
|
||||
job.Warnings = []string{err.Error()}
|
||||
job.FinishedAt = s.now()
|
||||
if persist {
|
||||
_ = s.store.FinishImportJob(ctx, job)
|
||||
}
|
||||
return ImportResponse{}, fmt.Errorf("web player fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert artist list to string
|
||||
artistName := ""
|
||||
if len(wpTrack.Artists) > 0 {
|
||||
artistNames := make([]string, len(wpTrack.Artists))
|
||||
for i, a := range wpTrack.Artists {
|
||||
artistNames[i] = a.Name
|
||||
}
|
||||
artistName = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
// Build external URLs
|
||||
externalURLs := map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", wpTrack.ID),
|
||||
}
|
||||
|
||||
// Get cross-platform links from Song.link
|
||||
if s.songlink != nil && s.songlink.Configured() {
|
||||
if links, err := s.songlink.GetLinksFromSpotifyID(wpTrack.ID); err == nil && links != nil {
|
||||
for platform, link := range links.Links {
|
||||
externalURLs[platform] = link.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to recommendation.Track
|
||||
track := recommendation.Track{
|
||||
ID: wpTrack.ID,
|
||||
Title: wpTrack.Name,
|
||||
Artist: artistName,
|
||||
Album: wpTrack.Album.Name,
|
||||
DurationMS: wpTrack.DurationMs,
|
||||
Explicit: wpTrack.Explicit,
|
||||
Popularity: 0.5, // Web player doesn't provide popularity
|
||||
External: externalURLs,
|
||||
CreatedAt: s.now(),
|
||||
UpdatedAt: s.now(),
|
||||
}
|
||||
|
||||
// Add image URL if available
|
||||
if len(wpTrack.Album.Images) > 0 {
|
||||
track.External["image_url"] = wpTrack.Album.Images[0].URL
|
||||
}
|
||||
|
||||
// Optionally enrich with MusicBrainz
|
||||
if boolDefault(req.EnrichMusicBrainz, true) && s.musicbrainz != nil {
|
||||
mb, _, _, ok := s.enrichTrack(ctx, track)
|
||||
if ok && mb.ID != "" {
|
||||
track.External["musicbrainz_recording_id"] = mb.ID
|
||||
if mb.ISRC != "" {
|
||||
track.External["isrc"] = mb.ISRC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the track
|
||||
imported, updated := 0, 0
|
||||
if persist {
|
||||
existing, _ := s.store.GetTracksByIDs(ctx, []string{track.ID})
|
||||
if len(existing) > 0 {
|
||||
updated = 1
|
||||
} else {
|
||||
imported = 1
|
||||
}
|
||||
if err := s.store.UpsertTrack(ctx, track); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
if err := s.upsertTrackEnrichments(ctx, []recommendation.Track{track}); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
job.Status = "succeeded"
|
||||
job.ImportedTracks = imported
|
||||
job.UpdatedTracks = updated
|
||||
job.FinishedAt = s.now()
|
||||
if persist {
|
||||
if err := s.store.FinishImportJob(ctx, job); err != nil {
|
||||
return ImportResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return ImportResponse{
|
||||
ImportID: job.ID,
|
||||
ImportedTracks: imported,
|
||||
UpdatedTracks: updated,
|
||||
Skipped: 0,
|
||||
Warnings: []string{"imported via webplayer (auth-free, native Go)"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// searchViaWebPlayer searches using the native webplayer client
|
||||
func (s *Service) searchViaWebPlayer(ctx context.Context, req SearchRequest) (SearchResponse, error) {
|
||||
// Use the webplayer's search capability
|
||||
wpTracks, err := s.webplayer.Search(req.Query, req.Limit)
|
||||
if err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
|
||||
var tracks []recommendation.Track
|
||||
for _, wpTrack := range wpTracks {
|
||||
artistName := ""
|
||||
if len(wpTrack.Artists) > 0 {
|
||||
artistNames := make([]string, len(wpTrack.Artists))
|
||||
for i, a := range wpTrack.Artists {
|
||||
artistNames[i] = a.Name
|
||||
}
|
||||
artistName = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
track := recommendation.Track{
|
||||
ID: wpTrack.ID,
|
||||
Title: wpTrack.Name,
|
||||
Artist: artistName,
|
||||
Album: wpTrack.Album.Name,
|
||||
DurationMS: wpTrack.DurationMs,
|
||||
Explicit: wpTrack.Explicit,
|
||||
Popularity: 0.5,
|
||||
External: map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", wpTrack.ID),
|
||||
},
|
||||
}
|
||||
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
|
||||
return SearchResponse{
|
||||
Tracks: tracks,
|
||||
Persisted: 0,
|
||||
Skipped: 0,
|
||||
Warnings: []string{"search results from webplayer (auth-free)"},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||
)
|
||||
|
||||
func TestImportSpotifyTrackPersistsRecommendableTrack(t *testing.T) {
|
||||
store := memory.New()
|
||||
spotifyServer := fakeSpotifyServer(t)
|
||||
defer spotifyServer.Close()
|
||||
mbServer := fakeMusicBrainzServer(t)
|
||||
defer mbServer.Close()
|
||||
|
||||
service := provider.NewService(store,
|
||||
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||
webplayer.NewClient(),
|
||||
songlink.NewClient(),
|
||||
musicbrainz.New(musicbrainz.Config{AppName: "SpotifyRecAlg", Contact: "test@example.com", BaseURL: mbServer.URL + "/ws/2", MinDelay: time.Nanosecond}),
|
||||
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||
)
|
||||
|
||||
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
||||
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/good"},
|
||||
Market: "US",
|
||||
EnrichMusicBrainz: boolPtr(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("import spotify: %v", err)
|
||||
}
|
||||
if resp.ImportedTracks != 1 || resp.Skipped != 0 {
|
||||
t.Fatalf("unexpected import response: %+v", resp)
|
||||
}
|
||||
|
||||
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
||||
ContentWeight: 0.5,
|
||||
PopularityWeight: 0.2,
|
||||
ExplorationWeight: 0.3,
|
||||
DiversityLambda: 0.7,
|
||||
})
|
||||
recs, _, err := engine.Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("recommend after import: %v", err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].Track.ID != "spotify:track:good" {
|
||||
t.Fatalf("unexpected recommendations: %+v", recs)
|
||||
}
|
||||
if got := recs[0].Track.External["musicbrainz_recording_id"]; got != "mb-recording" {
|
||||
t.Fatalf("musicbrainz recording id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestSearchSpotifyCapsLimitAndPersistFalse(t *testing.T) {
|
||||
store := memory.New()
|
||||
spotifyServer := fakeSpotifyServer(t)
|
||||
defer spotifyServer.Close()
|
||||
|
||||
service := provider.NewService(store,
|
||||
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||
webplayer.NewClient(),
|
||||
songlink.NewClient(),
|
||||
nil,
|
||||
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||
)
|
||||
|
||||
resp, err := service.SearchSpotify(context.Background(), provider.SearchRequest{Query: "hello", Type: "track", Limit: 50, Persist: false})
|
||||
if err != nil {
|
||||
t.Fatalf("search spotify: %v", err)
|
||||
}
|
||||
if len(resp.Tracks) != 1 || resp.Persisted != 0 {
|
||||
t.Fatalf("unexpected search response: %+v", resp)
|
||||
}
|
||||
if _, _, err := recommendation.NewEngine(recommendation.EngineConfig{}).Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1}); err == nil {
|
||||
t.Fatal("expected empty catalog because persist=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCacheUsesStaleOnError(t *testing.T) {
|
||||
store := memory.New()
|
||||
spotifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "upstream down", http.StatusInternalServerError)
|
||||
}))
|
||||
defer spotifyServer.Close()
|
||||
service := provider.NewService(store,
|
||||
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1", MaxRetries: 1}),
|
||||
webplayer.NewClient(),
|
||||
songlink.NewClient(),
|
||||
nil,
|
||||
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||
)
|
||||
|
||||
now := time.Now().UTC()
|
||||
trackPayload := []byte(`{"id":"cached","name":"Cached","artists":[{"name":"Artist"}],"album":{"name":"Album"},"popularity":50}`)
|
||||
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
||||
Provider: provider.ProviderSpotify,
|
||||
ItemType: "track",
|
||||
ItemID: "cached",
|
||||
Market: "US",
|
||||
Payload: trackPayload,
|
||||
FetchedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: now.Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert track cache: %v", err)
|
||||
}
|
||||
featuresPayload := []byte(`{"danceability":0.5,"energy":0.6,"loudness":-7,"speechiness":0.03,"acousticness":0.2,"instrumentalness":0,"liveness":0.1,"valence":0.4,"tempo":100,"time_signature":4,"key":1,"mode":1}`)
|
||||
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
||||
Provider: provider.ProviderSpotify,
|
||||
ItemType: "audio_features",
|
||||
ItemID: "cached",
|
||||
Payload: featuresPayload,
|
||||
FetchedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: now.Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert features cache: %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
||||
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/cached"},
|
||||
Market: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("import with stale cache: %v", err)
|
||||
}
|
||||
if resp.ImportedTracks != 1 || len(resp.Warnings) == 0 {
|
||||
t.Fatalf("expected stale fallback import with warning, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeSpotifyServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/search":
|
||||
if got := r.URL.Query().Get("limit"); got != "10" {
|
||||
t.Fatalf("search limit = %q, want 10", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tracks": map[string]any{"items": []map[string]any{{"id": "good"}}},
|
||||
})
|
||||
case "/v1/tracks/good":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "good",
|
||||
"name": "Good Song",
|
||||
"artists": []map[string]any{{"id": "spotify-artist", "name": "Good Artist"}},
|
||||
"album": map[string]any{"id": "album", "name": "Good Album", "release_date": "2024-01-01", "images": []map[string]any{{"url": "https://img.example/good.jpg"}}},
|
||||
"duration_ms": 210000,
|
||||
"popularity": 80,
|
||||
"explicit": false,
|
||||
"external_ids": map[string]string{"isrc": "USRC17607839"},
|
||||
"external_urls": map[string]string{
|
||||
"spotify": "https://open.spotify.com/track/good",
|
||||
},
|
||||
})
|
||||
case "/v1/audio-features/good":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"danceability": 0.7,
|
||||
"energy": 0.8,
|
||||
"loudness": -5.0,
|
||||
"speechiness": 0.04,
|
||||
"acousticness": 0.1,
|
||||
"instrumentalness": 0.0,
|
||||
"liveness": 0.12,
|
||||
"valence": 0.6,
|
||||
"tempo": 120,
|
||||
"time_signature": 4,
|
||||
"key": 2,
|
||||
"mode": 1,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func fakeMusicBrainzServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("User-Agent"); got == "" {
|
||||
t.Fatal("missing User-Agent")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/ws/2/isrc/USRC17607839":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"recordings": []map[string]any{{
|
||||
"id": "mb-recording",
|
||||
"title": "Good Song",
|
||||
"artist-credit": []map[string]any{{
|
||||
"artist": map[string]string{"id": "mb-artist", "name": "Good Artist"},
|
||||
}},
|
||||
"isrcs": []string{"USRC17607839"},
|
||||
"tags": []map[string]string{{"name": "indie"}},
|
||||
}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAccountsBaseURL = "https://accounts.spotify.com"
|
||||
defaultAPIBaseURL = "https://api.spotify.com/v1"
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var ErrNotConfigured = errors.New("spotify credentials are not configured")
|
||||
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
BearerToken string
|
||||
Market string
|
||||
AccountsBaseURL string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
staticToken string
|
||||
defaultMarket string
|
||||
accountsBaseURL string
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
accountsBaseURL := strings.TrimRight(cfg.AccountsBaseURL, "/")
|
||||
if accountsBaseURL == "" {
|
||||
accountsBaseURL = defaultAccountsBaseURL
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(cfg.APIBaseURL, "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 2
|
||||
}
|
||||
return &Client{
|
||||
clientID: strings.TrimSpace(cfg.ClientID),
|
||||
clientSecret: strings.TrimSpace(cfg.ClientSecret),
|
||||
staticToken: strings.TrimSpace(cfg.BearerToken),
|
||||
defaultMarket: strings.ToUpper(strings.TrimSpace(cfg.Market)),
|
||||
accountsBaseURL: accountsBaseURL,
|
||||
apiBaseURL: apiBaseURL,
|
||||
httpClient: httpClient,
|
||||
timeout: timeout,
|
||||
maxRetries: maxRetries,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c.staticToken != "" || (c.clientID != "" && c.clientSecret != "")
|
||||
}
|
||||
|
||||
func (c *Client) TokenMode() string {
|
||||
if c.staticToken != "" {
|
||||
return "static_bearer"
|
||||
}
|
||||
if c.clientID != "" && c.clientSecret != "" {
|
||||
return "client_credentials"
|
||||
}
|
||||
return "unconfigured"
|
||||
}
|
||||
|
||||
func (c *Client) LastError() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastError
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(ctx context.Context, id, market string) (Track, []byte, error) {
|
||||
var out Track
|
||||
payload, err := c.get(ctx, "/tracks/"+url.PathEscape(id), marketParams(marketOrDefault(market, c.defaultMarket)), &out)
|
||||
return out, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) GetAudioFeatures(ctx context.Context, id string) (AudioFeatures, []byte, error) {
|
||||
var out AudioFeatures
|
||||
payload, err := c.get(ctx, "/audio-features/"+url.PathEscape(id), nil, &out)
|
||||
return out, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, query, itemType, market string, limit int) (SearchResult, []byte, error) {
|
||||
itemType = strings.ToLower(strings.TrimSpace(itemType))
|
||||
if itemType == "" {
|
||||
itemType = "track"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > 10 {
|
||||
limit = 10
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("type", itemType)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
if market = marketOrDefault(market, c.defaultMarket); market != "" {
|
||||
params.Set("market", market)
|
||||
}
|
||||
var out SearchResult
|
||||
payload, err := c.get(ctx, "/search", params, &out)
|
||||
return out, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) GetAlbumTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
|
||||
return c.getPagedTrackRefs(ctx, "/albums/"+url.PathEscape(id)+"/tracks", "items", market, limit)
|
||||
}
|
||||
|
||||
func (c *Client) GetPlaylistTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
|
||||
limit = normalizeCollectionLimit(limit)
|
||||
refs := make([]TrackRef, 0, limit)
|
||||
var lastPayload []byte
|
||||
for offset := 0; len(refs) < limit; offset += 50 {
|
||||
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("fields", "items(track(id,is_local,type)),next")
|
||||
payload, err := c.getRaw(ctx, "/playlists/"+url.PathEscape(id)+"/tracks", params)
|
||||
if err != nil {
|
||||
return nil, payload, err
|
||||
}
|
||||
lastPayload = payload
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Track TrackRef `json:"track"`
|
||||
} `json:"items"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &page); err != nil {
|
||||
return nil, payload, fmt.Errorf("decode playlist tracks: %w", err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.Track.ID != "" && !item.Track.IsLocal {
|
||||
refs = append(refs, item.Track)
|
||||
if len(refs) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if page.Next == "" || len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return refs, lastPayload, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetArtistTopTracks(ctx context.Context, id, market string) ([]Track, []byte, error) {
|
||||
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||
if params.Get("market") == "" {
|
||||
params.Set("market", "US")
|
||||
}
|
||||
var out struct {
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
payload, err := c.get(ctx, "/artists/"+url.PathEscape(id)+"/top-tracks", params, &out)
|
||||
return out.Tracks, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) getPagedTrackRefs(ctx context.Context, path, listField, market string, limit int) ([]TrackRef, []byte, error) {
|
||||
limit = normalizeCollectionLimit(limit)
|
||||
refs := make([]TrackRef, 0, limit)
|
||||
var lastPayload []byte
|
||||
for offset := 0; len(refs) < limit; offset += 50 {
|
||||
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
payload, err := c.getRaw(ctx, path, params)
|
||||
if err != nil {
|
||||
return nil, payload, err
|
||||
}
|
||||
lastPayload = payload
|
||||
var page struct {
|
||||
Items []TrackRef `json:"items"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &page); err != nil {
|
||||
return nil, payload, fmt.Errorf("decode %s: %w", listField, err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.ID != "" {
|
||||
refs = append(refs, item)
|
||||
if len(refs) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if page.Next == "" || len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return refs, lastPayload, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) ([]byte, error) {
|
||||
payload, err := c.getRaw(ctx, path, params)
|
||||
if err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, out); err != nil {
|
||||
return payload, fmt.Errorf("decode spotify response: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *Client) getRaw(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
endpoint := c.apiBaseURL + path
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodGet, endpoint, nil, true)
|
||||
}
|
||||
|
||||
func (c *Client) accessToken(ctx context.Context) (string, error) {
|
||||
if c.staticToken != "" {
|
||||
return c.staticToken, nil
|
||||
}
|
||||
if c.clientID == "" || c.clientSecret == "" {
|
||||
c.setLastError(ErrNotConfigured.Error())
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.token != "" && time.Now().Add(60*time.Second).Before(c.expiresAt) {
|
||||
token := c.token
|
||||
c.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
body := strings.NewReader("grant_type=client_credentials")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.accountsBaseURL+"/api/token", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
credential := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+credential)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
err := fmt.Errorf("spotify token request failed with status %d", resp.StatusCode)
|
||||
c.setLastError(err.Error())
|
||||
return "", err
|
||||
}
|
||||
var decoded struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return "", fmt.Errorf("decode spotify token: %w", err)
|
||||
}
|
||||
if decoded.AccessToken == "" {
|
||||
err := errors.New("spotify token response did not include an access token")
|
||||
c.setLastError(err.Error())
|
||||
return "", err
|
||||
}
|
||||
expiresIn := time.Duration(decoded.ExpiresIn) * time.Second
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = time.Hour
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token = decoded.AccessToken
|
||||
c.expiresAt = time.Now().Add(expiresIn)
|
||||
c.lastError = ""
|
||||
c.mu.Unlock()
|
||||
return decoded.AccessToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, endpoint string, body []byte, authenticate bool) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if len(body) > 0 {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if authenticate {
|
||||
token, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
closeErr := resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return payload, readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return payload, closeErr
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
|
||||
c.setLastError("")
|
||||
return payload, nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
c.mu.Lock()
|
||||
c.token = ""
|
||||
c.expiresAt = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
lastErr = spotifyHTTPError{StatusCode: resp.StatusCode, Body: string(payload)}
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
wait := retryAfter(resp.Header.Get("Retry-After"))
|
||||
if wait > 0 && attempt < c.maxRetries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return payload, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.StatusCode < 500 && resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("spotify request failed")
|
||||
}
|
||||
c.setLastError(lastErr.Error())
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) setLastError(message string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lastError = message
|
||||
}
|
||||
|
||||
type spotifyHTTPError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e spotifyHTTPError) Error() string {
|
||||
if e.Body == "" {
|
||||
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
var httpErr spotifyHTTPError
|
||||
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
func retryAfter(value string) time.Duration {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
if when, err := http.ParseTime(value); err == nil {
|
||||
return time.Until(when)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func marketParams(market string) url.Values {
|
||||
params := url.Values{}
|
||||
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
|
||||
params.Set("market", market)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func marketOrDefault(market, fallback string) string {
|
||||
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
|
||||
return market
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(fallback))
|
||||
}
|
||||
|
||||
func normalizeCollectionLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 100
|
||||
}
|
||||
if limit > 100 {
|
||||
return 100
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientCredentialsTokenIsCached(t *testing.T) {
|
||||
var tokenRequests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/token":
|
||||
tokenRequests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Basic ") {
|
||||
t.Fatalf("missing basic authorization header")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "token-a", "expires_in": 3600, "token_type": "Bearer"})
|
||||
case "/v1/tracks/abc":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer token-a" {
|
||||
t.Fatalf("got authorization %q", got)
|
||||
}
|
||||
writeTrack(w, "abc")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AccountsBaseURL: server.URL,
|
||||
APIBaseURL: server.URL + "/v1",
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||
t.Fatalf("get track %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := tokenRequests.Load(); got != 1 {
|
||||
t.Fatalf("token requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetriesRateLimitedRequest(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/tracks/abc" {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
writeTrack(w, "abc")
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{BearerToken: "token", APIBaseURL: server.URL + "/v1", MaxRetries: 1})
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||
t.Fatalf("get track after retry: %v", err)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportsMalformedJSONAndContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(Config{BearerToken: "token", APIBaseURL: server.URL, MaxRetries: 0})
|
||||
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, _, err := client.GetTrack(ctx, "abc", "US"); err == nil {
|
||||
t.Fatal("expected context cancellation error")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTrack(w http.ResponseWriter, id string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Track{
|
||||
ID: id,
|
||||
Name: "Track",
|
||||
Artists: []Artist{{Name: "Artist"}},
|
||||
Album: Album{Name: "Album", ReleaseDate: "2024-01-01"},
|
||||
DurationMS: int((3 * time.Minute).Milliseconds()),
|
||||
Popularity: 77,
|
||||
ExternalIDs: map[string]string{
|
||||
"isrc": "USRC17607839",
|
||||
},
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": "https://open.spotify.com/track/" + id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package spotify
|
||||
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Album Album `json:"album"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Popularity int `json:"popularity"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalIDs map[string]string `json:"external_ids"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
Type string `json:"type"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
}
|
||||
|
||||
type TrackRef struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Genres []string `json:"genres"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Images []Image `json:"images"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
||||
type AudioFeatures struct {
|
||||
Danceability float64 `json:"danceability"`
|
||||
Energy float64 `json:"energy"`
|
||||
Loudness float64 `json:"loudness"`
|
||||
Speechiness float64 `json:"speechiness"`
|
||||
Acousticness float64 `json:"acousticness"`
|
||||
Instrumentalness float64 `json:"instrumentalness"`
|
||||
Liveness float64 `json:"liveness"`
|
||||
Valence float64 `json:"valence"`
|
||||
Tempo float64 `json:"tempo"`
|
||||
TimeSignature float64 `json:"time_signature"`
|
||||
Key float64 `json:"key"`
|
||||
Mode float64 `json:"mode"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Tracks struct {
|
||||
Items []Track `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Albums struct {
|
||||
Items []Album `json:"items"`
|
||||
} `json:"albums"`
|
||||
Artists struct {
|
||||
Items []Artist `json:"items"`
|
||||
} `json:"artists"`
|
||||
Playlists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
} `json:"playlists"`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
uriPattern = regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||
pathIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
|
||||
)
|
||||
|
||||
type ParsedSource struct {
|
||||
Type string
|
||||
ID string
|
||||
URL string
|
||||
}
|
||||
|
||||
func ParseSource(sourceType, value string) (ParsedSource, error) {
|
||||
sourceType = strings.ToLower(strings.TrimSpace(sourceType))
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ParsedSource{}, errors.New("source value is required")
|
||||
}
|
||||
|
||||
if sourceType == "" || sourceType == "url" {
|
||||
parsed, err := ParseURL(value)
|
||||
if err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
if sourceType == "url" {
|
||||
return ParsedSource{}, err
|
||||
}
|
||||
}
|
||||
if sourceType == "" {
|
||||
sourceType = "track"
|
||||
}
|
||||
if !validSpotifyType(sourceType) {
|
||||
return ParsedSource{}, errors.New("source type must be track, album, playlist, artist, or url")
|
||||
}
|
||||
if parsed, err := ParseURL(value); err == nil {
|
||||
if parsed.Type != sourceType {
|
||||
return ParsedSource{}, errors.New("source URL type does not match requested type")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
if !idPattern.MatchString(value) {
|
||||
return ParsedSource{}, errors.New("source value must be a Spotify ID, URI, or open.spotify.com URL")
|
||||
}
|
||||
return ParsedSource{Type: sourceType, ID: value, URL: "https://open.spotify.com/" + sourceType + "/" + value}, nil
|
||||
}
|
||||
|
||||
func ParseURL(raw string) (ParsedSource, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ParsedSource{}, errors.New("url is required")
|
||||
}
|
||||
|
||||
if match := uriPattern.FindStringSubmatch(raw); len(match) == 3 {
|
||||
return ParsedSource{Type: strings.ToLower(match[1]), ID: match[2], URL: "https://open.spotify.com/" + strings.ToLower(match[1]) + "/" + match[2]}, nil
|
||||
}
|
||||
|
||||
parsedURL, err := parseURLWithDefaultScheme(raw)
|
||||
if err == nil {
|
||||
if value := parsedURL.Query().Get("uri"); value != "" {
|
||||
return ParseURL(value)
|
||||
}
|
||||
|
||||
host := spotifyHost(parsedURL.Host)
|
||||
switch host {
|
||||
case "open.spotify.com", "play.spotify.com":
|
||||
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||
return parsed, nil
|
||||
}
|
||||
case "embed.spotify.com":
|
||||
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedSource{}, errors.New("unsupported Spotify URL")
|
||||
}
|
||||
|
||||
func parseURLWithDefaultScheme(raw string) (*url.URL, error) {
|
||||
if strings.Contains(raw, "://") {
|
||||
return url.Parse(raw)
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "open.spotify.com/") ||
|
||||
strings.HasPrefix(lower, "play.spotify.com/") ||
|
||||
strings.HasPrefix(lower, "embed.spotify.com/") {
|
||||
return url.Parse("https://" + raw)
|
||||
}
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
func spotifyHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
host = strings.TrimPrefix(host, "www.")
|
||||
return host
|
||||
}
|
||||
|
||||
func parseSpotifyPath(path string) (ParsedSource, bool) {
|
||||
parts := pathSegments(path)
|
||||
if len(parts) == 0 {
|
||||
return ParsedSource{}, false
|
||||
}
|
||||
if 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") && pathIDPattern.MatchString(parts[3]) {
|
||||
return ParsedSource{Type: "playlist", ID: parts[3]}, true
|
||||
}
|
||||
itemType := strings.ToLower(parts[0])
|
||||
if len(parts) >= 2 && validSpotifyType(itemType) && pathIDPattern.MatchString(parts[1]) {
|
||||
return ParsedSource{Type: itemType, ID: parts[1]}, true
|
||||
}
|
||||
return ParsedSource{}, false
|
||||
}
|
||||
|
||||
func pathSegments(path string) []string {
|
||||
rawParts := strings.Split(path, "/")
|
||||
parts := make([]string, 0, len(rawParts))
|
||||
for _, part := range rawParts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func canonicalURL(itemType, id string) string {
|
||||
return "https://open.spotify.com/" + itemType + "/" + id
|
||||
}
|
||||
|
||||
func validSpotifyType(value string) bool {
|
||||
switch value {
|
||||
case "track", "album", "playlist", "artist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package spotify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType string
|
||||
value string
|
||||
wantType string
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "track URL", sourceType: "url", value: "https://open.spotify.com/track/abc123XYZ?si=ignored", wantType: "track", wantID: "abc123XYZ"},
|
||||
{name: "intl track URL", sourceType: "url", value: "https://open.spotify.com/intl-cs/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "embed URI URL", sourceType: "url", value: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "album URI", sourceType: "url", value: "spotify:album:album123456", wantType: "album", wantID: "album123456"},
|
||||
{name: "album URL with inferred type", sourceType: "", value: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||
{name: "playlist URL", sourceType: "playlist", value: "https://open.spotify.com/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||
{name: "legacy user playlist URL", sourceType: "url", value: "https://open.spotify.com/user/someone/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||
{name: "artist ID", sourceType: "artist", value: "artist123456", wantType: "artist", wantID: "artist123456"},
|
||||
{name: "invalid URL", sourceType: "url", value: "https://example.com/track/abc", wantErr: true},
|
||||
{name: "type mismatch", sourceType: "track", value: "https://open.spotify.com/album/abc123456", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseSource(tt.sourceType, tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse source: %v", err)
|
||||
}
|
||||
if got.Type != tt.wantType || got.ID != tt.wantID {
|
||||
t.Fatalf("got type=%q id=%q, want type=%q id=%q", got.Type, got.ID, tt.wantType, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderSpotify = "spotify"
|
||||
ProviderMusicBrainz = "musicbrainz"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
}
|
||||
|
||||
type ImportRequest struct {
|
||||
Source Source `json:"source" binding:"required"`
|
||||
Market string `json:"market,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
EnrichMusicBrainz *bool `json:"enrich_musicbrainz,omitempty"`
|
||||
Persist *bool `json:"persist,omitempty"`
|
||||
AllowMissingFields bool `json:"allow_missing_features,omitempty"`
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Market string `json:"market,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Persist bool `json:"persist"`
|
||||
EnrichMusicBrainz *bool `json:"enrich_musicbrainz,omitempty"`
|
||||
AllowMissingFields bool `json:"allow_missing_features,omitempty"`
|
||||
}
|
||||
|
||||
type EnrichRequest struct {
|
||||
TrackIDs []string `json:"track_ids" binding:"required"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
ImportID string `json:"import_id"`
|
||||
ImportedTracks int `json:"imported_tracks"`
|
||||
UpdatedTracks int `json:"updated_tracks"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Tracks []recommendation.Track `json:"tracks"`
|
||||
Persisted int `json:"persisted"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
type EnrichResponse struct {
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Spotify ProviderStatus `json:"spotify"`
|
||||
MusicBrainz ProviderStatus `json:"musicbrainz"`
|
||||
Cache CacheStats `json:"cache"`
|
||||
}
|
||||
|
||||
type ProviderStatus struct {
|
||||
Configured bool `json:"configured"`
|
||||
TokenMode string `json:"token_mode,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
type CacheEntry struct {
|
||||
Provider string
|
||||
ItemType string
|
||||
ItemID string
|
||||
Market string
|
||||
Payload []byte
|
||||
FetchedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
LastError string
|
||||
}
|
||||
|
||||
func (e CacheEntry) Fresh(now time.Time) bool {
|
||||
return len(e.Payload) > 0 && now.Before(e.ExpiresAt)
|
||||
}
|
||||
|
||||
type CacheStats struct {
|
||||
Entries int64 `json:"entries"`
|
||||
FreshEntries int64 `json:"fresh_entries"`
|
||||
StaleEntries int64 `json:"stale_entries"`
|
||||
}
|
||||
|
||||
type ImportJob struct {
|
||||
ID string
|
||||
Provider string
|
||||
SourceType string
|
||||
SourceValue string
|
||||
Market string
|
||||
Status string
|
||||
ImportedTracks int
|
||||
UpdatedTracks int
|
||||
Skipped int
|
||||
Warnings []string
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type TrackEnrichment struct {
|
||||
TrackID string
|
||||
Provider string
|
||||
MusicBrainzRecordingID string
|
||||
MusicBrainzArtistID string
|
||||
ISRC string
|
||||
Payload []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
UpsertTrack(ctx context.Context, track recommendation.Track) error
|
||||
UpsertTracks(ctx context.Context, tracks []recommendation.Track) error
|
||||
GetTracksByIDs(ctx context.Context, ids []string) ([]recommendation.Track, error)
|
||||
GetProviderCache(ctx context.Context, providerName, itemType, itemID, market string) (CacheEntry, bool, error)
|
||||
UpsertProviderCache(ctx context.Context, entry CacheEntry) error
|
||||
ProviderCacheStats(ctx context.Context) (CacheStats, error)
|
||||
CreateImportJob(ctx context.Context, job ImportJob) error
|
||||
FinishImportJob(ctx context.Context, job ImportJob) error
|
||||
UpsertTrackEnrichment(ctx context.Context, enrichment TrackEnrichment) error
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package unlocker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("unlocker service not configured")
|
||||
ErrTrackNotFound = errors.New("track not found")
|
||||
)
|
||||
|
||||
// Client for the Python unlocker service (auth-free Spotify access)
|
||||
type Client struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
if baseURL == "" {
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c != nil && c.baseURL != ""
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Artists []string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
}
|
||||
|
||||
type ImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
Track *TrackResponse `json:"track"`
|
||||
Links map[string]string `json:"links"`
|
||||
Parsed *ParsedInfo `json:"parsed,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedInfo struct {
|
||||
Service string `json:"service"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type LinksResponse struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
ISRC string `json:"isrc"`
|
||||
Links map[string]LinkDetails `json:"links"`
|
||||
}
|
||||
|
||||
type LinkDetails struct {
|
||||
URL string `json:"url"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// ImportFromURL imports a track from any streaming service URL (auth-free)
|
||||
func (c *Client) ImportFromURL(ctx context.Context, url string) (*ImportResponse, error) {
|
||||
if !c.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(ImportRequest{URL: url})
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/import", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ImportResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrack gets a track by Spotify ID (auth-free)
|
||||
func (c *Client) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||
if !c.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/spotify/track/"+trackID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrTrackNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result TrackResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetLinks gets cross-platform links for a Spotify track
|
||||
func (c *Client) GetLinks(ctx context.Context, spotifyID string) (*LinksResponse, error) {
|
||||
if !c.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/links/"+spotifyID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result LinksResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package urlparser provides universal music URL parsing for multiple streaming services.
|
||||
package urlparser
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Service represents a music streaming service
|
||||
type Service string
|
||||
|
||||
const (
|
||||
Spotify Service = "spotify"
|
||||
Tidal Service = "tidal"
|
||||
AppleMusic Service = "apple_music"
|
||||
YouTube Service = "youtube"
|
||||
YouTubeMusic Service = "youtube_music"
|
||||
SoundCloud Service = "soundcloud"
|
||||
Deezer Service = "deezer"
|
||||
Bandcamp Service = "bandcamp"
|
||||
MusicBrainz Service = "musicbrainz"
|
||||
)
|
||||
|
||||
// ParsedURL represents a parsed music service URL
|
||||
type ParsedURL struct {
|
||||
Service Service
|
||||
URL string
|
||||
ItemType string
|
||||
ID string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Parser for music service URLs
|
||||
type Parser struct {
|
||||
patterns map[Service][]*regexp.Regexp
|
||||
services []Service
|
||||
}
|
||||
|
||||
// NewParser creates a new URL parser
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
services: []Service{
|
||||
Spotify,
|
||||
Tidal,
|
||||
AppleMusic,
|
||||
YouTubeMusic,
|
||||
YouTube,
|
||||
SoundCloud,
|
||||
Deezer,
|
||||
Bandcamp,
|
||||
MusicBrainz,
|
||||
},
|
||||
patterns: map[Service][]*regexp.Regexp{
|
||||
Spotify: {
|
||||
regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([a-zA-Z0-9]+)$`),
|
||||
regexp.MustCompile(`(?i)https?://open\.spotify\.com/(?:intl-[a-z]{2}/)?(?:embed/)?(track|album|playlist|artist)/([a-zA-Z0-9]+)`),
|
||||
regexp.MustCompile(`(?i)https://spotify\.link/([a-zA-Z0-9]+)`),
|
||||
},
|
||||
Tidal: {
|
||||
regexp.MustCompile(`(?i)https://tidal\.com/(?:browse/)?(track|album|playlist|artist)/(\d+)`),
|
||||
regexp.MustCompile(`(?i)https://listen\.tidal\.com/(?:browse/)?(track|album|playlist|artist)/(\d+)`),
|
||||
},
|
||||
AppleMusic: {
|
||||
regexp.MustCompile(`(?i)https://music\.apple\.com/([a-z]{2})/(song|album|playlist|artist)/(?:[^/]+/)?(\d+)`),
|
||||
},
|
||||
YouTubeMusic: {
|
||||
regexp.MustCompile(`(?i)https://music\.youtube\.com/(watch|playlist|channel)\?([^#]+)`),
|
||||
},
|
||||
YouTube: {
|
||||
regexp.MustCompile(`(?i)https://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]+)`),
|
||||
regexp.MustCompile(`(?i)https://youtu\.be/([a-zA-Z0-9_-]+)`),
|
||||
regexp.MustCompile(`(?i)https://(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)`),
|
||||
},
|
||||
SoundCloud: {
|
||||
regexp.MustCompile(`(?i)https://soundcloud\.com/([^/]+)/sets/([^/?#]+)`),
|
||||
regexp.MustCompile(`(?i)https://soundcloud\.com/([^/]+)/([^/]+)`),
|
||||
},
|
||||
Deezer: {
|
||||
regexp.MustCompile(`(?i)https://www\.deezer\.com/(?:[a-z]{2}/)?(track|album|playlist|artist)/(\d+)`),
|
||||
},
|
||||
Bandcamp: {
|
||||
regexp.MustCompile(`(?i)https://([a-zA-Z0-9-]+)\.bandcamp\.com/(track|album)/(.+)`),
|
||||
},
|
||||
MusicBrainz: {
|
||||
regexp.MustCompile(`(?i)https://musicbrainz\.org/(recording|release|release-group|artist)/([a-f0-9-]+)`),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseURL parses a music service URL and extracts service, type, and ID
|
||||
func (p *Parser) ParseURL(url string) *ParsedURL {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, service := range p.services {
|
||||
patterns := p.patterns[service]
|
||||
for _, pattern := range patterns {
|
||||
matches := pattern.FindStringSubmatch(url)
|
||||
if matches != nil {
|
||||
return p.extractServiceInfo(service, matches, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) extractServiceInfo(service Service, matches []string, url string) *ParsedURL {
|
||||
switch service {
|
||||
case Spotify:
|
||||
if len(matches) >= 3 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: matches[1],
|
||||
ID: matches[2],
|
||||
}
|
||||
}
|
||||
if len(matches) == 2 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: "short",
|
||||
ID: matches[1],
|
||||
}
|
||||
}
|
||||
|
||||
case Tidal:
|
||||
if len(matches) >= 3 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: matches[1],
|
||||
ID: matches[2],
|
||||
}
|
||||
}
|
||||
|
||||
case AppleMusic:
|
||||
if len(matches) >= 4 {
|
||||
itemType := matches[2]
|
||||
id := matches[3]
|
||||
if parsed, err := neturl.Parse(url); err == nil && itemType == "album" {
|
||||
if trackID := parsed.Query().Get("i"); trackID != "" {
|
||||
itemType = "song"
|
||||
id = trackID
|
||||
}
|
||||
}
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: itemType,
|
||||
ID: id,
|
||||
Metadata: map[string]string{
|
||||
"region": matches[1],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case YouTube, YouTubeMusic:
|
||||
if parsed, err := neturl.Parse(url); err == nil {
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return &ParsedURL{Service: service, URL: url, ItemType: "video", ID: v}
|
||||
}
|
||||
if list := parsed.Query().Get("list"); list != "" {
|
||||
return &ParsedURL{Service: service, URL: url, ItemType: "playlist", ID: list}
|
||||
}
|
||||
}
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: "video",
|
||||
ID: matches[1],
|
||||
}
|
||||
|
||||
case SoundCloud:
|
||||
if len(matches) >= 3 {
|
||||
itemType := "track"
|
||||
if strings.EqualFold(matches[1], "sets") || strings.Contains(strings.ToLower(url), "/sets/") {
|
||||
itemType = "playlist"
|
||||
}
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: itemType,
|
||||
ID: matches[1] + "/" + matches[2],
|
||||
}
|
||||
}
|
||||
|
||||
case Deezer:
|
||||
if len(matches) >= 3 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: matches[1],
|
||||
ID: matches[2],
|
||||
}
|
||||
}
|
||||
|
||||
case Bandcamp:
|
||||
if len(matches) >= 4 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: matches[2],
|
||||
ID: matches[1] + "/" + matches[3],
|
||||
}
|
||||
}
|
||||
|
||||
case MusicBrainz:
|
||||
if len(matches) >= 3 {
|
||||
return &ParsedURL{
|
||||
Service: service,
|
||||
URL: url,
|
||||
ItemType: matches[1],
|
||||
ID: matches[2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServiceFromURL quickly identifies the service from a URL without full parsing
|
||||
func (p *Parser) GetServiceFromURL(url string) Service {
|
||||
urlLower := strings.ToLower(url)
|
||||
|
||||
if strings.Contains(urlLower, "spotify.com") || strings.Contains(urlLower, "spotify.link") {
|
||||
return Spotify
|
||||
}
|
||||
if strings.Contains(urlLower, "tidal.com") || strings.Contains(urlLower, "listen.tidal.com") {
|
||||
return Tidal
|
||||
}
|
||||
if strings.Contains(urlLower, "music.apple.com") {
|
||||
return AppleMusic
|
||||
}
|
||||
if strings.Contains(urlLower, "music.youtube.com") {
|
||||
return YouTubeMusic
|
||||
}
|
||||
if strings.Contains(urlLower, "youtube.com") || strings.Contains(urlLower, "youtu.be") {
|
||||
return YouTube
|
||||
}
|
||||
if strings.Contains(urlLower, "soundcloud.com") {
|
||||
return SoundCloud
|
||||
}
|
||||
if strings.Contains(urlLower, "deezer.com") {
|
||||
return Deezer
|
||||
}
|
||||
if strings.Contains(urlLower, "bandcamp.com") {
|
||||
return Bandcamp
|
||||
}
|
||||
if strings.Contains(urlLower, "musicbrainz.org") {
|
||||
return MusicBrainz
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidateURL checks if a URL is from a supported service
|
||||
func (p *Parser) ValidateURL(url string) bool {
|
||||
return p.ParseURL(url) != nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package urlparser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseURLDetectsSupportedMusicLinks(t *testing.T) {
|
||||
parser := NewParser()
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
service Service
|
||||
itemType string
|
||||
id string
|
||||
}{
|
||||
{name: "spotify intl", url: "https://open.spotify.com/intl-us/track/7tFiyTwD0nx5a1eklYtX2J?si=x", service: Spotify, itemType: "track", id: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "spotify uri", url: "spotify:album:1GbtB4zTqAsyfZEsm1RZfx", service: Spotify, itemType: "album", id: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||
{name: "apple album track", url: "https://music.apple.com/us/album/example/1440857781?i=1440857782", service: AppleMusic, itemType: "song", id: "1440857782"},
|
||||
{name: "youtube music video", url: "https://music.youtube.com/watch?v=abc_DEF-123&si=x", service: YouTubeMusic, itemType: "video", id: "abc_DEF-123"},
|
||||
{name: "youtube playlist", url: "https://www.youtube.com/playlist?list=PL123", service: YouTube, itemType: "playlist", id: "PL123"},
|
||||
{name: "soundcloud set", url: "https://soundcloud.com/artist/sets/mixtape", service: SoundCloud, itemType: "playlist", id: "artist/mixtape"},
|
||||
{name: "tidal", url: "https://listen.tidal.com/browse/track/12345", service: Tidal, itemType: "track", id: "12345"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parser.ParseURL(tt.url)
|
||||
if got == nil {
|
||||
t.Fatal("expected parsed URL")
|
||||
}
|
||||
if got.Service != tt.service || got.ItemType != tt.itemType || got.ID != tt.id {
|
||||
t.Fatalf("got service=%q type=%q id=%q, want service=%q type=%q id=%q", got.Service, got.ItemType, got.ID, tt.service, tt.itemType, tt.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
// Package webplayer provides a Go native Spotify Web Player client using TOTP authentication.
|
||||
// This is a port of the Python implementation, allowing auth-free access to Spotify metadata.
|
||||
package webplayer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Hardcoded TOTP secret from Spotify Web Player (publicly known)
|
||||
totpSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||
totpVersion = 61
|
||||
clientVersion = "1.2.40"
|
||||
minRequestInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// GraphQL persisted query hashes
|
||||
var graphqlHashes = map[string]string{
|
||||
"getTrack": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294",
|
||||
"getAlbum": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||
"fetchPlaylist": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77",
|
||||
"getArtist": "2e7f695dd9c0a6591c2d4f3b9e6e0a7c8d5b4a3f2e1d0c9b8a7f6e5d4c3b2a1",
|
||||
}
|
||||
|
||||
// Track represents Spotify track metadata
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Album Album `json:"album"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalURLs map[string]string `json:"external_urls"`
|
||||
}
|
||||
|
||||
// Artist represents a Spotify artist
|
||||
type Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// Album represents a Spotify album
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
// Image represents an image asset
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// token holds the Spotify access token
|
||||
type token struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
DeviceID string
|
||||
ClientVersion string
|
||||
ExpiresAt time.Time
|
||||
ClientToken string
|
||||
}
|
||||
|
||||
// Client is the Spotify Web Player API client
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
token *token
|
||||
mu sync.RWMutex
|
||||
lastRequest time.Time
|
||||
cookies map[string]string
|
||||
}
|
||||
|
||||
// NewClient creates a new Web Player client
|
||||
func NewClient() *Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
},
|
||||
baseURL: "https://open.spotify.com",
|
||||
cookies: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Configured returns true if the client is functional (always true for this client)
|
||||
func (c *Client) Configured() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// generateTOTP generates a TOTP code using the hardcoded secret
|
||||
func generateTOTP() string {
|
||||
// Base32 decode the secret
|
||||
secretBytes, _ := base32.StdEncoding.DecodeString(totpSecret)
|
||||
|
||||
// Get current time in 30-second intervals
|
||||
currentTime := uint64(time.Now().Unix() / 30)
|
||||
|
||||
// Convert to bytes (big-endian, 8 bytes)
|
||||
timeBytes := make([]byte, 8)
|
||||
for i := 7; i >= 0; i-- {
|
||||
timeBytes[i] = byte(currentTime & 0xFF)
|
||||
currentTime >>= 8
|
||||
}
|
||||
|
||||
// HMAC-SHA1
|
||||
h := hmac.New(sha1.New, secretBytes)
|
||||
h.Write(timeBytes)
|
||||
hmacResult := h.Sum(nil)
|
||||
|
||||
// Dynamic truncation
|
||||
offset := hmacResult[len(hmacResult)-1] & 0x0F
|
||||
code := int(hmacResult[offset]&0x7F)<<24 |
|
||||
int(hmacResult[offset+1]&0xFF)<<16 |
|
||||
int(hmacResult[offset+2]&0xFF)<<8 |
|
||||
int(hmacResult[offset+3]&0xFF)
|
||||
|
||||
// Get 6-digit code
|
||||
totpCode := fmt.Sprintf("%06d", code%1000000)
|
||||
|
||||
return totpCode
|
||||
}
|
||||
|
||||
func (c *Client) rateLimit() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(c.lastRequest)
|
||||
if elapsed < minRequestInterval {
|
||||
time.Sleep(minRequestInterval - elapsed)
|
||||
}
|
||||
c.lastRequest = time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) ensureToken() error {
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if tok == nil || time.Now().After(tok.ExpiresAt.Add(-60*time.Second)) {
|
||||
return c.getAccessToken()
|
||||
}
|
||||
|
||||
if tok.ClientToken == "" {
|
||||
return c.getClientToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getAccessToken() error {
|
||||
// Try TOTP generation first (same as official Web Player)
|
||||
if err := c.getAccessTokenTOTP(); err == nil {
|
||||
// Client token is optional
|
||||
_ = c.getClientToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to tokener API
|
||||
if err := c.getAccessTokenTokener(); err == nil {
|
||||
// Client token is optional - try to get it but don't fail if unavailable
|
||||
_ = c.getClientToken()
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("failed to obtain access token")
|
||||
}
|
||||
|
||||
func (c *Client) getAccessTokenTOTP() error {
|
||||
c.rateLimit()
|
||||
|
||||
totpCode := generateTOTP()
|
||||
|
||||
params := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {totpCode},
|
||||
"totpVer": {strconv.Itoa(totpVersion)},
|
||||
"totpServer": {totpCode},
|
||||
}
|
||||
|
||||
tokenURL := fmt.Sprintf("%s/api/token?%s", c.baseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", tokenURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body for debugging - check content length first
|
||||
var bodyBytes []byte
|
||||
if resp.ContentLength > 0 {
|
||||
bodyBytes = make([]byte, resp.ContentLength)
|
||||
_, err = io.ReadFull(resp.Body, bodyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
} else {
|
||||
bodyBytes, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("TOTP token request failed: HTTP %d, body: %s, content-length: %d", resp.StatusCode, string(bodyBytes), resp.ContentLength)
|
||||
}
|
||||
|
||||
// Extract cookies
|
||||
for _, cookie := range resp.Cookies() {
|
||||
c.cookies[cookie.Name] = cookie.Value
|
||||
}
|
||||
|
||||
var data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &data); err != nil {
|
||||
return fmt.Errorf("failed to decode JSON: %w, body: %s", err, string(bodyBytes))
|
||||
}
|
||||
|
||||
deviceID := c.cookies["sp_t"]
|
||||
if deviceID == "" {
|
||||
deviceID = generateDeviceID()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = &token{
|
||||
AccessToken: data.AccessToken,
|
||||
ClientID: data.ClientID,
|
||||
DeviceID: deviceID,
|
||||
ClientVersion: clientVersion,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getAccessTokenTokener() error {
|
||||
c.rateLimit()
|
||||
|
||||
resp, err := c.httpClient.Get("https://spotify-tokener-api.vercel.app/api/getToken")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tokener API failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.AccessToken == "" || data.ClientID == "" {
|
||||
return errors.New("tokener API returned invalid data")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = &token{
|
||||
AccessToken: data.AccessToken,
|
||||
ClientID: data.ClientID,
|
||||
DeviceID: generateDeviceID(),
|
||||
ClientVersion: clientVersion,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getClientToken() error {
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if tok == nil {
|
||||
return errors.New("no access token available")
|
||||
}
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"client_data": map[string]interface{}{
|
||||
"client_version": tok.ClientVersion,
|
||||
"client_id": tok.ClientID,
|
||||
"js_sdk_data": map[string]interface{}{
|
||||
"device_brand": "unknown",
|
||||
"device_model": "unknown",
|
||||
"os": "windows",
|
||||
"os_version": "NT 10.0",
|
||||
"device_id": tok.DeviceID,
|
||||
"device_type": "computer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("client token request failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var data struct {
|
||||
ResponseType string `json:"response_type"`
|
||||
GrantedToken struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"granted_token"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.ResponseType != "RESPONSE_GRANTED_TOKEN_RESPONSE" {
|
||||
return errors.New("invalid client token response type: " + data.ResponseType)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token.ClientToken = data.GrantedToken.Token
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) graphqlQuery(operationName string, variables map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, ok := graphqlHashes[operationName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown GraphQL operation: %s", operationName)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Use struct with explicit field order to match Python's JSON key ordering
|
||||
// The SHA256 hash is computed on the exact JSON string
|
||||
payload := struct {
|
||||
Variables map[string]interface{} `json:"variables"`
|
||||
OperationName string `json:"operationName"`
|
||||
Extensions struct {
|
||||
PersistedQuery struct {
|
||||
Version int `json:"version"`
|
||||
Sha256Hash string `json:"sha256Hash"`
|
||||
} `json:"persistedQuery"`
|
||||
} `json:"extensions"`
|
||||
}{
|
||||
Variables: variables,
|
||||
OperationName: operationName,
|
||||
}
|
||||
payload.Extensions.PersistedQuery.Version = 1
|
||||
payload.Extensions.PersistedQuery.Sha256Hash = hash
|
||||
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api-partner.spotify.com/pathfinder/v1/query", bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
if tok.ClientToken != "" {
|
||||
req.Header.Set("Client-Token", tok.ClientToken)
|
||||
}
|
||||
req.Header.Set("Spotify-App-Version", tok.ClientVersion)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Token expired, refresh and retry
|
||||
c.mu.Lock()
|
||||
c.token = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Retry request
|
||||
c.mu.RLock()
|
||||
tok = c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
if tok.ClientToken != "" {
|
||||
req.Header.Set("Client-Token", tok.ClientToken)
|
||||
}
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GraphQL query failed: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTrack fetches track metadata by ID
|
||||
func (c *Client) GetTrack(trackID string) (*Track, error) {
|
||||
variables := map[string]interface{}{
|
||||
"uri": fmt.Sprintf("spotify:track:%s", trackID),
|
||||
}
|
||||
|
||||
data, err := c.graphqlQuery("getTrack", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackData, ok := getNestedMap(data, "data", "trackUnion")
|
||||
if !ok {
|
||||
return nil, errors.New("track not found in response")
|
||||
}
|
||||
|
||||
if getString(trackData, "__typename") != "Track" {
|
||||
return nil, errors.New("item is not a track")
|
||||
}
|
||||
|
||||
// Extract artists
|
||||
var artists []Artist
|
||||
if firstArtist, ok := getNestedMap(trackData, "firstArtist"); ok {
|
||||
if profile, ok := getNestedMap(firstArtist, "profile"); ok {
|
||||
artists = append(artists, Artist{
|
||||
ID: getString(firstArtist, "id"),
|
||||
Name: getString(profile, "name"),
|
||||
URI: getString(firstArtist, "uri"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if otherArtists, ok := getNestedMap(trackData, "otherArtists"); ok {
|
||||
if items, ok := otherArtists["items"].([]interface{}); ok {
|
||||
for _, item := range items {
|
||||
if artist, ok := item.(map[string]interface{}); ok {
|
||||
if profile, ok := getNestedMap(artist, "profile"); ok {
|
||||
artists = append(artists, Artist{
|
||||
ID: getString(artist, "id"),
|
||||
Name: getString(profile, "name"),
|
||||
URI: getString(artist, "uri"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract album
|
||||
var album Album
|
||||
if albumData, ok := getNestedMap(trackData, "albumOfTrack"); ok {
|
||||
album = Album{
|
||||
ID: getString(albumData, "id"),
|
||||
Name: getString(albumData, "name"),
|
||||
URI: getString(albumData, "uri"),
|
||||
}
|
||||
|
||||
if visualIdentity, ok := getNestedMap(albumData, "visualIdentity"); ok {
|
||||
if avatarImage, ok := getNestedMap(visualIdentity, "avatarImage"); ok {
|
||||
if sources, ok := avatarImage["sources"].([]interface{}); ok && len(sources) > 0 {
|
||||
if img, ok := sources[0].(map[string]interface{}); ok {
|
||||
album.Images = append(album.Images, Image{
|
||||
URL: getString(img, "url"),
|
||||
Width: int(getFloat(img, "width")),
|
||||
Height: int(getFloat(img, "height")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get duration
|
||||
durationMs := 0
|
||||
if duration, ok := getNestedMap(trackData, "duration"); ok {
|
||||
durationMs = int(getFloat(duration, "totalMilliseconds"))
|
||||
}
|
||||
|
||||
// Check explicit
|
||||
explicit := false
|
||||
if contentRating, ok := getNestedMap(trackData, "contentRating"); ok {
|
||||
explicit = getString(contentRating, "label") == "EXPLICIT"
|
||||
}
|
||||
|
||||
track := &Track{
|
||||
ID: getString(trackData, "id"),
|
||||
Name: getString(trackData, "name"),
|
||||
Artists: artists,
|
||||
Album: album,
|
||||
DurationMs: durationMs,
|
||||
Explicit: explicit,
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", trackID),
|
||||
},
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Search searches for tracks (uses public search endpoint)
|
||||
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"q": {query},
|
||||
"type": {"track"},
|
||||
"limit": {strconv.Itoa(limit)},
|
||||
"market": {"US"},
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?%s", params.Encode())
|
||||
|
||||
c.rateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"images"`
|
||||
} `json:"album"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
} `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tracks []Track
|
||||
for _, item := range data.Tracks.Items {
|
||||
var artists []Artist
|
||||
for _, a := range item.Artists {
|
||||
artists = append(artists, Artist{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
})
|
||||
}
|
||||
|
||||
var images []Image
|
||||
for _, img := range item.Album.Images {
|
||||
images = append(images, Image{
|
||||
URL: img.URL,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
})
|
||||
}
|
||||
|
||||
tracks = append(tracks, Track{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Artists: artists,
|
||||
DurationMs: item.DurationMs,
|
||||
Explicit: item.Explicit,
|
||||
Album: Album{
|
||||
ID: item.Album.ID,
|
||||
Name: item.Album.Name,
|
||||
Images: images,
|
||||
},
|
||||
ExternalURLs: map[string]string{
|
||||
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateDeviceID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func getNestedMap(m map[string]interface{}, keys ...string) (map[string]interface{}, bool) {
|
||||
current := m
|
||||
for _, key := range keys {
|
||||
next, ok := current[key].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getFloat(m map[string]interface{}, key string) float64 {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(v, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// URL parsing helpers
|
||||
|
||||
var spotifyIDRegex = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||
|
||||
// ParseSpotifyURL extracts the type and ID from a Spotify URL
|
||||
func ParseSpotifyURL(urlStr string) (itemType, itemID string, err error) {
|
||||
urlStr = strings.TrimSpace(urlStr)
|
||||
if urlStr == "" {
|
||||
return "", "", errors.New("invalid Spotify URL")
|
||||
}
|
||||
if matches := regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`).FindStringSubmatch(urlStr); len(matches) == 3 {
|
||||
return strings.ToLower(matches[1]), matches[2], nil
|
||||
}
|
||||
|
||||
parsed, parseErr := parseSpotifyWebURL(urlStr)
|
||||
if parseErr != nil {
|
||||
return "", "", parseErr
|
||||
}
|
||||
return parsed.itemType, parsed.itemID, nil
|
||||
}
|
||||
|
||||
type parsedSpotifyWebURL struct {
|
||||
itemType string
|
||||
itemID string
|
||||
}
|
||||
|
||||
func parseSpotifyWebURL(raw string) (parsedSpotifyWebURL, error) {
|
||||
if !strings.Contains(raw, "://") {
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "open.spotify.com/") || strings.HasPrefix(lower, "play.spotify.com/") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return parsedSpotifyWebURL{}, err
|
||||
}
|
||||
if value := u.Query().Get("uri"); value != "" {
|
||||
itemType, itemID, err := ParseSpotifyURL(value)
|
||||
if err != nil {
|
||||
return parsedSpotifyWebURL{}, err
|
||||
}
|
||||
return parsedSpotifyWebURL{itemType: itemType, itemID: itemID}, nil
|
||||
}
|
||||
|
||||
host := strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
||||
if host != "open.spotify.com" && host != "play.spotify.com" && host != "embed.spotify.com" {
|
||||
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 4)
|
||||
for _, part := range strings.Split(u.Path, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && spotifyIDRegex.MatchString(parts[3]) {
|
||||
return parsedSpotifyWebURL{itemType: "playlist", itemID: parts[3]}, nil
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
itemType := strings.ToLower(parts[0])
|
||||
switch itemType {
|
||||
case "track", "album", "playlist", "artist":
|
||||
if spotifyIDRegex.MatchString(parts[1]) {
|
||||
return parsedSpotifyWebURL{itemType: itemType, itemID: parts[1]}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package webplayer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSpotifyURLVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantType string
|
||||
wantID string
|
||||
}{
|
||||
{name: "open URL", url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
{name: "intl URL", url: "https://open.spotify.com/intl-cs/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||
{name: "URI", url: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", wantType: "playlist", wantID: "37i9dQZF1DXcBWIGoYBM5M"},
|
||||
{name: "embed URI", url: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if itemType != tt.wantType || itemID != tt.wantID {
|
||||
t.Fatalf("got type=%q id=%q, want type=%q id=%q", itemType, itemID, tt.wantType, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebPlayerIntegration tests against real Spotify endpoints
|
||||
// Run with: go test -v -run TestWebPlayerIntegration ./... -tags=integration
|
||||
// Or set WEBPLAYER_TEST=1 environment variable
|
||||
func TestWebPlayerIntegration(t *testing.T) {
|
||||
if os.Getenv("WEBPLAYER_TEST") == "" {
|
||||
t.Skip("Skipping integration test. Set WEBPLAYER_TEST=1 to run")
|
||||
}
|
||||
|
||||
client := NewClient()
|
||||
|
||||
t.Run("GetTrack", func(t *testing.T) {
|
||||
// Test with "Bohemian Rhapsody" - a well-known track
|
||||
track, err := client.GetTrack("7tFiyTwD0nx5a1eklYtX2J")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack failed: %v", err)
|
||||
}
|
||||
|
||||
if track.ID == "" {
|
||||
t.Error("track ID is empty")
|
||||
}
|
||||
if track.Name == "" {
|
||||
t.Error("track name is empty")
|
||||
}
|
||||
if len(track.Artists) == 0 {
|
||||
t.Error("no artists found")
|
||||
}
|
||||
if track.Album.Name == "" {
|
||||
t.Error("album name is empty")
|
||||
}
|
||||
|
||||
t.Logf("Got track: %s by %s (%d artists) from album %s, duration=%dms",
|
||||
track.Name,
|
||||
track.Artists[0].Name,
|
||||
len(track.Artists),
|
||||
track.Album.Name,
|
||||
track.DurationMs,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
tracks, err := client.Search("Bohemian Rhapsody Queen", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) == 0 {
|
||||
t.Error("no tracks found in search results")
|
||||
}
|
||||
|
||||
for i, track := range tracks {
|
||||
t.Logf("Result %d: %s by %s", i+1, track.Name, track.Artists[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ParseSpotifyURL", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
wantType string
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J",
|
||||
wantType: "track",
|
||||
wantID: "7tFiyTwD0nx5a1eklYtX2J",
|
||||
},
|
||||
{
|
||||
url: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx",
|
||||
wantType: "album",
|
||||
wantID: "1GbtB4zTqAsyfZEsm1RZfx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||
if err != nil {
|
||||
t.Errorf("ParseSpotifyURL(%q) error: %v", tt.url, err)
|
||||
continue
|
||||
}
|
||||
if itemType != tt.wantType {
|
||||
t.Errorf("ParseSpotifyURL(%q) type = %q, want %q", tt.url, itemType, tt.wantType)
|
||||
}
|
||||
if itemID != tt.wantID {
|
||||
t.Errorf("ParseSpotifyURL(%q) ID = %q, want %q", tt.url, itemID, tt.wantID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTOTPGeneration verifies TOTP generation produces valid codes
|
||||
func TestTOTPGeneration(t *testing.T) {
|
||||
totp := generateTOTP()
|
||||
|
||||
// TOTP should be 6 digits
|
||||
if len(totp) != 6 {
|
||||
t.Errorf("TOTP length = %d, want 6", len(totp))
|
||||
}
|
||||
|
||||
// Should only contain digits
|
||||
for _, c := range totp {
|
||||
if c < '0' || c > '9' {
|
||||
t.Errorf("TOTP contains non-digit character: %c", c)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Generated TOTP: %s", totp)
|
||||
}
|
||||
Reference in New Issue
Block a user