first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
+105
View File
@@ -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, `"`, `\"`)
}
+912
View File
@@ -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)
}
})
}
}
+135
View File
@@ -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)
}