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

move stuff around 2

Natalie B 9d2d675c a3e7f67d

Changed files
+394 -146
db
models
oauth
service
lastfm
musicbrainz
spotify
session
+1 -1
db/atproto.go
··· 126 126 atproto_token_expiry = ?, 127 127 atproto_scope = ?, 128 128 atproto_sub = ?, 129 - atproto_authserver_iss = ?, 129 + atproto_authserver_issuer = ?, 130 130 atproto_token_type = ?, 131 131 atproto_authserver_nonce = ?, 132 132 atproto_dpop_private_jwk = ?,
+72 -25
db/db.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "encoding/json" 6 + "fmt" 6 7 "os" 7 8 "path/filepath" 8 9 "time" ··· 374 375 return users, nil 375 376 } 376 377 377 - func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error { 378 - _, err := db.Exec(` 379 - UPDATE users 380 - SET lastfm_username = ? 381 - WHERE user_id = ?`, lastfmUsername, userID) 378 + // debug to view current user's information 379 + // put everything in an 'any' type 380 + func (db *DB) DebugViewUserInformation(userID int64) (map[string]any, error) { 381 + // Use Query instead of QueryRow to get access to column names and ensure only one row is processed 382 + rows, err := db.Query(` 383 + SELECT * 384 + FROM users 385 + WHERE id = ? LIMIT 1`, userID) 386 + if err != nil { 387 + return nil, fmt.Errorf("query failed: %w", err) 388 + } 389 + defer rows.Close() 382 390 383 - return err 384 - } 391 + // Get column names 392 + cols, err := rows.Columns() 393 + if err != nil { 394 + return nil, fmt.Errorf("failed to get columns: %w", err) 395 + } 385 396 386 - func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { 387 - rows, err := db.Query(` 388 - SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at, lastfm_username 389 - FROM users 390 - ORDER BY id`) 397 + // Check if there's a row to process 398 + if !rows.Next() { 399 + if err := rows.Err(); err != nil { 400 + // Error during rows.Next() or preparing the result set 401 + return nil, fmt.Errorf("error checking for row: %w", err) 402 + } 403 + // No rows found, which is a valid outcome but might be considered an error in some contexts. 404 + // Returning sql.ErrNoRows is conventional. 405 + return nil, sql.ErrNoRows 406 + } 391 407 408 + // Prepare scan arguments: pointers to interface{} slices 409 + values := make([]any, len(cols)) 410 + scanArgs := make([]any, len(cols)) 411 + for i := range values { 412 + scanArgs[i] = &values[i] 413 + } 414 + 415 + // Scan the row values 416 + err = rows.Scan(scanArgs...) 392 417 if err != nil { 393 - return nil, err 418 + return nil, fmt.Errorf("failed to scan row: %w", err) 394 419 } 395 - defer rows.Close() 396 420 397 - var users []*models.User 421 + // Check for errors that might have occurred during iteration (after Scan) 422 + if err := rows.Err(); err != nil { 423 + return nil, fmt.Errorf("error after scanning row: %w", err) 424 + } 398 425 399 - for rows.Next() { 400 - user := &models.User{} 401 - err := rows.Scan( 402 - &user.ID, &user.Username, &user.Email, &user.SpotifyID, 403 - &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 404 - &user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername) 405 - if err != nil { 406 - return nil, err 426 + // Create the result map 427 + resultMap := make(map[string]any, len(cols)) 428 + for i, colName := range cols { 429 + val := values[i] 430 + // SQLite often returns []byte for TEXT columns, convert to string for usability. 431 + // Also handle potential nil values appropriately. 432 + if b, ok := val.([]byte); ok { 433 + resultMap[colName] = string(b) 434 + } else { 435 + resultMap[colName] = val // Keep nil as nil, numbers as numbers, etc. 407 436 } 408 - users = append(users, user) 409 437 } 410 438 411 - return users, nil 439 + return resultMap, nil 440 + } 441 + 442 + func (db *DB) GetLastScrobbleTimestamp(userID int64) (*time.Time, error) { 443 + var lastTimestamp time.Time 444 + err := db.QueryRow(` 445 + SELECT timestamp 446 + FROM tracks 447 + WHERE user_id = ? 448 + ORDER BY timestamp DESC 449 + LIMIT 1`, userID).Scan(&lastTimestamp) 450 + 451 + if err != nil { 452 + if err == sql.ErrNoRows { 453 + return nil, nil 454 + } 455 + return nil, fmt.Errorf("failed to query last scrobble timestamp for user %d: %w", userID, err) 456 + } 457 + 458 + return &lastTimestamp, nil 412 459 }
+29 -5
main.go
··· 146 146 147 147 func handleLinkLastfmForm(database *db.DB) http.HandlerFunc { 148 148 return func(w http.ResponseWriter, r *http.Request) { 149 - userID, _ := session.GetUserID(r.Context()) // Auth middleware ensures this exists 149 + userID, _ := session.GetUserID(r.Context()) 150 + if r.Method == http.MethodPost { 151 + if err := r.ParseForm(); err != nil { 152 + http.Error(w, "Failed to parse form", http.StatusBadRequest) 153 + return 154 + } 155 + 156 + lastfmUsername := r.FormValue("lastfm_username") 157 + if lastfmUsername == "" { 158 + http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest) 159 + return 160 + } 161 + 162 + err := database.AddLastFMUsername(userID, lastfmUsername) 163 + if err != nil { 164 + log.Printf("Error saving Last.fm username for user %d: %v", userID, err) 165 + http.Error(w, "Failed to save Last.fm username", http.StatusInternalServerError) 166 + return 167 + } 168 + 169 + log.Printf("Successfully linked Last.fm username '%s' for user ID %d", lastfmUsername, userID) 170 + 171 + http.Redirect(w, r, "/", http.StatusSeeOther) 172 + } 150 173 151 174 currentUser, err := database.GetUserByID(userID) 152 175 currentUsername := "" ··· 337 360 338 361 mbService := musicbrainz.NewMusicBrainzService(database) 339 362 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService) 340 - lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key")) 363 + lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService) 341 364 342 - sessionManager := session.NewSessionManager() 343 - oauthManager := oauth.NewOAuthServiceManager() 365 + sessionManager := session.NewSessionManager(database) 366 + oauthManager := oauth.NewOAuthServiceManager(sessionManager) 344 367 345 368 spotifyOAuth := oauth.NewOAuth2Service( 346 369 viper.GetString("spotify.client_id"), ··· 369 392 http.HandleFunc("/api-keys", session.WithAuth(apiKeyService.HandleAPIKeyManagement, sessionManager)) 370 393 http.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(database), sessionManager)) // GET form 371 394 http.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(database), sessionManager)) // POST submit - Changed route slightly 372 - http.HandleFunc("/logout", sessionManager.HandleLogout) // Logout doesn't strictly need auth middleware, but handles session deletion 395 + http.HandleFunc("/logout", sessionManager.HandleLogout) 396 + http.HandleFunc("/debug/", session.WithAuth(sessionManager.HandleDebug, sessionManager)) 373 397 374 398 http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager)) // Spotify Current 375 399 http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager)) // Spotify History
+1 -1
models/user.go
··· 5 5 // an end user of piper 6 6 type User struct { 7 7 ID int64 8 - Username string 8 + Username *string 9 9 Email *string 10 10 11 11 // spotify information
+2 -2
oauth/oauth_manager.go
··· 17 17 mu sync.RWMutex 18 18 } 19 19 20 - func NewOAuthServiceManager() *OAuthServiceManager { 20 + func NewOAuthServiceManager(sessionManager *session.SessionManager) *OAuthServiceManager { 21 21 return &OAuthServiceManager{ 22 22 services: make(map[string]AuthService), 23 - sessionManager: session.NewSessionManager(), 23 + sessionManager: sessionManager, 24 24 } 25 25 } 26 26
+194 -31
service/lastfm/lastfm.go
··· 9 9 "net/http" 10 10 "net/url" 11 11 "strconv" 12 + "sync" 12 13 "time" 13 14 14 15 "github.com/teal-fm/piper/db" 16 + "github.com/teal-fm/piper/models" 17 + "github.com/teal-fm/piper/service/musicbrainz" 15 18 "golang.org/x/time/rate" 16 19 ) 17 20 18 21 const ( 19 22 lastfmAPIBaseURL = "https://ws.audioscrobbler.com/2.0/" 20 - defaultLimit = 50 // Default number of tracks to fetch per user 23 + defaultLimit = 1 // Default number of tracks to fetch per user 21 24 ) 22 25 23 26 // Structs to represent the Last.fm API response for user.getrecenttracks ··· 40 43 URL string `json:"url"` 41 44 Date *TrackDate `json:"date,omitempty"` // Use pointer for optional fields 42 45 NowPlaying *struct { // Custom handling for @attr.nowplaying 43 - NowPlaying string `json:"nowplaying"` 44 - } `json:"@attr,omitempty"` 46 + NowPlaying string `json:"nowplaying"` // Field name corrected to match struct tag 47 + } `json:"@attr,omitempty"` // This captures the @attr object within the track 45 48 } 46 49 47 50 type Artist struct { ··· 73 76 } 74 77 75 78 type LastFMService struct { 76 - db *db.DB 77 - httpClient *http.Client 78 - limiter *rate.Limiter 79 - apiKey string 80 - Usernames []string 79 + db *db.DB 80 + httpClient *http.Client 81 + limiter *rate.Limiter 82 + apiKey string 83 + Usernames []string 84 + musicBrainzService *musicbrainz.MusicBrainzService 85 + // Removed in-memory map, assuming DB handles last seen state 86 + // lastSeenTrackDate map[string]time.Time 87 + // mu sync.Mutex // Keep mutex if other shared state is added later 81 88 } 82 89 83 - func NewLastFMService(db *db.DB, apiKey string) *LastFMService { 90 + func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService) *LastFMService { 84 91 return &LastFMService{ 85 92 db: db, 86 93 httpClient: &http.Client{ ··· 90 97 limiter: rate.NewLimiter(rate.Every(200*time.Millisecond), 1), 91 98 apiKey: apiKey, 92 99 Usernames: make([]string, 0), 100 + // lastSeenTrackDate: make(map[string]time.Time), // Removed 101 + musicBrainzService: musicBrainzService, 93 102 } 94 103 } 95 104 ··· 135 144 params.Set("user", username) 136 145 params.Set("api_key", l.apiKey) 137 146 params.Set("format", "json") 138 - params.Set("limit", strconv.Itoa(limit)) 147 + params.Set("limit", strconv.Itoa(limit)) // Fetch a few more to handle duplicates/now playing 139 148 140 149 apiURL := lastfmAPIBaseURL + "?" + params.Encode() 141 150 ··· 157 166 158 167 if resp.StatusCode != http.StatusOK { 159 168 bodyBytes, _ := io.ReadAll(resp.Body) 169 + // Handle specific Last.fm error codes if necessary 170 + // e.g., {"error": 6, "message": "User not found"} 160 171 return nil, fmt.Errorf("last.fm API error for %s: status %d, body: %s", username, resp.StatusCode, string(bodyBytes)) 161 172 } 162 173 163 174 var recentTracksResp RecentTracksResponse 164 - if err := json.NewDecoder(resp.Body).Decode(&recentTracksResp); err != nil { 175 + bodyBytes, err := io.ReadAll(resp.Body) // Read body first for potential decoding errors 176 + if err != nil { 177 + return nil, fmt.Errorf("failed to read response body for %s: %w", username, err) 178 + } 179 + if err := json.Unmarshal(bodyBytes, &recentTracksResp); err != nil { 180 + // Log the body content that failed to decode 181 + log.Printf("Failed to decode response body for %s: %s", username, string(bodyBytes)) 165 182 return nil, fmt.Errorf("failed to decode response for %s: %w", username, err) 166 183 } 167 184 ··· 181 198 func (l *LastFMService) StartListeningTracker(interval time.Duration) { 182 199 if err := l.loadUsernames(); err != nil { 183 200 log.Printf("Failed to perform initial username load: %v", err) 201 + // Decide if we should proceed without initial load or return error 184 202 } 185 203 186 204 if len(l.Usernames) == 0 { 187 - log.Println("No Last.fm users configured! Will start listening tracker anyways.") 205 + log.Println("No Last.fm users configured. Tracker will run but fetch cycles will be skipped until users are added.") 206 + } else { 207 + log.Printf("Found %d Last.fm users.", len(l.Usernames)) 188 208 } 189 209 190 210 ticker := time.NewTicker(interval) 191 211 go func() { 192 - l.fetchAllUserTracks(context.Background()) 212 + // Initial fetch immediately 213 + if len(l.Usernames) > 0 { 214 + l.fetchAllUserTracks(context.Background()) 215 + } else { 216 + log.Println("Skipping initial fetch cycle as no users are configured.") 217 + } 193 218 194 219 for { 195 220 select { ··· 197 222 // refresh usernames periodically from db 198 223 if err := l.loadUsernames(); err != nil { 199 224 log.Printf("Error reloading usernames in ticker: %v", err) 225 + // Continue ticker loop even if reload fails? Or log and potentially stop? 226 + continue // Continue for now 200 227 } 201 228 if len(l.Usernames) > 0 { 202 229 l.fetchAllUserTracks(context.Background()) 203 230 } else { 204 231 log.Println("No Last.fm users configured. Skipping fetch cycle.") 205 232 } 206 - // Add a way to stop the goroutine if needed, e.g., via a context or channel 233 + // TODO: Implement graceful shutdown using context cancellation 207 234 // case <-ctx.Done(): 208 235 // log.Println("Stopping Last.fm listening tracker.") 209 236 // ticker.Stop() ··· 218 245 // fetchAllUserTracks iterates through users and fetches their tracks. 219 246 func (l *LastFMService) fetchAllUserTracks(ctx context.Context) { 220 247 log.Printf("Starting fetch cycle for %d users...", len(l.Usernames)) 248 + var wg sync.WaitGroup // Use WaitGroup to fetch concurrently (optional) 249 + fetchErrors := make(chan error, len(l.Usernames)) // Channel for errors 250 + 221 251 for _, username := range l.Usernames { 222 252 if ctx.Err() != nil { 223 - log.Printf("Context cancelled during fetch cycle for user %s.", username) 224 - return 253 + log.Printf("Context cancelled before starting fetch for user %s.", username) 254 + break // Exit loop if context is cancelled 255 + } 256 + 257 + wg.Add(1) 258 + go func(uname string) { // Launch fetch and process in a goroutine per user 259 + defer wg.Done() 260 + if ctx.Err() != nil { 261 + log.Printf("Context cancelled during fetch cycle for user %s.", uname) 262 + return // Exit goroutine if context is cancelled 263 + } 264 + 265 + // Fetch slightly more than 1 track to better handle edge cases 266 + // where the latest is 'now playing' or duplicates exist. 267 + const fetchLimit = 5 268 + recentTracks, err := l.getRecentTracks(ctx, uname, fetchLimit) 269 + if err != nil { 270 + log.Printf("Error fetching tracks for %s: %v", uname, err) 271 + fetchErrors <- fmt.Errorf("fetch failed for %s: %w", uname, err) // Report error 272 + return 273 + } 274 + 275 + if recentTracks == nil || len(recentTracks.RecentTracks.Tracks) == 0 { 276 + log.Printf("No tracks returned for user %s", uname) 277 + return 278 + } 279 + 280 + // Process the fetched tracks 281 + if err := l.processTracks(uname, recentTracks.RecentTracks.Tracks); err != nil { 282 + log.Printf("Error processing tracks for %s: %v", uname, err) 283 + fetchErrors <- fmt.Errorf("process failed for %s: %w", uname, err) // Report error 284 + } 285 + }(username) 286 + } 287 + 288 + wg.Wait() // Wait for all goroutines to complete 289 + close(fetchErrors) // Close the error channel 290 + 291 + // Log any errors that occurred during the fetch cycle 292 + errorCount := 0 293 + for err := range fetchErrors { 294 + log.Printf("Fetch cycle error: %v", err) 295 + errorCount++ 296 + } 297 + 298 + if errorCount > 0 { 299 + log.Printf("Finished fetch cycle with %d errors.", errorCount) 300 + } else { 301 + log.Println("Finished fetch cycle successfully.") 302 + } 303 + } 304 + 305 + // processTracks processes the fetched tracks for a user, adding new scrobbles to the DB. 306 + func (l *LastFMService) processTracks(username string, tracks []Track) error { 307 + if l.db == nil { 308 + return fmt.Errorf("database connection is nil") 309 + } 310 + 311 + // get uid 312 + user, err := l.db.GetUserByLastFM(username) 313 + if err != nil { 314 + return fmt.Errorf("failed to get user ID for %s: %w", username, err) 315 + } 316 + 317 + lastKnownTimestamp, err := l.db.GetLastScrobbleTimestamp(user.ID) // Hypothetical DB call 318 + if err != nil { 319 + return fmt.Errorf("failed to get last scrobble timestamp for %s: %w", username, err) 320 + } 321 + 322 + found := lastKnownTimestamp == nil 323 + if found { 324 + log.Printf("No previous scrobble timestamp found for user %s. Processing latest track.", username) 325 + } else { 326 + log.Printf("Last known scrobble for %s was at %s", username, lastKnownTimestamp.Format(time.RFC3339)) 327 + } 328 + 329 + processedCount := 0 330 + var latestProcessedTime time.Time 331 + 332 + for i := len(tracks) - 1; i >= 0; i-- { 333 + track := tracks[i] 334 + 335 + // skip now playing 336 + if track.NowPlaying != nil && track.NowPlaying.NowPlaying == "true" { 337 + log.Printf("Skipping 'now playing' track for %s: %s - %s", username, track.Artist.Text, track.Name) 338 + continue 225 339 } 226 - recentTracks, err := l.getRecentTracks(ctx, username, defaultLimit) 340 + 341 + // skip tracks w/out valid date (should be none, but just in case) 342 + if track.Date == nil || track.Date.UTS == "" { 343 + log.Printf("Skipping track without timestamp for %s: %s - %s", username, track.Artist.Text, track.Name) 344 + continue 345 + } 346 + 347 + // parse uts (unix timestamp string) 348 + uts, err := strconv.ParseInt(track.Date.UTS, 10, 64) 227 349 if err != nil { 228 - log.Printf("Error fetching tracks for %s: %v", username, err) 350 + log.Printf("Error parsing timestamp '%s' for track %s - %s: %v", track.Date.UTS, track.Artist.Text, track.Name, err) 229 351 continue 230 352 } 353 + trackTime := time.Unix(uts, 0) 231 354 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) 355 + if lastKnownTimestamp != nil && !trackTime.After(*lastKnownTimestamp) { 356 + if processedCount == 0 { 357 + log.Printf("Reached already known scrobbles for user %s (Track time: %s, Last known: %s).", 358 + username, 359 + trackTime.Format(time.RFC3339), 360 + lastKnownTimestamp.UTC().Format(time.RFC3339)) 361 + } 362 + break 363 + } 236 364 365 + unhydratedArtist := []models.Artist{ 366 + { 367 + Name: track.Artist.Text, 368 + MBID: track.Artist.MBID, 369 + }, 370 + } 371 + 372 + mTrack := models.Track{ 373 + Name: track.Name, 374 + URL: track.URL, 375 + ServiceBaseUrl: "last.fm", 376 + Album: track.Album.Text, 377 + Timestamp: time.Unix(uts, 0), 378 + Artist: unhydratedArtist, 379 + } 380 + 381 + // Fix based on diagnostic: Assume HydrateTrack returns (*models.Track, error) 382 + hydratedTrackPtr, err := musicbrainz.HydrateTrack(l.musicBrainzService, mTrack) 383 + if err != nil { 384 + // Log hydration error specifically 385 + log.Printf("Error hydrating track details for user %s, track %s - %s: %v", username, track.Artist.Text, track.Name, err) 386 + // fallback to original track if hydration fails 387 + hydratedTrackPtr = &mTrack 388 + continue 389 + } 390 + 391 + l.db.SaveTrack(user.ID, hydratedTrackPtr) 392 + 393 + processedCount++ 394 + 395 + if trackTime.After(latestProcessedTime) { 396 + latestProcessedTime = trackTime 397 + } 398 + 399 + if found { 400 + break 401 + } 237 402 } 238 - log.Println("Finished fetch cycle.") 239 - } 240 403 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 - // } 404 + if processedCount > 0 { 405 + log.Printf("Successfully processed %d new track(s) for user %s. Latest timestamp in batch: %s", 406 + processedCount, username, latestProcessedTime.Format(time.RFC3339)) 407 + } 408 + 409 + return nil 410 + }
+62
service/musicbrainz/musicbrainz.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log" 8 9 "net/http" ··· 13 14 "time" 14 15 15 16 "github.com/teal-fm/piper/db" 17 + "github.com/teal-fm/piper/models" 16 18 "golang.org/x/time/rate" 17 19 ) 18 20 ··· 253 255 r := releases[0] 254 256 return &r 255 257 } 258 + 259 + func HydrateTrack(mb *MusicBrainzService, track models.Track) (*models.Track, error) { 260 + ctx := context.Background() 261 + // array of strings 262 + artistArray := make([]string, len(track.Artist)) // Assuming Name is string type 263 + for i, a := range track.Artist { 264 + artistArray[i] = a.Name 265 + } 266 + 267 + params := SearchParams{ 268 + Track: track.Name, 269 + Artist: strings.Join(artistArray, ", "), 270 + Release: track.Album, 271 + } 272 + res, err := mb.SearchMusicBrainz(ctx, params) 273 + if err != nil { 274 + return nil, err 275 + } 276 + 277 + if len(res) == 0 { 278 + return nil, errors.New("no results found") 279 + } 280 + 281 + firstResult := res[0] 282 + firstResultAlbum := GetBestRelease(firstResult.Releases, firstResult.Title) 283 + 284 + bestISRC := firstResult.ISRCs[0] 285 + 286 + if len(firstResult.ISRCs) == 0 { 287 + bestISRC = track.ISRC 288 + } 289 + 290 + artists := make([]models.Artist, len(firstResult.ArtistCredit)) 291 + 292 + for i, a := range firstResult.ArtistCredit { 293 + artists[i] = models.Artist{ 294 + Name: a.Name, 295 + ID: a.Artist.ID, 296 + MBID: a.Artist.ID, 297 + } 298 + } 299 + 300 + resTrack := models.Track{ 301 + HasStamped: track.HasStamped, 302 + PlayID: track.PlayID, 303 + Name: track.Name, 304 + URL: track.URL, 305 + ServiceBaseUrl: track.ServiceBaseUrl, 306 + RecordingMBID: firstResult.ID, 307 + Album: firstResultAlbum.Title, 308 + ReleaseMBID: firstResultAlbum.ID, 309 + ISRC: bestISRC, 310 + Timestamp: track.Timestamp, 311 + ProgressMs: track.ProgressMs, 312 + DurationMs: int64(firstResult.Length), 313 + Artist: artists, 314 + } 315 + 316 + return &resTrack, nil 317 + }
+6 -71
service/spotify/spotify.go
··· 1 1 package spotify 2 2 3 3 import ( 4 - "context" 5 - "encoding/base64" // Added for Basic Auth 4 + "encoding/base64" 6 5 "encoding/json" 7 6 "errors" 8 7 "fmt" 9 8 "io" 10 9 "log" 11 10 "net/http" 12 - "net/url" // Added for request body 13 - 14 - // "strconv" // Removed unused import 15 - "strings" // Added for request body 11 + "net/url" 12 + "strings" 16 13 "sync" 17 14 "time" 18 15 19 - "github.com/spf13/viper" // Added for config access 16 + "github.com/spf13/viper" 20 17 "github.com/teal-fm/piper/db" 21 18 "github.com/teal-fm/piper/models" 22 19 "github.com/teal-fm/piper/oauth/atproto" ··· 89 86 // for now log and continue 90 87 log.Printf("Error updating user token for user ID %d: %v", user.ID, err) 91 88 } else { 92 - log.Printf("Updated token for existing user: %s (ID: %d)", user.Username, user.ID) 89 + log.Printf("Updated token for existing user: %s (ID: %d)", *user.Username, user.ID) 93 90 } 94 91 } 95 92 user.AccessToken = &token ··· 99 96 s.userTokens[user.ID] = token 100 97 s.mu.Unlock() 101 98 102 - log.Printf("User authenticated via Spotify: %s (ID: %d)", user.Username, user.ID) 99 + log.Printf("User authenticated via Spotify: %s (ID: %d)", *user.Username, user.ID) 103 100 return user.ID, nil 104 101 } 105 102 ··· 552 549 553 550 track.PlayID = id 554 551 555 - // Update in memory 556 552 s.mu.Lock() 557 553 s.userTracks[userID] = track 558 554 s.mu.Unlock() ··· 562 558 } 563 559 } 564 560 } 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 - }
+27 -10
session/session.go
··· 4 4 "context" 5 5 "crypto/rand" 6 6 "encoding/base64" 7 + "encoding/json" 8 + "fmt" 7 9 "log" 8 10 "net/http" 9 11 "sync" ··· 31 33 mu sync.RWMutex 32 34 } 33 35 34 - func NewSessionManager() *SessionManager { 35 - database, err := db.New("./data/piper.db") 36 - if err != nil { 37 - log.Printf("Error connecting to database for sessions, falling back to in memory only: %v", err) 38 - // back up to in memory only 39 - return &SessionManager{ 40 - sessions: make(map[string]*Session), 41 - } 42 - } 36 + func NewSessionManager(database *db.DB) *SessionManager { 43 37 44 - _, err = database.Exec(` 38 + _, err := database.Exec(` 45 39 CREATE TABLE IF NOT EXISTS sessions ( 46 40 id TEXT PRIMARY KEY, 47 41 user_id INTEGER NOT NULL, ··· 305 299 306 300 handler(w, r) 307 301 } 302 + } 303 + 304 + func (sm *SessionManager) HandleDebug(w http.ResponseWriter, r *http.Request) { 305 + ctx := r.Context() 306 + userID, ok := GetUserID(ctx) 307 + if !ok { 308 + w.Header().Set("Content-Type", "application/json") 309 + w.WriteHeader(http.StatusUnauthorized) 310 + w.Write([]byte(`{"error": "User ID not found in context"}`)) 311 + return 312 + } 313 + 314 + res, err := sm.db.DebugViewUserInformation(userID) 315 + if err != nil { 316 + w.Header().Set("Content-Type", "application/json") 317 + w.WriteHeader(http.StatusInternalServerError) 318 + w.Write([]byte(fmt.Sprintf(`{"error": "Failed to retrieve user information: %v"}`, err))) 319 + return 320 + } 321 + 322 + w.Header().Set("Content-Type", "application/json") 323 + w.WriteHeader(http.StatusOK) 324 + json.NewEncoder(w).Encode(res) 308 325 } 309 326 310 327 type contextKey int