+57
-10
db/db.go
+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
+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
+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
+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
+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
+3
models/user.go
+247
service/lastfm/lastfm.go
+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
+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
+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
+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
+
}