tangled
alpha
login
or
join now
tom.sherman.is
/
piper
0
fork
atom
A fork of https://github.com/teal-fm/piper
0
fork
atom
overview
issues
pulls
pipelines
add lfm username management
Natalie B
11 months ago
5cba2fc7
e8ef3ae1
+198
-64
2 changed files
expand all
collapse all
unified
split
db
db.go
main.go
+9
db/db.go
···
374
return users, nil
375
}
376
0
0
0
0
0
0
0
0
0
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
···
374
return users, nil
375
}
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
+
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
+189
-64
main.go
···
6
"log"
7
"net/http"
8
"os"
0
9
"time"
10
11
"github.com/spf13/viper"
···
14
"github.com/teal-fm/piper/oauth"
15
"github.com/teal-fm/piper/oauth/atproto"
16
apikeyService "github.com/teal-fm/piper/service/apikey"
17
-
"github.com/teal-fm/piper/service/musicbrainz" // Added musicbrainz service
0
18
"github.com/teal-fm/piper/service/spotify"
19
"github.com/teal-fm/piper/session"
20
)
21
22
-
func home(w http.ResponseWriter, r *http.Request) {
23
-
w.Header().Set("Content-Type", "text/html")
24
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
30
31
-
html := `
0
0
0
0
0
0
0
0
0
0
0
0
0
32
<html>
33
<head>
34
-
<title>Piper - Spotify Tracker</title>
35
<style>
36
body {
37
font-family: Arial, sans-serif;
···
45
}
46
.nav {
47
display: flex;
0
48
margin-bottom: 20px;
49
}
50
.nav a {
51
margin-right: 15px;
0
52
text-decoration: none;
53
color: #1DB954;
54
font-weight: bold;
···
59
padding: 20px;
60
margin-bottom: 20px;
61
}
0
0
0
0
62
</style>
63
</head>
64
<body>
65
-
<h1>Piper - Multi-User Spotify Tracker via ATProto</h1>
66
<div class="nav">
67
<a href="/">Home</a>`
68
69
-
if isLoggedIn {
70
-
html += `
71
-
<a href="/current-track">Current Track</a>
72
-
<a href="/history">Track History</a>
0
0
0
0
0
73
<a href="/api-keys">API Keys</a>
74
-
<a href="/login/spotify">Connect Spotify Account</a> <!-- Link to connect Spotify -->
75
<a href="/logout">Logout</a>`
76
-
} else {
77
-
html += `
78
-
<a href="/login/atproto">Login with ATProto</a>` // Primary login is ATProto
79
-
}
80
81
-
html += `
82
</div>
83
84
<div class="card">
85
<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>`
87
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>
0
0
0
0
0
0
0
0
0
0
0
95
<p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p>`
96
-
}
0
0
0
0
97
98
-
html += `
0
0
99
</div> <!-- Close card div -->
100
</body>
101
</html>
102
-
` // Added closing div tag
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
103
104
-
w.Write([]byte(html))
0
105
}
106
107
// JSON API handlers
···
122
return
123
}
124
125
-
track, err := spotifyService.DB.GetRecentTracks(userID, 1)
126
if err != nil {
127
-
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
0
0
0
0
0
128
return
129
}
130
131
-
jsonResponse(w, http.StatusOK, track)
132
}
133
}
134
···
140
return
141
}
142
0
143
limit := 50 // Default limit
0
0
0
0
0
0
0
0
0
144
tracks, err := spotifyService.DB.GetRecentTracks(userID, limit)
145
if err != nil {
146
-
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
147
return
148
}
149
···
151
}
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"),
···
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
}
···
192
log.Fatalf("Error initializing database: %v", err)
193
}
194
195
-
// init atproto svc
196
jwksBytes, err := os.ReadFile("./jwks.json")
197
if err != nil {
198
log.Fatalf("Error reading JWK file: %v", err)
199
}
200
-
201
jwks, err := atproto.LoadJwks(jwksBytes)
202
if err != nil {
203
log.Fatalf("Error loading JWK: %v", err)
204
}
205
-
206
atprotoService, err := atproto.NewATprotoAuthService(
207
database,
208
jwks,
···
212
if err != nil {
213
log.Fatalf("Error creating ATproto auth service: %v", err)
214
}
215
-
mbService := musicbrainz.NewMusicBrainzService(database)
216
0
217
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService)
0
0
218
sessionManager := session.NewSessionManager()
219
oauthManager := oauth.NewOAuthServiceManager()
220
···
227
spotifyService,
228
)
229
oauthManager.RegisterService("spotify", spotifyOAuth)
230
-
apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager)
231
-
232
oauthManager.RegisterService("atproto", atprotoService)
233
234
-
// Web browser routes
235
-
http.HandleFunc("/", home)
236
237
-
// oauth (scraper) logins
0
0
238
http.HandleFunc("/login/spotify", oauthManager.HandleLogin("spotify"))
239
-
http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager))
240
-
241
-
// atproto login
242
http.HandleFunc("/login/atproto", oauthManager.HandleLogin("atproto"))
243
-
http.HandleFunc("/callback/atproto", oauthManager.HandleCallback("atproto"))
244
0
245
http.HandleFunc("/current-track", session.WithAuth(spotifyService.HandleCurrentTrack, sessionManager))
246
http.HandleFunc("/history", session.WithAuth(spotifyService.HandleTrackHistory, sessionManager))
247
http.HandleFunc("/api-keys", session.WithAuth(apiKeyService.HandleAPIKeyManagement, sessionManager))
248
-
http.HandleFunc("/logout", sessionManager.HandleLogout)
0
0
249
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
254
255
serverUrlRoot := viper.GetString("server.root_url")
256
atpClientId := viper.GetString("atproto.client_id")
257
atpCallbackUrl := viper.GetString("atproto.callback_url")
258
-
259
http.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
260
atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl)
261
})
262
-
263
http.HandleFunc("/oauth/jwks.json", atprotoService.HandleJwks)
264
-
265
trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
0
0
0
0
266
267
if err := spotifyService.LoadAllUsers(); err != nil {
268
-
log.Printf("Warning: Failed to preload users: %v", err)
269
}
0
270
271
-
go spotifyService.StartListeningTracker(trackerInterval)
272
273
serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port"))
274
fmt.Printf("Server running at: http://%s\n", serverAddr)
···
6
"log"
7
"net/http"
8
"os"
9
+
"strconv"
10
"time"
11
12
"github.com/spf13/viper"
···
15
"github.com/teal-fm/piper/oauth"
16
"github.com/teal-fm/piper/oauth/atproto"
17
apikeyService "github.com/teal-fm/piper/service/apikey"
18
+
"github.com/teal-fm/piper/service/lastfm"
19
+
"github.com/teal-fm/piper/service/musicbrainz"
20
"github.com/teal-fm/piper/service/spotify"
21
"github.com/teal-fm/piper/session"
22
)
23
24
+
func home(database *db.DB) http.HandlerFunc {
25
+
return func(w http.ResponseWriter, r *http.Request) {
26
27
+
w.Header().Set("Content-Type", "text/html")
0
0
0
0
28
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 := `
43
<html>
44
<head>
45
+
<title>Piper - Spotify & Last.fm Tracker</title>
46
<style>
47
body {
48
font-family: Arial, sans-serif;
···
56
}
57
.nav {
58
display: flex;
59
+
flex-wrap: wrap; /* Allow wrapping on smaller screens */
60
margin-bottom: 20px;
61
}
62
.nav a {
63
margin-right: 15px;
64
+
margin-bottom: 5px; /* Add spacing below links */
65
text-decoration: none;
66
color: #1DB954;
67
font-weight: bold;
···
72
padding: 20px;
73
margin-bottom: 20px;
74
}
75
+
.service-status {
76
+
font-style: italic;
77
+
color: #555;
78
+
}
79
</style>
80
</head>
81
<body>
82
+
<h1>Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1>
83
<div class="nav">
84
<a href="/">Home</a>`
85
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 += `
95
<a href="/api-keys">API Keys</a>
96
+
<a href="/login/spotify">Connect Spotify Account</a>
97
<a href="/logout">Logout</a>`
98
+
} else {
99
+
html += `
100
+
<a href="/login/atproto">Login with ATProto</a>`
101
+
}
102
103
+
html += `
104
</div>
105
106
<div class="card">
107
<h2>Welcome to Piper</h2>
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>`
109
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>
128
<p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p>`
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
+
}
134
135
+
}
136
+
137
+
html += `
138
</div> <!-- Close card div -->
139
</body>
140
</html>
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)
215
216
+
http.Redirect(w, r, "/", http.StatusSeeOther)
217
+
}
218
}
219
220
// JSON API handlers
···
235
return
236
}
237
238
+
tracks, err := spotifyService.DB.GetRecentTracks(userID, 1)
239
if err != nil {
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)
246
return
247
}
248
249
+
jsonResponse(w, http.StatusOK, tracks[0])
250
}
251
}
252
···
258
return
259
}
260
261
+
limitStr := r.URL.Query().Get("limit")
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
+
272
tracks, err := spotifyService.DB.GetRecentTracks(userID, limit)
273
if err != nil {
274
+
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get track history: " + err.Error()})
275
return
276
}
277
···
279
}
280
}
281
0
282
func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc {
283
return func(w http.ResponseWriter, r *http.Request) {
0
284
285
params := musicbrainz.SearchParams{
286
Track: r.URL.Query().Get("track"),
···
300
return
301
}
302
0
0
303
jsonResponse(w, http.StatusOK, recordings)
304
}
305
}
···
316
log.Fatalf("Error initializing database: %v", err)
317
}
318
319
+
// --- Service Initializations ---
320
jwksBytes, err := os.ReadFile("./jwks.json")
321
if err != nil {
322
log.Fatalf("Error reading JWK file: %v", err)
323
}
0
324
jwks, err := atproto.LoadJwks(jwksBytes)
325
if err != nil {
326
log.Fatalf("Error loading JWK: %v", err)
327
}
0
328
atprotoService, err := atproto.NewATprotoAuthService(
329
database,
330
jwks,
···
334
if err != nil {
335
log.Fatalf("Error creating ATproto auth service: %v", err)
336
}
0
337
338
+
mbService := musicbrainz.NewMusicBrainzService(database)
339
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService)
340
+
lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"))
341
+
342
sessionManager := session.NewSessionManager()
343
oauthManager := oauth.NewOAuthServiceManager()
344
···
351
spotifyService,
352
)
353
oauthManager.RegisterService("spotify", spotifyOAuth)
0
0
354
oauthManager.RegisterService("atproto", atprotoService)
355
356
+
apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager)
0
357
358
+
http.HandleFunc("/", session.WithPossibleAuth(home(database), sessionManager))
359
+
360
+
// OAuth Routes
361
http.HandleFunc("/login/spotify", oauthManager.HandleLogin("spotify"))
362
+
http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager)) // Use possible auth
0
0
363
http.HandleFunc("/login/atproto", oauthManager.HandleLogin("atproto"))
364
+
http.HandleFunc("/callback/atproto", session.WithPossibleAuth(oauthManager.HandleCallback("atproto"), sessionManager)) // Use possible auth
365
366
+
// Authenticated Web Routes
367
http.HandleFunc("/current-track", session.WithAuth(spotifyService.HandleCurrentTrack, sessionManager))
368
http.HandleFunc("/history", session.WithAuth(spotifyService.HandleTrackHistory, sessionManager))
369
http.HandleFunc("/api-keys", session.WithAuth(apiKeyService.HandleAPIKeyManagement, sessionManager))
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
373
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?)
0
377
378
serverUrlRoot := viper.GetString("server.root_url")
379
atpClientId := viper.GetString("atproto.client_id")
380
atpCallbackUrl := viper.GetString("atproto.callback_url")
0
381
http.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
382
atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl)
383
})
0
384
http.HandleFunc("/oauth/jwks.json", atprotoService.HandleJwks)
0
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
+
}
390
391
if err := spotifyService.LoadAllUsers(); err != nil {
392
+
log.Printf("Warning: Failed to preload Spotify users: %v", err)
393
}
394
+
go spotifyService.StartListeningTracker(trackerInterval)
395
396
+
go lastfmService.StartListeningTracker(lastfmInterval)
397
398
serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port"))
399
fmt.Printf("Server running at: http://%s\n", serverAddr)