A fork of https://github.com/teal-fm/piper

add lfm username management

Natalie B 5cba2fc7 e8ef3ae1

+198 -64
+9
db/db.go
··· 374 374 return users, nil 375 375 } 376 376 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) 382 + 383 + return err 384 + } 385 + 377 386 func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { 378 387 rows, err := db.Query(` 379 388 SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at, lastfm_username
+189 -64
main.go
··· 6 6 "log" 7 7 "net/http" 8 8 "os" 9 + "strconv" 9 10 "time" 10 11 11 12 "github.com/spf13/viper" ··· 14 15 "github.com/teal-fm/piper/oauth" 15 16 "github.com/teal-fm/piper/oauth/atproto" 16 17 apikeyService "github.com/teal-fm/piper/service/apikey" 17 - "github.com/teal-fm/piper/service/musicbrainz" // Added musicbrainz service 18 + "github.com/teal-fm/piper/service/lastfm" 19 + "github.com/teal-fm/piper/service/musicbrainz" 18 20 "github.com/teal-fm/piper/service/spotify" 19 21 "github.com/teal-fm/piper/session" 20 22 ) 21 23 22 - func home(w http.ResponseWriter, r *http.Request) { 23 - w.Header().Set("Content-Type", "text/html") 24 + func home(database *db.DB) http.HandlerFunc { 25 + return func(w http.ResponseWriter, r *http.Request) { 24 26 25 - // check if user has an active session cookie 26 - cookie, err := r.Cookie("session") 27 - isLoggedIn := err == nil && cookie != nil 28 - // TODO: add logic here to fetch user details from DB using session ID 29 - // to check if Spotify is already connected 27 + w.Header().Set("Content-Type", "text/html") 30 28 31 - html := ` 29 + userID, authenticated := session.GetUserID(r.Context()) 30 + isLoggedIn := authenticated 31 + lastfmUsername := "" 32 + 33 + if isLoggedIn { 34 + user, err := database.GetUserByID(userID) 35 + if err == nil && user != nil && user.LastFMUsername != nil { 36 + lastfmUsername = *user.LastFMUsername 37 + } else if err != nil { 38 + log.Printf("Error fetching user %d details for home page: %v", userID, err) 39 + } 40 + } 41 + 42 + html := ` 32 43 <html> 33 44 <head> 34 - <title>Piper - Spotify Tracker</title> 45 + <title>Piper - Spotify & Last.fm Tracker</title> 35 46 <style> 36 47 body { 37 48 font-family: Arial, sans-serif; ··· 45 56 } 46 57 .nav { 47 58 display: flex; 59 + flex-wrap: wrap; /* Allow wrapping on smaller screens */ 48 60 margin-bottom: 20px; 49 61 } 50 62 .nav a { 51 63 margin-right: 15px; 64 + margin-bottom: 5px; /* Add spacing below links */ 52 65 text-decoration: none; 53 66 color: #1DB954; 54 67 font-weight: bold; ··· 59 72 padding: 20px; 60 73 margin-bottom: 20px; 61 74 } 75 + .service-status { 76 + font-style: italic; 77 + color: #555; 78 + } 62 79 </style> 63 80 </head> 64 81 <body> 65 - <h1>Piper - Multi-User Spotify Tracker via ATProto</h1> 82 + <h1>Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 66 83 <div class="nav"> 67 84 <a href="/">Home</a>` 68 85 69 - if isLoggedIn { 70 - html += ` 71 - <a href="/current-track">Current Track</a> 72 - <a href="/history">Track History</a> 86 + if isLoggedIn { 87 + html += ` 88 + <a href="/current-track">Spotify Current</a> 89 + <a href="/history">Spotify History</a> 90 + <a href="/link-lastfm">Link Last.fm</a>` // Link to Last.fm page 91 + if lastfmUsername != "" { 92 + html += ` <a href="/lastfm/recent">Last.fm Recent</a>` // Show only if linked 93 + } 94 + html += ` 73 95 <a href="/api-keys">API Keys</a> 74 - <a href="/login/spotify">Connect Spotify Account</a> <!-- Link to connect Spotify --> 96 + <a href="/login/spotify">Connect Spotify Account</a> 75 97 <a href="/logout">Logout</a>` 76 - } else { 77 - html += ` 78 - <a href="/login/atproto">Login with ATProto</a>` // Primary login is ATProto 79 - } 98 + } else { 99 + html += ` 100 + <a href="/login/atproto">Login with ATProto</a>` 101 + } 80 102 81 - html += ` 103 + html += ` 82 104 </div> 83 105 84 106 <div class="card"> 85 107 <h2>Welcome to Piper</h2> 86 - <p>Piper is a multi-user Spotify tracking application that records what you're listening to and saves your listening history.</p>` 108 + <p>Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p>` 87 109 88 - if !isLoggedIn { 89 - html += ` 90 - <p><a href="/login/atproto">Login with ATProto</a> to get started!</p>` // Prompt to login via ATProto 91 - } else { 92 - html += ` 93 - <p>You're logged in! <a href="/login/spotify">Connect your Spotify account</a> to start tracking.</p> 94 - <p>Once connected, you can check out your <a href="/current-track">current track</a> or view your <a href="/history">listening history</a>.</p> 110 + if !isLoggedIn { 111 + html += ` 112 + <p><a href="/login/atproto">Login with ATProto</a> to get started!</p>` 113 + } else { 114 + html += ` 115 + <p>You're logged in!</p> 116 + <ul> 117 + <li><a href="/login/spotify">Connect your Spotify account</a> to start tracking.</li> 118 + <li><a href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li> 119 + </ul> 120 + <p>Once connected, you can check out your:</p> 121 + <ul> 122 + <li><a href="/current-track">Spotify current track</a> or <a href="/history">listening history</a>.</li>` 123 + if lastfmUsername != "" { 124 + html += `<li><a href="/lastfm/recent">Last.fm recent tracks</a>.</li>` 125 + } 126 + html += ` 127 + </ul> 95 128 <p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p>` 96 - } 129 + if lastfmUsername != "" { 130 + html += fmt.Sprintf("<p class='service-status'>Last.fm Username: %s</p>", lastfmUsername) 131 + } else { 132 + html += "<p class='service-status'>Last.fm account not linked.</p>" 133 + } 97 134 98 - html += ` 135 + } 136 + 137 + html += ` 99 138 </div> <!-- Close card div --> 100 139 </body> 101 140 </html> 102 - ` // Added closing div tag 141 + ` 142 + 143 + w.Write([]byte(html)) 144 + } 145 + } 146 + 147 + func handleLinkLastfmForm(database *db.DB) http.HandlerFunc { 148 + return func(w http.ResponseWriter, r *http.Request) { 149 + userID, _ := session.GetUserID(r.Context()) // Auth middleware ensures this exists 150 + 151 + currentUser, err := database.GetUserByID(userID) 152 + currentUsername := "" 153 + if err == nil && currentUser != nil && currentUser.LastFMUsername != nil { 154 + currentUsername = *currentUser.LastFMUsername 155 + } else if err != nil { 156 + log.Printf("Error fetching user %d for Last.fm form: %v", userID, err) 157 + // Don't fail, just show an empty form 158 + } 159 + 160 + w.Header().Set("Content-Type", "text/html") 161 + fmt.Fprintf(w, ` 162 + <html> 163 + <head><title>Link Last.fm Account</title> 164 + <style> 165 + body { font-family: Arial, sans-serif; max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; } 166 + label, input { display: block; margin-bottom: 10px; } 167 + input[type='text'] { width: 95%%; padding: 8px; } /* Corrected width */ 168 + input[type='submit'] { padding: 10px 15px; background-color: #d51007; color: white; border: none; border-radius: 4px; cursor: pointer; } 169 + .nav { margin-bottom: 20px; } 170 + .nav a { margin-right: 10px; text-decoration: none; color: #1DB954; font-weight: bold; } 171 + .error { color: red; margin-bottom: 10px; } 172 + </style> 173 + </head> 174 + <body> 175 + <div class="nav"> 176 + <a href="/">Home</a> 177 + <a href="/link-lastfm">Link Last.fm</a> 178 + <a href="/logout">Logout</a> 179 + </div> 180 + <h2>Link Your Last.fm Account</h2> 181 + <p>Enter your Last.fm username to start tracking your scrobbles.</p> 182 + <form method="post" action="/link-lastfm"> 183 + <label for="lastfm_username">Last.fm Username:</label> 184 + <input type="text" id="lastfm_username" name="lastfm_username" value="%s" required> 185 + <input type="submit" value="Save Username"> 186 + </form> 187 + </body> 188 + </html>`, currentUsername) 189 + } 190 + } 191 + 192 + func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc { 193 + return func(w http.ResponseWriter, r *http.Request) { 194 + userID, _ := session.GetUserID(r.Context()) // Auth middleware ensures this exists 195 + 196 + if err := r.ParseForm(); err != nil { 197 + http.Error(w, "Failed to parse form", http.StatusBadRequest) 198 + return 199 + } 200 + 201 + lastfmUsername := r.FormValue("lastfm_username") 202 + if lastfmUsername == "" { 203 + http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest) 204 + return 205 + } 206 + 207 + err := database.AddLastFMUsername(userID, lastfmUsername) 208 + if err != nil { 209 + log.Printf("Error saving Last.fm username for user %d: %v", userID, err) 210 + http.Error(w, "Failed to save Last.fm username", http.StatusInternalServerError) 211 + return 212 + } 213 + 214 + log.Printf("Successfully linked Last.fm username '%s' for user ID %d", lastfmUsername, userID) 103 215 104 - w.Write([]byte(html)) 216 + http.Redirect(w, r, "/", http.StatusSeeOther) 217 + } 105 218 } 106 219 107 220 // JSON API handlers ··· 122 235 return 123 236 } 124 237 125 - track, err := spotifyService.DB.GetRecentTracks(userID, 1) 238 + tracks, err := spotifyService.DB.GetRecentTracks(userID, 1) 126 239 if err != nil { 127 - jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) 240 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get current track: " + err.Error()}) 241 + return 242 + } 243 + 244 + if len(tracks) == 0 { 245 + jsonResponse(w, http.StatusOK, nil) 128 246 return 129 247 } 130 248 131 - jsonResponse(w, http.StatusOK, track) 249 + jsonResponse(w, http.StatusOK, tracks[0]) 132 250 } 133 251 } 134 252 ··· 140 258 return 141 259 } 142 260 261 + limitStr := r.URL.Query().Get("limit") 143 262 limit := 50 // Default limit 263 + if limitStr != "" { 264 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { 265 + limit = l 266 + } 267 + } 268 + if limit > 200 { 269 + limit = 200 270 + } 271 + 144 272 tracks, err := spotifyService.DB.GetRecentTracks(userID, limit) 145 273 if err != nil { 146 - jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) 274 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get track history: " + err.Error()}) 147 275 return 148 276 } 149 277 ··· 151 279 } 152 280 } 153 281 154 - // apiMusicBrainzSearch handles requests to the MusicBrainz search API. 155 282 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 156 283 return func(w http.ResponseWriter, r *http.Request) { 157 - // Optional: Add authentication/rate limiting if needed 158 284 159 285 params := musicbrainz.SearchParams{ 160 286 Track: r.URL.Query().Get("track"), ··· 174 300 return 175 301 } 176 302 177 - // Optionally process recordings (e.g., select best release) before responding 178 - // For now, just return the raw results 179 303 jsonResponse(w, http.StatusOK, recordings) 180 304 } 181 305 } ··· 192 316 log.Fatalf("Error initializing database: %v", err) 193 317 } 194 318 195 - // init atproto svc 319 + // --- Service Initializations --- 196 320 jwksBytes, err := os.ReadFile("./jwks.json") 197 321 if err != nil { 198 322 log.Fatalf("Error reading JWK file: %v", err) 199 323 } 200 - 201 324 jwks, err := atproto.LoadJwks(jwksBytes) 202 325 if err != nil { 203 326 log.Fatalf("Error loading JWK: %v", err) 204 327 } 205 - 206 328 atprotoService, err := atproto.NewATprotoAuthService( 207 329 database, 208 330 jwks, ··· 212 334 if err != nil { 213 335 log.Fatalf("Error creating ATproto auth service: %v", err) 214 336 } 215 - mbService := musicbrainz.NewMusicBrainzService(database) 216 337 338 + mbService := musicbrainz.NewMusicBrainzService(database) 217 339 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService) 340 + lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key")) 341 + 218 342 sessionManager := session.NewSessionManager() 219 343 oauthManager := oauth.NewOAuthServiceManager() 220 344 ··· 227 351 spotifyService, 228 352 ) 229 353 oauthManager.RegisterService("spotify", spotifyOAuth) 230 - apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 231 - 232 354 oauthManager.RegisterService("atproto", atprotoService) 233 355 234 - // Web browser routes 235 - http.HandleFunc("/", home) 356 + apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 236 357 237 - // oauth (scraper) logins 358 + http.HandleFunc("/", session.WithPossibleAuth(home(database), sessionManager)) 359 + 360 + // OAuth Routes 238 361 http.HandleFunc("/login/spotify", oauthManager.HandleLogin("spotify")) 239 - http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager)) 240 - 241 - // atproto login 362 + http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager)) // Use possible auth 242 363 http.HandleFunc("/login/atproto", oauthManager.HandleLogin("atproto")) 243 - http.HandleFunc("/callback/atproto", oauthManager.HandleCallback("atproto")) 364 + http.HandleFunc("/callback/atproto", session.WithPossibleAuth(oauthManager.HandleCallback("atproto"), sessionManager)) // Use possible auth 244 365 366 + // Authenticated Web Routes 245 367 http.HandleFunc("/current-track", session.WithAuth(spotifyService.HandleCurrentTrack, sessionManager)) 246 368 http.HandleFunc("/history", session.WithAuth(spotifyService.HandleTrackHistory, sessionManager)) 247 369 http.HandleFunc("/api-keys", session.WithAuth(apiKeyService.HandleAPIKeyManagement, sessionManager)) 248 - http.HandleFunc("/logout", sessionManager.HandleLogout) 370 + http.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(database), sessionManager)) // GET form 371 + 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 249 373 250 - // API routes 251 - http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager)) 252 - http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager)) 253 - http.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(mbService)) // Added MusicBrainz search endpoint 374 + http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager)) // Spotify Current 375 + http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager)) // Spotify History 376 + http.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(mbService)) // MusicBrainz (public?) 254 377 255 378 serverUrlRoot := viper.GetString("server.root_url") 256 379 atpClientId := viper.GetString("atproto.client_id") 257 380 atpCallbackUrl := viper.GetString("atproto.callback_url") 258 - 259 381 http.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 260 382 atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl) 261 383 }) 262 - 263 384 http.HandleFunc("/oauth/jwks.json", atprotoService.HandleJwks) 264 - 265 385 trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second 386 + lastfmInterval := time.Duration(viper.GetInt("lastfm.interval_seconds")) * time.Second // Add config for Last.fm interval 387 + if lastfmInterval <= 0 { 388 + lastfmInterval = 1 * time.Minute 389 + } 266 390 267 391 if err := spotifyService.LoadAllUsers(); err != nil { 268 - log.Printf("Warning: Failed to preload users: %v", err) 392 + log.Printf("Warning: Failed to preload Spotify users: %v", err) 269 393 } 394 + go spotifyService.StartListeningTracker(trackerInterval) 270 395 271 - go spotifyService.StartListeningTracker(trackerInterval) 396 + go lastfmService.StartListeningTracker(lastfmInterval) 272 397 273 398 serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port")) 274 399 fmt.Printf("Server running at: http://%s\n", serverAddr)