[WIP] music platform user data scraper
teal-fm atproto

add musicbrainz/initial lfm

Natalie B 4ff41eba 6303de32

Changed files
+1079 -78
db
models
service
lastfm
musicbrainz
spotify
+57 -10
db/db.go
··· 40 40 id INTEGER PRIMARY KEY AUTOINCREMENT, 41 41 username TEXT, -- Made nullable, might not have username initially 42 42 email TEXT UNIQUE, -- Made nullable 43 - spotify_id TEXT UNIQUE, -- Spotify specific ID 44 - access_token TEXT, -- Spotify access token 45 - refresh_token TEXT, -- Spotify refresh token 46 - token_expiry TIMESTAMP, -- Spotify token expiry 47 43 atproto_did TEXT UNIQUE, -- Atproto DID (identifier) 48 44 atproto_authserver_issuer TEXT, 49 45 atproto_access_token TEXT, -- Atproto access token ··· 54 50 atproto_token_type TEXT, -- Atproto token type 55 51 atproto_authserver_nonce TEXT, 56 52 atproto_dpop_private_jwk TEXT, 53 + spotify_id TEXT UNIQUE, -- Spotify specific ID 54 + access_token TEXT, -- Spotify access token 55 + refresh_token TEXT, -- Spotify refresh token 56 + token_expiry TIMESTAMP, -- Spotify token expiry 57 + lastfm_username TEXT, -- Last.fm username 57 58 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default 58 59 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default 59 60 )`) ··· 66 67 id INTEGER PRIMARY KEY AUTOINCREMENT, 67 68 user_id INTEGER NOT NULL, 68 69 name TEXT NOT NULL, 70 + recording_mbid TEXT, -- Added 69 71 artist TEXT NOT NULL, -- should be JSONB in PostgreSQL if we ever switch 70 72 album TEXT NOT NULL, 73 + release_mbid TEXT, -- Added 71 74 url TEXT NOT NULL, 72 75 timestamp TIMESTAMP, 73 76 duration_ms INTEGER, ··· 97 100 return err 98 101 } 99 102 103 + // Add columns recording_mbid and release_mbid to tracks table if they don't exist 104 + _, err = db.Exec(`ALTER TABLE tracks ADD COLUMN recording_mbid TEXT`) 105 + if err != nil && err.Error() != "duplicate column name: recording_mbid" { 106 + // Handle errors other than 'duplicate column' 107 + return err 108 + } 109 + _, err = db.Exec(`ALTER TABLE tracks ADD COLUMN release_mbid TEXT`) 110 + if err != nil && err.Error() != "duplicate column name: release_mbid" { 111 + // Handle errors other than 'duplicate column' 112 + return err 113 + } 114 + 100 115 return nil 101 116 } 102 117 ··· 206 221 var trackID int64 207 222 208 223 err := db.QueryRow(` 209 - INSERT INTO tracks (user_id, name, artist, album, url, timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped) 210 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 224 + INSERT INTO tracks (user_id, name, recording_mbid, artist, album, release_mbid, url, timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped) 225 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 211 226 RETURNING id`, 212 - userID, track.Name, artistString, track.Album, track.URL, track.Timestamp, 227 + userID, track.Name, track.RecordingMBID, artistString, track.Album, track.ReleaseMBID, track.URL, track.Timestamp, 213 228 track.DurationMs, track.ProgressMs, track.ServiceBaseUrl, track.ISRC, track.HasStamped).Scan(&trackID) 214 229 215 230 return trackID, err ··· 230 245 _, err := db.Exec(` 231 246 UPDATE tracks 232 247 SET name = ?, 248 + recording_mbid = ?, 233 249 artist = ?, 234 250 album = ?, 251 + release_mbid = ?, 235 252 url = ?, 236 253 timestamp = ?, 237 254 duration_ms = ?, ··· 240 257 isrc = ?, 241 258 has_stamped = ? 242 259 WHERE id = ?`, 243 - track.Name, artistString, track.Album, track.URL, track.Timestamp, 260 + track.Name, track.RecordingMBID, artistString, track.Album, track.ReleaseMBID, track.URL, track.Timestamp, 244 261 track.DurationMs, track.ProgressMs, track.ServiceBaseUrl, track.ISRC, track.HasStamped, 245 262 trackID) 246 263 ··· 249 266 250 267 func (db *DB) GetRecentTracks(userID int64, limit int) ([]*models.Track, error) { 251 268 rows, err := db.Query(` 252 - SELECT id, name, artist, album, url, timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped 269 + SELECT id, name, recording_mbid, artist, album, release_mbid, url, timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped 253 270 FROM tracks 254 271 WHERE user_id = ? 255 272 ORDER BY timestamp DESC ··· 268 285 err := rows.Scan( 269 286 &track.PlayID, 270 287 &track.Name, 271 - &artistString, // scan to be unmarshaled later 288 + &track.RecordingMBID, // Scan new field 289 + &artistString, // scan to be unmarshaled later 272 290 &track.Album, 291 + &track.ReleaseMBID, // Scan new field 273 292 &track.URL, 274 293 &track.Timestamp, 275 294 &track.DurationMs, ··· 354 373 355 374 return users, nil 356 375 } 376 + 377 + func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { 378 + rows, err := db.Query(` 379 + SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at, lastfm_username 380 + FROM users 381 + ORDER BY id`) 382 + 383 + if err != nil { 384 + return nil, err 385 + } 386 + defer rows.Close() 387 + 388 + var users []*models.User 389 + 390 + for rows.Next() { 391 + user := &models.User{} 392 + err := rows.Scan( 393 + &user.ID, &user.Username, &user.Email, &user.SpotifyID, 394 + &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 395 + &user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername) 396 + if err != nil { 397 + return nil, err 398 + } 399 + users = append(users, user) 400 + } 401 + 402 + return users, nil 403 + }
+2
go.mod
··· 22 22 github.com/cli/safeexec v1.0.1 // indirect 23 23 github.com/creack/pty v1.1.23 // indirect 24 24 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 25 + github.com/dlclark/regexp2 v1.11.5 // indirect 25 26 github.com/fatih/color v1.17.0 // indirect 26 27 github.com/felixge/httpsnoop v1.0.4 // indirect 27 28 github.com/fsnotify/fsnotify v1.8.0 // indirect ··· 89 90 golang.org/x/crypto v0.32.0 // indirect 90 91 golang.org/x/sys v0.29.0 // indirect 91 92 golang.org/x/text v0.21.0 // indirect 93 + golang.org/x/time v0.11.0 // indirect 92 94 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect 93 95 google.golang.org/protobuf v1.36.1 // indirect 94 96 gopkg.in/yaml.v3 v3.0.1 // indirect
+4
go.sum
··· 29 29 github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 30 30 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 31 31 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 32 + github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 33 + github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 32 34 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 33 35 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 34 36 github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= ··· 330 332 golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 331 333 golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 332 334 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 335 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 336 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 333 337 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 334 338 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 335 339 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+47 -15
main.go
··· 14 14 "github.com/teal-fm/piper/oauth" 15 15 "github.com/teal-fm/piper/oauth/atproto" 16 16 apikeyService "github.com/teal-fm/piper/service/apikey" 17 + "github.com/teal-fm/piper/service/musicbrainz" // Added musicbrainz service 17 18 "github.com/teal-fm/piper/service/spotify" 18 19 "github.com/teal-fm/piper/session" 19 20 ) ··· 150 151 } 151 152 } 152 153 154 + // apiMusicBrainzSearch handles requests to the MusicBrainz search API. 155 + func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 156 + return func(w http.ResponseWriter, r *http.Request) { 157 + // Optional: Add authentication/rate limiting if needed 158 + 159 + params := musicbrainz.SearchParams{ 160 + Track: r.URL.Query().Get("track"), 161 + Artist: r.URL.Query().Get("artist"), 162 + Release: r.URL.Query().Get("release"), 163 + } 164 + 165 + if params.Track == "" && params.Artist == "" && params.Release == "" { 166 + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "At least one query parameter (track, artist, release) is required"}) 167 + return 168 + } 169 + 170 + recordings, err := mbService.SearchMusicBrainz(r.Context(), params) 171 + if err != nil { 172 + log.Printf("Error searching MusicBrainz: %v", err) // Log the error 173 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to search MusicBrainz"}) 174 + return 175 + } 176 + 177 + // Optionally process recordings (e.g., select best release) before responding 178 + // For now, just return the raw results 179 + jsonResponse(w, http.StatusOK, recordings) 180 + } 181 + } 182 + 153 183 func main() { 154 184 config.Load() 155 185 ··· 162 192 log.Fatalf("Error initializing database: %v", err) 163 193 } 164 194 165 - spotifyService := spotify.NewSpotifyService(database) 166 - sessionManager := session.NewSessionManager() 167 - oauthManager := oauth.NewOAuthServiceManager() 168 - 169 - spotifyOAuth := oauth.NewOAuth2Service( 170 - viper.GetString("spotify.client_id"), 171 - viper.GetString("spotify.client_secret"), 172 - viper.GetString("callback.spotify"), 173 - viper.GetStringSlice("spotify.scopes"), 174 - "spotify", 175 - spotifyService, 176 - ) 177 - oauthManager.RegisterService("spotify", spotifyOAuth) 178 - apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 179 - 180 195 // init atproto svc 181 196 jwksBytes, err := os.ReadFile("./jwks.json") 182 197 if err != nil { ··· 197 212 if err != nil { 198 213 log.Fatalf("Error creating ATproto auth service: %v", err) 199 214 } 215 + mbService := musicbrainz.NewMusicBrainzService(database) 216 + 217 + spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService) 218 + sessionManager := session.NewSessionManager() 219 + oauthManager := oauth.NewOAuthServiceManager() 220 + 221 + spotifyOAuth := oauth.NewOAuth2Service( 222 + viper.GetString("spotify.client_id"), 223 + viper.GetString("spotify.client_secret"), 224 + viper.GetString("callback.spotify"), 225 + viper.GetStringSlice("spotify.scopes"), 226 + "spotify", 227 + spotifyService, 228 + ) 229 + oauthManager.RegisterService("spotify", spotifyOAuth) 230 + apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 200 231 201 232 oauthManager.RegisterService("atproto", atprotoService) 202 233 ··· 219 250 // API routes 220 251 http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager)) 221 252 http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager)) 253 + http.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(mbService)) // Added MusicBrainz search endpoint 222 254 223 255 serverUrlRoot := viper.GetString("server.root_url") 224 256 atpClientId := viper.GetString("atproto.client_id")
+9 -4
models/track.go
··· 4 4 5 5 // Track represents a Spotify track 6 6 type Track struct { 7 - PlayID int64 `json:"playId"` 8 - Name string `json:"name"` 9 - Artist []Artist `json:"artist"` 10 - Album string `json:"album"` 7 + PlayID int64 `json:"playId"` 8 + Name string `json:"name"` 9 + // analogous to "track" 10 + RecordingMBID string `json:"trackMBID"` 11 + Artist []Artist `json:"artist"` 12 + Album string `json:"album"` 13 + // analogous to "album" 14 + ReleaseMBID string `json:"releaseMBID"` 11 15 URL string `json:"url"` 12 16 Timestamp time.Time `json:"timestamp"` 13 17 DurationMs int64 `json:"durationMs"` ··· 20 24 type Artist struct { 21 25 Name string `json:"name"` 22 26 ID string `json:"id"` 27 + MBID string `json:"mbid"` 23 28 }
+3
models/user.go
··· 14 14 RefreshToken *string 15 15 TokenExpiry *time.Time 16 16 17 + // lfm information 18 + LastFMUsername *string 19 + 17 20 // atp info 18 21 ATProtoDID *string 19 22 ATProtoAccessToken *string
+247
service/lastfm/lastfm.go
··· 1 + package lastfm 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "net/url" 11 + "strconv" 12 + "time" 13 + 14 + "github.com/teal-fm/piper/db" 15 + "golang.org/x/time/rate" 16 + ) 17 + 18 + const ( 19 + lastfmAPIBaseURL = "https://ws.audioscrobbler.com/2.0/" 20 + defaultLimit = 50 // Default number of tracks to fetch per user 21 + ) 22 + 23 + // Structs to represent the Last.fm API response for user.getrecenttracks 24 + type RecentTracksResponse struct { 25 + RecentTracks RecentTracks `json:"recenttracks"` 26 + } 27 + 28 + type RecentTracks struct { 29 + Tracks []Track `json:"track"` 30 + Attr TrackXMLAttr `json:"@attr"` 31 + } 32 + 33 + type Track struct { 34 + Artist Artist `json:"artist"` 35 + Streamable string `json:"streamable"` // Typically "0" or "1" 36 + Image []Image `json:"image"` 37 + MBID string `json:"mbid"` // MusicBrainz ID for the track 38 + Album Album `json:"album"` 39 + Name string `json:"name"` 40 + URL string `json:"url"` 41 + Date *TrackDate `json:"date,omitempty"` // Use pointer for optional fields 42 + NowPlaying *struct { // Custom handling for @attr.nowplaying 43 + NowPlaying string `json:"nowplaying"` 44 + } `json:"@attr,omitempty"` 45 + } 46 + 47 + type Artist struct { 48 + MBID string `json:"mbid"` // MusicBrainz ID for the artist 49 + Text string `json:"#text"` 50 + } 51 + 52 + type Image struct { 53 + Size string `json:"size"` // "small", "medium", "large", "extralarge" 54 + Text string `json:"#text"` // URL of the image 55 + } 56 + 57 + type Album struct { 58 + MBID string `json:"mbid"` // MusicBrainz ID for the album 59 + Text string `json:"#text"` // Album name 60 + } 61 + 62 + type TrackDate struct { 63 + UTS string `json:"uts"` // Unix timestamp string 64 + Text string `json:"#text"` // Human-readable date string 65 + } 66 + 67 + type TrackXMLAttr struct { 68 + User string `json:"user"` 69 + TotalPages string `json:"totalPages"` 70 + Page string `json:"page"` 71 + PerPage string `json:"perPage"` 72 + Total string `json:"total"` 73 + } 74 + 75 + type LastFMService struct { 76 + db *db.DB 77 + httpClient *http.Client 78 + limiter *rate.Limiter 79 + apiKey string 80 + Usernames []string 81 + } 82 + 83 + func NewLastFMService(db *db.DB, apiKey string) *LastFMService { 84 + return &LastFMService{ 85 + db: db, 86 + httpClient: &http.Client{ 87 + Timeout: 10 * time.Second, 88 + }, 89 + // Last.fm unofficial rate limit is ~5 requests per second 90 + limiter: rate.NewLimiter(rate.Every(200*time.Millisecond), 1), 91 + apiKey: apiKey, 92 + Usernames: make([]string, 0), 93 + } 94 + } 95 + 96 + func (l *LastFMService) loadUsernames() error { 97 + u, err := l.db.GetAllUsersWithLastFM() 98 + if err != nil { 99 + log.Printf("Error loading users with Last.fm from DB: %v", err) 100 + return fmt.Errorf("failed to load users from database: %w", err) 101 + } 102 + usernames := make([]string, len(u)) 103 + for i, user := range u { 104 + // Assuming the User struct has a LastFMUsername field 105 + if user.LastFMUsername != nil { // Check if the username is set 106 + usernames[i] = *user.LastFMUsername 107 + } else { 108 + log.Printf("User ID %d has Last.fm enabled but no username set", user.ID) 109 + // Handle this case - maybe skip the user or log differently 110 + } 111 + } 112 + 113 + // Filter out empty usernames if any were added due to missing data 114 + filteredUsernames := make([]string, 0, len(usernames)) 115 + for _, name := range usernames { 116 + if name != "" { 117 + filteredUsernames = append(filteredUsernames, name) 118 + } 119 + } 120 + 121 + l.Usernames = filteredUsernames 122 + log.Printf("Loaded %d Last.fm usernames", len(l.Usernames)) 123 + 124 + return nil 125 + } 126 + 127 + // getRecentTracks fetches the most recent tracks for a given Last.fm user. 128 + func (l *LastFMService) getRecentTracks(ctx context.Context, username string, limit int) (*RecentTracksResponse, error) { 129 + if username == "" { 130 + return nil, fmt.Errorf("username cannot be empty") 131 + } 132 + 133 + params := url.Values{} 134 + params.Set("method", "user.getrecenttracks") 135 + params.Set("user", username) 136 + params.Set("api_key", l.apiKey) 137 + params.Set("format", "json") 138 + params.Set("limit", strconv.Itoa(limit)) 139 + 140 + apiURL := lastfmAPIBaseURL + "?" + params.Encode() 141 + 142 + if err := l.limiter.Wait(ctx); err != nil { 143 + return nil, fmt.Errorf("rate limiter error: %w", err) 144 + } 145 + 146 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 147 + if err != nil { 148 + return nil, fmt.Errorf("failed to create request for %s: %w", username, err) 149 + } 150 + 151 + log.Printf("Fetching recent tracks for user: %s", username) 152 + resp, err := l.httpClient.Do(req) 153 + if err != nil { 154 + return nil, fmt.Errorf("failed to fetch recent tracks for %s: %w", username, err) 155 + } 156 + defer resp.Body.Close() 157 + 158 + if resp.StatusCode != http.StatusOK { 159 + bodyBytes, _ := io.ReadAll(resp.Body) 160 + return nil, fmt.Errorf("last.fm API error for %s: status %d, body: %s", username, resp.StatusCode, string(bodyBytes)) 161 + } 162 + 163 + var recentTracksResp RecentTracksResponse 164 + if err := json.NewDecoder(resp.Body).Decode(&recentTracksResp); err != nil { 165 + return nil, fmt.Errorf("failed to decode response for %s: %w", username, err) 166 + } 167 + 168 + if len(recentTracksResp.RecentTracks.Tracks) > 0 { 169 + log.Printf("Fetched %d tracks for %s. Most recent: %s - %s", 170 + len(recentTracksResp.RecentTracks.Tracks), 171 + username, 172 + recentTracksResp.RecentTracks.Tracks[0].Artist.Text, 173 + recentTracksResp.RecentTracks.Tracks[0].Name) 174 + } else { 175 + log.Printf("No recent tracks found for %s", username) 176 + } 177 + 178 + return &recentTracksResp, nil 179 + } 180 + 181 + func (l *LastFMService) StartListeningTracker(interval time.Duration) { 182 + if err := l.loadUsernames(); err != nil { 183 + log.Printf("Failed to perform initial username load: %v", err) 184 + } 185 + 186 + if len(l.Usernames) == 0 { 187 + log.Println("No Last.fm users configured! Will start listening tracker anyways.") 188 + } 189 + 190 + ticker := time.NewTicker(interval) 191 + go func() { 192 + l.fetchAllUserTracks(context.Background()) 193 + 194 + for { 195 + select { 196 + case <-ticker.C: 197 + // refresh usernames periodically from db 198 + if err := l.loadUsernames(); err != nil { 199 + log.Printf("Error reloading usernames in ticker: %v", err) 200 + } 201 + if len(l.Usernames) > 0 { 202 + l.fetchAllUserTracks(context.Background()) 203 + } else { 204 + log.Println("No Last.fm users configured. Skipping fetch cycle.") 205 + } 206 + // Add a way to stop the goroutine if needed, e.g., via a context or channel 207 + // case <-ctx.Done(): 208 + // log.Println("Stopping Last.fm listening tracker.") 209 + // ticker.Stop() 210 + // return 211 + } 212 + } 213 + }() 214 + 215 + log.Printf("Last.fm Listening Tracker started with interval %v", interval) 216 + } 217 + 218 + // fetchAllUserTracks iterates through users and fetches their tracks. 219 + func (l *LastFMService) fetchAllUserTracks(ctx context.Context) { 220 + log.Printf("Starting fetch cycle for %d users...", len(l.Usernames)) 221 + for _, username := range l.Usernames { 222 + if ctx.Err() != nil { 223 + log.Printf("Context cancelled during fetch cycle for user %s.", username) 224 + return 225 + } 226 + recentTracks, err := l.getRecentTracks(ctx, username, defaultLimit) 227 + if err != nil { 228 + log.Printf("Error fetching tracks for %s: %v", username, err) 229 + continue 230 + } 231 + 232 + // TODO: Process the fetched tracks (e.g., store in DB, update stats) 233 + _ = recentTracks // Avoid unused variable warning for now 234 + // Example processing: 235 + // l.processTracks(username, recentTracks.RecentTracks.Tracks) 236 + 237 + } 238 + log.Println("Finished fetch cycle.") 239 + } 240 + 241 + // Placeholder for processing logic 242 + // func (l *LastFMService) processTracks(username string, tracks []Track) { 243 + // log.Printf("Processing %d tracks for user %s", len(tracks), username) 244 + // // Implement logic to store tracks, update user scrobble counts, etc. 245 + // // Consider handling 'now playing' tracks differently (track.NowPlaying != nil) 246 + // // Be mindful of duplicates if the interval is short. Store the last fetched timestamp? 247 + // }
+213
service/musicbrainz/clean.go
··· 1 + package musicbrainz 2 + 3 + import ( 4 + "strings" 5 + "unicode" 6 + "unicode/utf8" 7 + 8 + "github.com/dlclark/regexp2" 9 + ) 10 + 11 + var symbols = "1234567890!@#$%^&*()-=_+[]{};\"|;'\\<>?/.,~`" 12 + 13 + var guffParenWords = []string{ 14 + "a cappella", "acoustic", "bonus", "censored", "clean", "club", "clubmix", "composition", 15 + "cut", "dance", "demo", "dialogue", "dirty", "edit", "excerpt", "explicit", "extended", 16 + "instrumental", "interlude", "intro", "karaoke", "live", "long", "main", "maxi", "megamix", 17 + "mix", "mono", "official", "orchestral", "original", "outro", "outtake", "outtakes", "piano", 18 + "quadraphonic", "radio", "rap", "re-edit", "reedit", "refix", "rehearsal", "reinterpreted", 19 + "released", "release", "remake", "remastered", "remaster", "master", "remix", "remixed", 20 + "remode", "reprise", "rework", "reworked", "rmx", "session", "short", "single", "skit", 21 + "stereo", "studio", "take", "takes", "tape", "track", "tryout", "uncensored", "unknown", 22 + "unplugged", "untitled", "version", "ver", "video", "vocal", "vs", "with", "without", 23 + } 24 + 25 + type MetadataCleaner struct { 26 + recordingExpressions []*regexp2.Regexp 27 + artistExpressions []*regexp2.Regexp 28 + parenGuffExpr *regexp2.Regexp 29 + preferredScript string 30 + } 31 + 32 + func NewMetadataCleaner(preferredScript string) *MetadataCleaner { 33 + recordingPatterns := []string{ 34 + `(?<title>.+?)\s+(?<enclosed>\(.+\)|\[.+\]|\{.+\}|\<.+\>)$`, 35 + `(?<title>.+?)\s+?(?<feat>[\[\(]?(?:feat(?:uring)?|ft)\b\.?)\s*?(?<artists>.+)\s*`, 36 + `(?<title>.+?)(?:\s+?[\u2010\u2012\u2013\u2014~/-])(?![^(]*\))(?<dash>.*)`, 37 + } 38 + 39 + artistPatterns := []string{ 40 + `(?<title>.+?)(?:\s*?,)(?<comma>.*)`, 41 + `(?<title>.+?)(?:\s+?(&|with))(?<dash>.*)`, 42 + } 43 + 44 + compiledRecording := make([]*regexp2.Regexp, 0, len(recordingPatterns)) 45 + for _, pattern := range recordingPatterns { 46 + compiledRecording = append(compiledRecording, regexp2.MustCompile(`(?i)`+pattern, 0)) 47 + } 48 + 49 + compiledArtist := make([]*regexp2.Regexp, 0, len(artistPatterns)) 50 + for _, pattern := range artistPatterns { 51 + compiledArtist = append(compiledArtist, regexp2.MustCompile(`(?i)`+pattern, 0)) 52 + } 53 + 54 + return &MetadataCleaner{ 55 + recordingExpressions: compiledRecording, 56 + artistExpressions: compiledArtist, 57 + preferredScript: preferredScript, 58 + parenGuffExpr: regexp2.MustCompile(`(20[0-9]{2}|19[0-9]{2})`, 0), 59 + } 60 + } 61 + 62 + func (mc *MetadataCleaner) DropForeignChars(text string) string { 63 + var b strings.Builder 64 + b.Grow(len(text)) 65 + 66 + hasForeign := false 67 + hasLetter := false 68 + 69 + for _, r := range text { 70 + if unicode.Is(unicode.Common, r) || mc.isPreferredScript(r) { 71 + b.WriteRune(r) 72 + if unicode.IsLetter(r) { 73 + hasLetter = true 74 + } 75 + } else { 76 + hasForeign = true 77 + } 78 + } 79 + 80 + cleaned := strings.TrimSpace(b.String()) 81 + if hasForeign && len(cleaned) > 0 && hasLetter { 82 + return cleaned 83 + } 84 + return text 85 + } 86 + 87 + func (mc *MetadataCleaner) isPreferredScript(r rune) bool { 88 + switch mc.preferredScript { 89 + case "Latin": 90 + return unicode.Is(unicode.Latin, r) 91 + case "Han": 92 + return unicode.Is(unicode.Han, r) 93 + case "Cyrillic": 94 + return unicode.Is(unicode.Cyrillic, r) 95 + case "Devanagari": 96 + return unicode.Is(unicode.Devanagari, r) 97 + default: 98 + return false 99 + } 100 + } 101 + 102 + func (mc *MetadataCleaner) IsParenTextLikelyGuff(parenText string) bool { 103 + pt := strings.ToLower(parenText) 104 + beforeLen := utf8.RuneCountInString(pt) 105 + 106 + for _, guff := range guffParenWords { 107 + pt = strings.ReplaceAll(pt, guff, "") 108 + } 109 + 110 + pt, _ = mc.parenGuffExpr.Replace(pt, "", -1, -1) 111 + afterLen := utf8.RuneCountInString(pt) 112 + replaced := beforeLen - afterLen 113 + 114 + chars := 0 115 + guffChars := replaced 116 + for _, ch := range pt { 117 + if strings.ContainsRune(symbols, ch) { 118 + guffChars++ 119 + } 120 + if unicode.IsLetter(ch) { 121 + chars++ 122 + } 123 + } 124 + 125 + return guffChars > chars 126 + } 127 + 128 + func (mc *MetadataCleaner) ParenChecker(text string) bool { 129 + brackets := []struct { 130 + open, close rune 131 + }{ 132 + {'(', ')'}, {'[', ']'}, {'{', '}'}, {'<', '>'}, 133 + } 134 + for _, pair := range brackets { 135 + if strings.Count(text, string(pair.open)) != strings.Count(text, string(pair.close)) { 136 + return false 137 + } 138 + } 139 + return true 140 + } 141 + 142 + func (mc *MetadataCleaner) CleanRecording(text string) (string, bool) { 143 + text = strings.TrimSpace(text) 144 + 145 + if !mc.ParenChecker(text) { 146 + return text, false 147 + } 148 + 149 + text = mc.DropForeignChars(text) 150 + var changed bool 151 + 152 + for _, expr := range mc.recordingExpressions { 153 + match, _ := expr.FindStringMatch(text) 154 + if match != nil { 155 + groups := make(map[string]string) 156 + for _, name := range expr.GetGroupNames() { 157 + groups[name] = strings.TrimSpace(match.GroupByName(name).String()) 158 + } 159 + 160 + if guffy := groups["enclosed"]; guffy != "" && mc.IsParenTextLikelyGuff(guffy) { 161 + text = groups["title"] 162 + changed = true 163 + break 164 + } 165 + 166 + if feat := groups["feat"]; feat != "" { 167 + text = groups["title"] 168 + changed = true 169 + break 170 + } 171 + 172 + if dash := groups["dash"]; dash != "" { 173 + if mc.IsParenTextLikelyGuff(dash) { 174 + text = groups["title"] 175 + changed = true 176 + break 177 + } 178 + } 179 + } 180 + } 181 + 182 + return strings.TrimSpace(text), changed 183 + } 184 + 185 + func (mc *MetadataCleaner) CleanArtist(text string) (string, bool) { 186 + text = strings.TrimSpace(text) 187 + 188 + if !mc.ParenChecker(text) { 189 + return text, false 190 + } 191 + 192 + text = mc.DropForeignChars(text) 193 + var changed bool 194 + 195 + for _, expr := range mc.artistExpressions { 196 + match, _ := expr.FindStringMatch(text) 197 + if match != nil { 198 + groups := make(map[string]string) 199 + for _, name := range expr.GetGroupNames() { 200 + groups[name] = strings.TrimSpace(match.GroupByName(name).String()) 201 + } 202 + 203 + title := groups["title"] 204 + if len(title) > 2 && unicode.IsLetter(rune(title[0])) { 205 + text = title 206 + changed = true 207 + break 208 + } 209 + } 210 + } 211 + 212 + return strings.TrimSpace(text), changed 213 + }
+255
service/musicbrainz/musicbrainz.go
··· 1 + package musicbrainz 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "sort" 11 + "strings" 12 + "sync" // Added for mutex 13 + "time" 14 + 15 + "github.com/teal-fm/piper/db" 16 + "golang.org/x/time/rate" 17 + ) 18 + 19 + // MusicBrainz API Types 20 + type MusicBrainzArtistCredit struct { 21 + Artist struct { 22 + ID string `json:"id"` 23 + Name string `json:"name"` 24 + SortName string `json:"sort-name,omitempty"` 25 + } `json:"artist"` 26 + Joinphrase string `json:"joinphrase,omitempty"` 27 + Name string `json:"name"` 28 + } 29 + 30 + type MusicBrainzRelease struct { 31 + ID string `json:"id"` 32 + Title string `json:"title"` 33 + Status string `json:"status,omitempty"` 34 + Date string `json:"date,omitempty"` // YYYY-MM-DD, YYYY-MM, or YYYY 35 + Country string `json:"country,omitempty"` 36 + Disambiguation string `json:"disambiguation,omitempty"` 37 + TrackCount int `json:"track-count,omitempty"` 38 + } 39 + 40 + type MusicBrainzRecording struct { 41 + ID string `json:"id"` 42 + Title string `json:"title"` 43 + Length int `json:"length,omitempty"` // milliseconds 44 + ISRCs []string `json:"isrcs,omitempty"` 45 + ArtistCredit []MusicBrainzArtistCredit `json:"artist-credit,omitempty"` 46 + Releases []MusicBrainzRelease `json:"releases,omitempty"` 47 + } 48 + 49 + type MusicBrainzSearchResponse struct { 50 + Created time.Time `json:"created"` 51 + Count int `json:"count"` 52 + Offset int `json:"offset"` 53 + Recordings []MusicBrainzRecording `json:"recordings"` 54 + } 55 + 56 + type SearchParams struct { 57 + Track string 58 + Artist string 59 + Release string 60 + } 61 + 62 + // cacheEntry holds the cached data and its expiration time. 63 + type cacheEntry struct { 64 + recordings []MusicBrainzRecording 65 + expiresAt time.Time 66 + } 67 + 68 + type MusicBrainzService struct { 69 + db *db.DB 70 + httpClient *http.Client 71 + limiter *rate.Limiter 72 + searchCache map[string]cacheEntry // In-memory cache for search results 73 + cacheMutex sync.RWMutex // Mutex to protect the cache 74 + cacheTTL time.Duration // Time-to-live for cache entries 75 + cleaner MetadataCleaner // Cleaner for cleaning up expired cache entries 76 + } 77 + 78 + // NewMusicBrainzService creates a new service instance with rate limiting and caching. 79 + func NewMusicBrainzService(db *db.DB) *MusicBrainzService { 80 + // MusicBrainz allows 1 request per second 81 + limiter := rate.NewLimiter(rate.Every(time.Second), 1) 82 + // Set a default cache TTL (e.g., 1 hour) 83 + defaultCacheTTL := 1 * time.Hour 84 + 85 + return &MusicBrainzService{ 86 + db: db, 87 + httpClient: &http.Client{ 88 + Timeout: 10 * time.Second, 89 + }, 90 + limiter: limiter, 91 + searchCache: make(map[string]cacheEntry), // Initialize the cache map 92 + cacheTTL: defaultCacheTTL, // Set the cache TTL 93 + cleaner: *NewMetadataCleaner("Latin"), // Initialize the cleaner 94 + // cacheMutex is zero-value ready 95 + } 96 + } 97 + 98 + // generateCacheKey creates a unique string key for caching based on search parameters. 99 + func generateCacheKey(params SearchParams) string { 100 + // Use a structured format to avoid collisions and ensure order doesn't matter implicitly 101 + // url.QueryEscape handles potential special characters in parameters 102 + return fmt.Sprintf("track=%s&artist=%s&release=%s", 103 + url.QueryEscape(params.Track), 104 + url.QueryEscape(params.Artist), 105 + url.QueryEscape(params.Release)) 106 + } 107 + 108 + // SearchMusicBrainz searches the MusicBrainz API for recordings, using an in-memory cache. 109 + func (s *MusicBrainzService) SearchMusicBrainz(ctx context.Context, params SearchParams) ([]MusicBrainzRecording, error) { 110 + // Validate parameters first 111 + if params.Track == "" && params.Artist == "" && params.Release == "" { 112 + return nil, fmt.Errorf("at least one search parameter (Track, Artist, Release) must be provided") 113 + } 114 + 115 + // clean params 116 + params.Track, _ = s.cleaner.CleanRecording(params.Track) 117 + params.Artist, _ = s.cleaner.CleanArtist(params.Artist) 118 + 119 + cacheKey := generateCacheKey(params) 120 + now := time.Now() 121 + 122 + // --- Check Cache (Read Lock) --- 123 + s.cacheMutex.RLock() 124 + entry, found := s.searchCache[cacheKey] 125 + s.cacheMutex.RUnlock() 126 + 127 + if found && now.Before(entry.expiresAt) { 128 + log.Printf("Cache hit for MusicBrainz search: key=%s", cacheKey) 129 + // Return the cached data directly. Consider if a deep copy is needed if callers modify results. 130 + return entry.recordings, nil 131 + } 132 + // --- Cache Miss or Expired --- 133 + if found { 134 + log.Printf("Cache expired for MusicBrainz search: key=%s", cacheKey) 135 + } else { 136 + log.Printf("Cache miss for MusicBrainz search: key=%s", cacheKey) 137 + } 138 + 139 + // --- Proceed with API call --- 140 + queryParts := []string{} 141 + if params.Track != "" { 142 + queryParts = append(queryParts, fmt.Sprintf(`recording:"%s"`, params.Track)) 143 + } 144 + if params.Artist != "" { 145 + queryParts = append(queryParts, fmt.Sprintf(`artist:"%s"`, params.Artist)) 146 + } 147 + if params.Release != "" { 148 + queryParts = append(queryParts, fmt.Sprintf(`release:"%s"`, params.Release)) 149 + } 150 + query := strings.Join(queryParts, " AND ") 151 + endpoint := fmt.Sprintf("https://musicbrainz.org/ws/2/recording?query=%s&fmt=json&inc=artists+releases+isrcs", url.QueryEscape(query)) 152 + 153 + if err := s.limiter.Wait(ctx); err != nil { 154 + if ctx.Err() != nil { 155 + return nil, fmt.Errorf("context cancelled during rate limiter wait: %w", ctx.Err()) 156 + } 157 + return nil, fmt.Errorf("rate limiter error: %w", err) 158 + } 159 + 160 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 161 + if err != nil { 162 + return nil, fmt.Errorf("failed to create request: %w", err) 163 + } 164 + req.Header.Set("User-Agent", "piper/0.0.1 ( https://github.com/teal-fm/piper )") 165 + 166 + resp, err := s.httpClient.Do(req) 167 + if err != nil { 168 + if ctx.Err() != nil { 169 + return nil, fmt.Errorf("context error during request execution: %w", ctx.Err()) 170 + } 171 + return nil, fmt.Errorf("failed to execute request to %s: %w", endpoint, err) 172 + } 173 + defer resp.Body.Close() 174 + 175 + if resp.StatusCode != http.StatusOK { 176 + // TODO: Consider reading body for detailed error message from MusicBrainz 177 + return nil, fmt.Errorf("MusicBrainz API request to %s returned status %d", endpoint, resp.StatusCode) 178 + } 179 + 180 + var result MusicBrainzSearchResponse 181 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 182 + return nil, fmt.Errorf("failed to decode response from %s: %w", endpoint, err) 183 + } 184 + 185 + // cache result for later 186 + s.cacheMutex.Lock() 187 + s.searchCache[cacheKey] = cacheEntry{ 188 + recordings: result.Recordings, 189 + expiresAt: time.Now().Add(s.cacheTTL), 190 + } 191 + s.cacheMutex.Unlock() 192 + log.Printf("Cached MusicBrainz search result for key=%s, TTL=%s", cacheKey, s.cacheTTL) 193 + 194 + // Return the newly fetched results 195 + return result.Recordings, nil 196 + } 197 + 198 + // GetBestRelease selects the 'best' release from a list based on specific criteria. 199 + func GetBestRelease(releases []MusicBrainzRelease, trackTitle string) *MusicBrainzRelease { 200 + if len(releases) == 0 { 201 + return nil 202 + } 203 + if len(releases) == 1 { 204 + // Return a pointer to the single element 205 + r := releases[0] 206 + return &r 207 + } 208 + 209 + // Sort releases: Prefer valid dates first, then sort by date, title, id. 210 + sort.SliceStable(releases, func(i, j int) bool { 211 + dateA := releases[i].Date 212 + dateB := releases[j].Date 213 + validDateA := len(dateA) >= 4 // Basic check for YYYY format or longer 214 + validDateB := len(dateB) >= 4 215 + 216 + // Put invalid/empty dates at the end 217 + if validDateA && !validDateB { 218 + return true 219 + } 220 + if !validDateA && validDateB { 221 + return false 222 + } 223 + // If both valid or both invalid, compare dates lexicographically 224 + if dateA != dateB { 225 + return dateA < dateB 226 + } 227 + // If dates are same, compare by title 228 + if releases[i].Title != releases[j].Title { 229 + return releases[i].Title < releases[j].Title 230 + } 231 + // If titles are same, compare by ID 232 + return releases[i].ID < releases[j].ID 233 + }) 234 + 235 + // 1. Find oldest release where country is 'XW' or 'US' AND title is NOT track title 236 + for i := range releases { 237 + release := &releases[i] 238 + if (release.Country == "XW" || release.Country == "US") && release.Title != trackTitle { 239 + return release 240 + } 241 + } 242 + 243 + // 2. If none, find oldest release where title is NOT track title 244 + for i := range releases { 245 + release := &releases[i] 246 + if release.Title != trackTitle { 247 + return release 248 + } 249 + } 250 + 251 + // 3. If none found, return the oldest release overall (which is the first one after sorting) 252 + log.Printf("Could not find a suitable release for '%s', picking oldest: '%s' (%s)", trackTitle, releases[0].Title, releases[0].ID) 253 + r := releases[0] 254 + return &r 255 + }
+242 -49
service/spotify/spotify.go
··· 1 1 package spotify 2 2 3 3 import ( 4 + "context" 5 + "encoding/base64" // Added for Basic Auth 4 6 "encoding/json" 5 7 "errors" 6 8 "fmt" 7 9 "io" 8 10 "log" 9 11 "net/http" 10 - "strconv" 12 + "net/url" // Added for request body 13 + 14 + // "strconv" // Removed unused import 15 + "strings" // Added for request body 11 16 "sync" 12 17 "time" 13 18 19 + "github.com/spf13/viper" // Added for config access 14 20 "github.com/teal-fm/piper/db" 15 21 "github.com/teal-fm/piper/models" 22 + "github.com/teal-fm/piper/oauth/atproto" 23 + "github.com/teal-fm/piper/service/musicbrainz" 16 24 "github.com/teal-fm/piper/session" 17 25 ) 18 26 19 27 type SpotifyService struct { 20 - DB *db.DB 21 - userTracks map[int64]*models.Track 22 - userTokens map[int64]string 23 - mu sync.RWMutex 28 + DB *db.DB 29 + atprotoService *atproto.ATprotoAuthService // Added field 30 + mb *musicbrainz.MusicBrainzService // Added field 31 + userTracks map[int64]*models.Track 32 + userTokens map[int64]string 33 + mu sync.RWMutex 24 34 } 25 35 26 - func NewSpotifyService(database *db.DB) *SpotifyService { 36 + func NewSpotifyService(database *db.DB, atprotoService *atproto.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService) *SpotifyService { 27 37 return &SpotifyService{ 28 - DB: database, 29 - userTracks: make(map[int64]*models.Track), 30 - userTokens: make(map[int64]string), 38 + DB: database, 39 + atprotoService: atprotoService, 40 + mb: musicBrainzService, 41 + userTracks: make(map[int64]*models.Track), 42 + userTokens: make(map[int64]string), 31 43 } 32 44 } 33 45 ··· 119 131 return nil 120 132 } 121 133 122 - func (s *SpotifyService) refreshTokenInner(user models.User) error { 123 - // implement token refresh logic here using Spotify's token refresh endpoint 124 - // this would make a request to Spotify's token endpoint with grant_type=refresh_token 125 - return errors.New("Not implemented yet") 126 - // if successful, update the database and in-memory cache 127 - } 134 + // refreshTokenInner handles the actual Spotify token refresh logic. 135 + // It returns the new access token or an error. 136 + func (s *SpotifyService) refreshTokenInner(userID int64) (string, error) { 137 + user, err := s.DB.GetUserByID(userID) 138 + if err != nil { 139 + return "", fmt.Errorf("error loading user %d for refresh: %w", userID, err) 140 + } 141 + 142 + if user == nil { 143 + return "", fmt.Errorf("user %d not found for refresh", userID) 144 + } 145 + 146 + if user.RefreshToken == nil || *user.RefreshToken == "" { 147 + // If no refresh token, remove potentially stale access token from cache and return error 148 + s.mu.Lock() 149 + delete(s.userTokens, userID) 150 + s.mu.Unlock() 151 + return "", fmt.Errorf("no refresh token available for user %d", userID) 152 + } 153 + 154 + clientID := viper.GetString("spotify.client_id") 155 + clientSecret := viper.GetString("spotify.client_secret") 156 + if clientID == "" || clientSecret == "" { 157 + return "", errors.New("spotify client ID or secret not configured") 158 + } 128 159 129 - func (s *SpotifyService) RefreshToken(userID string) error { 130 - s.mu.Lock() 131 - defer s.mu.Unlock() 160 + data := url.Values{} 161 + data.Set("grant_type", "refresh_token") 162 + data.Set("refresh_token", *user.RefreshToken) 132 163 133 - user, err := s.DB.GetUserBySpotifyID(userID) 164 + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(data.Encode())) 134 165 if err != nil { 135 - return fmt.Errorf("error loading user: %v", err) 166 + return "", fmt.Errorf("failed to create refresh request: %w", err) 136 167 } 137 168 138 - if user.RefreshToken == nil { 139 - return fmt.Errorf("no refresh token for user %s", userID) 169 + authHeader := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) 170 + req.Header.Set("Authorization", "Basic "+authHeader) 171 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 172 + 173 + client := &http.Client{Timeout: 10 * time.Second} 174 + resp, err := client.Do(req) 175 + if err != nil { 176 + return "", fmt.Errorf("failed to execute refresh request: %w", err) 140 177 } 178 + defer resp.Body.Close() 141 179 142 - return s.refreshTokenInner(*user) 180 + body, readErr := io.ReadAll(resp.Body) 181 + if readErr != nil { 182 + return "", fmt.Errorf("failed to read refresh response body: %w", readErr) 183 + } 184 + 185 + if resp.StatusCode != http.StatusOK { 186 + // If refresh fails (e.g., bad refresh token), remove tokens from cache 187 + s.mu.Lock() 188 + delete(s.userTokens, userID) 189 + s.mu.Unlock() 190 + // Also clear the bad refresh token from the DB 191 + updateErr := s.DB.UpdateUserToken(userID, "", "", time.Now()) // Clear tokens 192 + if updateErr != nil { 193 + log.Printf("Failed to clear bad refresh token for user %d: %v", userID, updateErr) 194 + } 195 + return "", fmt.Errorf("spotify token refresh failed (%d): %s", resp.StatusCode, string(body)) 196 + } 197 + 198 + var tokenResponse struct { 199 + AccessToken string `json:"access_token"` 200 + TokenType string `json:"token_type"` 201 + Scope string `json:"scope"` 202 + ExpiresIn int `json:"expires_in"` // Seconds 203 + RefreshToken string `json:"refresh_token,omitempty"` // Spotify might issue a new refresh token 204 + } 205 + 206 + if err := json.Unmarshal(body, &tokenResponse); err != nil { 207 + return "", fmt.Errorf("failed to decode refresh response: %w", err) 208 + } 209 + 210 + newExpiry := time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second) 211 + newRefreshToken := *user.RefreshToken // Default to old one 212 + if tokenResponse.RefreshToken != "" { 213 + newRefreshToken = tokenResponse.RefreshToken // Use new one if provided 214 + } 215 + 216 + // Update DB 217 + if err := s.DB.UpdateUserToken(userID, tokenResponse.AccessToken, newRefreshToken, newExpiry); err != nil { 218 + // Log error but continue, as we have the token in memory 219 + log.Printf("Error updating user token in DB for user %d after refresh: %v", userID, err) 220 + } 221 + 222 + // Update in-memory cache 223 + s.mu.Lock() 224 + s.userTokens[userID] = tokenResponse.AccessToken 225 + s.mu.Unlock() 226 + 227 + log.Printf("Successfully refreshed token for user %d", userID) 228 + return tokenResponse.AccessToken, nil 229 + } 230 + 231 + // RefreshToken attempts to refresh the token for a given user ID. 232 + // It's less commonly needed now refreshTokenInner handles fetching the user. 233 + func (s *SpotifyService) RefreshToken(userID int64) error { 234 + _, err := s.refreshTokenInner(userID) 235 + return err 143 236 } 144 237 145 238 // attempt to refresh expired tokens ··· 157 250 continue 158 251 } 159 252 160 - err := s.refreshTokenInner(*user) 253 + _, err := s.refreshTokenInner(user.ID) 161 254 162 255 if err != nil { 163 256 // just print out errors here for now ··· 246 339 return nil, fmt.Errorf("no access token for user %d", userID) 247 340 } 248 341 249 - req, err := http.NewRequest("GET", "https://api.spotify.com/v1/me/player/currently-playing", nil) 250 - if err != nil { 251 - return nil, err 342 + req, rErr := http.NewRequest("GET", "https://api.spotify.com/v1/me/player/currently-playing", nil) 343 + if rErr != nil { 344 + return nil, rErr 252 345 } 253 346 254 347 req.Header.Set("Authorization", "Bearer "+token) 255 348 client := &http.Client{} 256 - resp, err := client.Do(req) 257 - if err != nil { 258 - return nil, err 349 + var resp *http.Response 350 + var err error 351 + 352 + // Retry logic: try once, if 401, refresh and try again 353 + for attempt := 0; attempt < 2; attempt++ { 354 + // We need to be able to re-read the body if the request is retried, 355 + // but since this is a GET request with no body, we don't need to worry about it. 356 + resp, err = client.Do(req) // Use = instead of := inside loop 357 + if err != nil { 358 + // Network or other client error, don't retry 359 + return nil, fmt.Errorf("failed to execute spotify request on attempt %d: %w", attempt+1, err) 360 + } 361 + // Defer close inside the loop IF we continue, otherwise close after the loop 362 + // Simplified: Always defer close, it's idempotent for nil resp.Body 363 + // defer resp.Body.Close() // Moved defer outside loop to avoid potential issues 364 + 365 + // oops, token expired or other client error 366 + if resp.StatusCode == 401 && attempt == 0 { // Only refresh on 401 on the first attempt 367 + log.Printf("Spotify token potentially expired for user %d, attempting refresh...", userID) 368 + newAccessToken, refreshErr := s.refreshTokenInner(userID) 369 + if refreshErr != nil { 370 + log.Printf("Token refresh failed for user %d: %v", userID, refreshErr) 371 + // No point retrying if refresh failed 372 + return nil, fmt.Errorf("spotify token expired or invalid for user %d and refresh failed: %w", userID, refreshErr) 373 + } 374 + log.Printf("Token refreshed for user %d, retrying request...", userID) 375 + token = newAccessToken // Update token for the next attempt 376 + req.Header.Set("Authorization", "Bearer "+token) // Update header for retry 377 + continue // Go to next attempt in the loop 378 + } 379 + 380 + // If it's not 200 or 204, or if it's 401 on the second attempt, break and return error 381 + if resp.StatusCode != 200 && resp.StatusCode != 204 { 382 + body, _ := io.ReadAll(resp.Body) 383 + return nil, fmt.Errorf("spotify API error (%d) for user %d after %d attempts: %s", resp.StatusCode, userID, attempt+1, string(body)) 384 + } 385 + 386 + // If status is 200 or 204, break the loop, we have a valid response (or no content) 387 + break 388 + } // End of retry loop 389 + 390 + // Ensure body is closed regardless of loop outcome 391 + if resp != nil && resp.Body != nil { 392 + defer resp.Body.Close() 259 393 } 260 - defer resp.Body.Close() 261 394 262 - // nothing playing 395 + // Handle final response after loop 396 + if resp == nil { 397 + // This should ideally not happen if client.Do succeeded but we check defensively 398 + return nil, fmt.Errorf("spotify request failed with no response after retries") 399 + } 263 400 if resp.StatusCode == 204 { 264 - return nil, nil 401 + return nil, nil // Nothing playing 265 402 } 266 403 267 - // oops, token expired 268 - if resp.StatusCode == 401 { 269 - // attempt to refresh token 270 - if err := s.RefreshToken(strconv.FormatInt(userID, 10)); err != nil { 271 - s.mu.Lock() 272 - delete(s.userTokens, userID) 273 - s.mu.Unlock() 274 - return nil, fmt.Errorf("spotify token expired for user %d", userID) 275 - } 276 - } 277 - 278 - if resp.StatusCode != 200 { 279 - body, _ := io.ReadAll(resp.Body) 280 - return nil, fmt.Errorf("spotify API error: %s", body) 404 + // Read body now that we know it's a successful 200 405 + bodyBytes, err := io.ReadAll(resp.Body) // Read the already fetched successful response body 406 + if err != nil { 407 + return nil, fmt.Errorf("failed to read successful spotify response body: %w", err) 281 408 } 282 409 283 410 var response struct { ··· 301 428 ProgressMS int `json:"progress_ms"` 302 429 } 303 430 304 - body, err := io.ReadAll(resp.Body) 431 + err = json.Unmarshal(bodyBytes, &response) // Use bodyBytes here 305 432 if err != nil { 306 - return nil, err 433 + return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) 434 + } 435 + 436 + body, ioErr := io.ReadAll(resp.Body) 437 + if ioErr != nil { 438 + return nil, ioErr 307 439 } 308 440 309 441 err = json.Unmarshal(body, &response) ··· 430 562 } 431 563 } 432 564 } 565 + 566 + // Take a track, with any IDs and hydrate it with MusicBrainz data 567 + func (s *SpotifyService) hydrateTrack(track *models.Track) (*models.Track, error) { 568 + ctx := context.Background() 569 + // array of strings 570 + artistArray := make([]string, len(track.Artist)) // Assuming Name is string type 571 + for i, a := range track.Artist { 572 + artistArray[i] = a.Name 573 + } 574 + 575 + params := musicbrainz.SearchParams{ 576 + Track: track.Name, 577 + Artist: strings.Join(artistArray, ", "), 578 + Release: track.Album, 579 + } 580 + res, err := s.mb.SearchMusicBrainz(ctx, params) 581 + if err != nil { 582 + return nil, err 583 + } 584 + 585 + if len(res) == 0 { 586 + return nil, errors.New("no results found") 587 + } 588 + 589 + firstResult := res[0] 590 + firstResultAlbum := musicbrainz.GetBestRelease(firstResult.Releases, firstResult.Title) 591 + 592 + bestISRC := firstResult.ISRCs[0] 593 + 594 + if len(firstResult.ISRCs) == 0 { 595 + bestISRC = track.ISRC 596 + } 597 + 598 + artists := make([]models.Artist, len(firstResult.ArtistCredit)) 599 + 600 + for i, a := range firstResult.ArtistCredit { 601 + artists[i] = models.Artist{ 602 + Name: a.Name, 603 + ID: a.Artist.ID, 604 + MBID: a.Artist.ID, 605 + } 606 + } 607 + 608 + resTrack := models.Track{ 609 + HasStamped: track.HasStamped, 610 + PlayID: track.PlayID, 611 + Name: track.Name, 612 + URL: track.URL, 613 + ServiceBaseUrl: track.ServiceBaseUrl, 614 + RecordingMBID: firstResult.ID, 615 + Album: firstResultAlbum.Title, 616 + ReleaseMBID: firstResultAlbum.ID, 617 + ISRC: bestISRC, 618 + Timestamp: track.Timestamp, 619 + ProgressMs: track.ProgressMs, 620 + DurationMs: int64(firstResult.Length), 621 + Artist: artists, 622 + } 623 + 624 + return &resTrack, nil 625 + }