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
374
return users, nil
375
375
}
376
376
377
377
+
func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error {
378
378
+
_, err := db.Exec(`
379
379
+
UPDATE users
380
380
+
SET lastfm_username = ?
381
381
+
WHERE user_id = ?`, lastfmUsername, userID)
382
382
+
383
383
+
return err
384
384
+
}
385
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
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
17
-
"github.com/teal-fm/piper/service/musicbrainz" // Added musicbrainz service
18
18
+
"github.com/teal-fm/piper/service/lastfm"
19
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
22
-
func home(w http.ResponseWriter, r *http.Request) {
23
23
-
w.Header().Set("Content-Type", "text/html")
24
24
+
func home(database *db.DB) http.HandlerFunc {
25
25
+
return func(w http.ResponseWriter, r *http.Request) {
24
26
25
25
-
// check if user has an active session cookie
26
26
-
cookie, err := r.Cookie("session")
27
27
-
isLoggedIn := err == nil && cookie != nil
28
28
-
// TODO: add logic here to fetch user details from DB using session ID
29
29
-
// to check if Spotify is already connected
27
27
+
w.Header().Set("Content-Type", "text/html")
30
28
31
31
-
html := `
29
29
+
userID, authenticated := session.GetUserID(r.Context())
30
30
+
isLoggedIn := authenticated
31
31
+
lastfmUsername := ""
32
32
+
33
33
+
if isLoggedIn {
34
34
+
user, err := database.GetUserByID(userID)
35
35
+
if err == nil && user != nil && user.LastFMUsername != nil {
36
36
+
lastfmUsername = *user.LastFMUsername
37
37
+
} else if err != nil {
38
38
+
log.Printf("Error fetching user %d details for home page: %v", userID, err)
39
39
+
}
40
40
+
}
41
41
+
42
42
+
html := `
32
43
<html>
33
44
<head>
34
34
-
<title>Piper - Spotify Tracker</title>
45
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
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
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
75
+
.service-status {
76
76
+
font-style: italic;
77
77
+
color: #555;
78
78
+
}
62
79
</style>
63
80
</head>
64
81
<body>
65
65
-
<h1>Piper - Multi-User Spotify Tracker via ATProto</h1>
82
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
69
-
if isLoggedIn {
70
70
-
html += `
71
71
-
<a href="/current-track">Current Track</a>
72
72
-
<a href="/history">Track History</a>
86
86
+
if isLoggedIn {
87
87
+
html += `
88
88
+
<a href="/current-track">Spotify Current</a>
89
89
+
<a href="/history">Spotify History</a>
90
90
+
<a href="/link-lastfm">Link Last.fm</a>` // Link to Last.fm page
91
91
+
if lastfmUsername != "" {
92
92
+
html += ` <a href="/lastfm/recent">Last.fm Recent</a>` // Show only if linked
93
93
+
}
94
94
+
html += `
73
95
<a href="/api-keys">API Keys</a>
74
74
-
<a href="/login/spotify">Connect Spotify Account</a> <!-- Link to connect Spotify -->
96
96
+
<a href="/login/spotify">Connect Spotify Account</a>
75
97
<a href="/logout">Logout</a>`
76
76
-
} else {
77
77
-
html += `
78
78
-
<a href="/login/atproto">Login with ATProto</a>` // Primary login is ATProto
79
79
-
}
98
98
+
} else {
99
99
+
html += `
100
100
+
<a href="/login/atproto">Login with ATProto</a>`
101
101
+
}
80
102
81
81
-
html += `
103
103
+
html += `
82
104
</div>
83
105
84
106
<div class="card">
85
107
<h2>Welcome to Piper</h2>
86
86
-
<p>Piper is a multi-user Spotify tracking application that records what you're listening to and saves your listening history.</p>`
108
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
88
-
if !isLoggedIn {
89
89
-
html += `
90
90
-
<p><a href="/login/atproto">Login with ATProto</a> to get started!</p>` // Prompt to login via ATProto
91
91
-
} else {
92
92
-
html += `
93
93
-
<p>You're logged in! <a href="/login/spotify">Connect your Spotify account</a> to start tracking.</p>
94
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
110
+
if !isLoggedIn {
111
111
+
html += `
112
112
+
<p><a href="/login/atproto">Login with ATProto</a> to get started!</p>`
113
113
+
} else {
114
114
+
html += `
115
115
+
<p>You're logged in!</p>
116
116
+
<ul>
117
117
+
<li><a href="/login/spotify">Connect your Spotify account</a> to start tracking.</li>
118
118
+
<li><a href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li>
119
119
+
</ul>
120
120
+
<p>Once connected, you can check out your:</p>
121
121
+
<ul>
122
122
+
<li><a href="/current-track">Spotify current track</a> or <a href="/history">listening history</a>.</li>`
123
123
+
if lastfmUsername != "" {
124
124
+
html += `<li><a href="/lastfm/recent">Last.fm recent tracks</a>.</li>`
125
125
+
}
126
126
+
html += `
127
127
+
</ul>
95
128
<p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p>`
96
96
-
}
129
129
+
if lastfmUsername != "" {
130
130
+
html += fmt.Sprintf("<p class='service-status'>Last.fm Username: %s</p>", lastfmUsername)
131
131
+
} else {
132
132
+
html += "<p class='service-status'>Last.fm account not linked.</p>"
133
133
+
}
97
134
98
98
-
html += `
135
135
+
}
136
136
+
137
137
+
html += `
99
138
</div> <!-- Close card div -->
100
139
</body>
101
140
</html>
102
102
-
` // Added closing div tag
141
141
+
`
142
142
+
143
143
+
w.Write([]byte(html))
144
144
+
}
145
145
+
}
146
146
+
147
147
+
func handleLinkLastfmForm(database *db.DB) http.HandlerFunc {
148
148
+
return func(w http.ResponseWriter, r *http.Request) {
149
149
+
userID, _ := session.GetUserID(r.Context()) // Auth middleware ensures this exists
150
150
+
151
151
+
currentUser, err := database.GetUserByID(userID)
152
152
+
currentUsername := ""
153
153
+
if err == nil && currentUser != nil && currentUser.LastFMUsername != nil {
154
154
+
currentUsername = *currentUser.LastFMUsername
155
155
+
} else if err != nil {
156
156
+
log.Printf("Error fetching user %d for Last.fm form: %v", userID, err)
157
157
+
// Don't fail, just show an empty form
158
158
+
}
159
159
+
160
160
+
w.Header().Set("Content-Type", "text/html")
161
161
+
fmt.Fprintf(w, `
162
162
+
<html>
163
163
+
<head><title>Link Last.fm Account</title>
164
164
+
<style>
165
165
+
body { font-family: Arial, sans-serif; max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
166
166
+
label, input { display: block; margin-bottom: 10px; }
167
167
+
input[type='text'] { width: 95%%; padding: 8px; } /* Corrected width */
168
168
+
input[type='submit'] { padding: 10px 15px; background-color: #d51007; color: white; border: none; border-radius: 4px; cursor: pointer; }
169
169
+
.nav { margin-bottom: 20px; }
170
170
+
.nav a { margin-right: 10px; text-decoration: none; color: #1DB954; font-weight: bold; }
171
171
+
.error { color: red; margin-bottom: 10px; }
172
172
+
</style>
173
173
+
</head>
174
174
+
<body>
175
175
+
<div class="nav">
176
176
+
<a href="/">Home</a>
177
177
+
<a href="/link-lastfm">Link Last.fm</a>
178
178
+
<a href="/logout">Logout</a>
179
179
+
</div>
180
180
+
<h2>Link Your Last.fm Account</h2>
181
181
+
<p>Enter your Last.fm username to start tracking your scrobbles.</p>
182
182
+
<form method="post" action="/link-lastfm">
183
183
+
<label for="lastfm_username">Last.fm Username:</label>
184
184
+
<input type="text" id="lastfm_username" name="lastfm_username" value="%s" required>
185
185
+
<input type="submit" value="Save Username">
186
186
+
</form>
187
187
+
</body>
188
188
+
</html>`, currentUsername)
189
189
+
}
190
190
+
}
191
191
+
192
192
+
func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc {
193
193
+
return func(w http.ResponseWriter, r *http.Request) {
194
194
+
userID, _ := session.GetUserID(r.Context()) // Auth middleware ensures this exists
195
195
+
196
196
+
if err := r.ParseForm(); err != nil {
197
197
+
http.Error(w, "Failed to parse form", http.StatusBadRequest)
198
198
+
return
199
199
+
}
200
200
+
201
201
+
lastfmUsername := r.FormValue("lastfm_username")
202
202
+
if lastfmUsername == "" {
203
203
+
http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest)
204
204
+
return
205
205
+
}
206
206
+
207
207
+
err := database.AddLastFMUsername(userID, lastfmUsername)
208
208
+
if err != nil {
209
209
+
log.Printf("Error saving Last.fm username for user %d: %v", userID, err)
210
210
+
http.Error(w, "Failed to save Last.fm username", http.StatusInternalServerError)
211
211
+
return
212
212
+
}
213
213
+
214
214
+
log.Printf("Successfully linked Last.fm username '%s' for user ID %d", lastfmUsername, userID)
103
215
104
104
-
w.Write([]byte(html))
216
216
+
http.Redirect(w, r, "/", http.StatusSeeOther)
217
217
+
}
105
218
}
106
219
107
220
// JSON API handlers
···
122
235
return
123
236
}
124
237
125
125
-
track, err := spotifyService.DB.GetRecentTracks(userID, 1)
238
238
+
tracks, err := spotifyService.DB.GetRecentTracks(userID, 1)
126
239
if err != nil {
127
127
-
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
240
240
+
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get current track: " + err.Error()})
241
241
+
return
242
242
+
}
243
243
+
244
244
+
if len(tracks) == 0 {
245
245
+
jsonResponse(w, http.StatusOK, nil)
128
246
return
129
247
}
130
248
131
131
-
jsonResponse(w, http.StatusOK, track)
249
249
+
jsonResponse(w, http.StatusOK, tracks[0])
132
250
}
133
251
}
134
252
···
140
258
return
141
259
}
142
260
261
261
+
limitStr := r.URL.Query().Get("limit")
143
262
limit := 50 // Default limit
263
263
+
if limitStr != "" {
264
264
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
265
265
+
limit = l
266
266
+
}
267
267
+
}
268
268
+
if limit > 200 {
269
269
+
limit = 200
270
270
+
}
271
271
+
144
272
tracks, err := spotifyService.DB.GetRecentTracks(userID, limit)
145
273
if err != nil {
146
146
-
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
274
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
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
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
177
-
// Optionally process recordings (e.g., select best release) before responding
178
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
195
-
// init atproto svc
319
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
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
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
215
-
mbService := musicbrainz.NewMusicBrainzService(database)
216
337
338
338
+
mbService := musicbrainz.NewMusicBrainzService(database)
217
339
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService)
340
340
+
lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"))
341
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
230
-
apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager)
231
231
-
232
354
oauthManager.RegisterService("atproto", atprotoService)
233
355
234
234
-
// Web browser routes
235
235
-
http.HandleFunc("/", home)
356
356
+
apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager)
236
357
237
237
-
// oauth (scraper) logins
358
358
+
http.HandleFunc("/", session.WithPossibleAuth(home(database), sessionManager))
359
359
+
360
360
+
// OAuth Routes
238
361
http.HandleFunc("/login/spotify", oauthManager.HandleLogin("spotify"))
239
239
-
http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager))
240
240
-
241
241
-
// atproto login
362
362
+
http.HandleFunc("/callback/spotify", session.WithPossibleAuth(oauthManager.HandleCallback("spotify"), sessionManager)) // Use possible auth
242
363
http.HandleFunc("/login/atproto", oauthManager.HandleLogin("atproto"))
243
243
-
http.HandleFunc("/callback/atproto", oauthManager.HandleCallback("atproto"))
364
364
+
http.HandleFunc("/callback/atproto", session.WithPossibleAuth(oauthManager.HandleCallback("atproto"), sessionManager)) // Use possible auth
244
365
366
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
248
-
http.HandleFunc("/logout", sessionManager.HandleLogout)
370
370
+
http.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(database), sessionManager)) // GET form
371
371
+
http.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(database), sessionManager)) // POST submit - Changed route slightly
372
372
+
http.HandleFunc("/logout", sessionManager.HandleLogout) // Logout doesn't strictly need auth middleware, but handles session deletion
249
373
250
250
-
// API routes
251
251
-
http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager))
252
252
-
http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager))
253
253
-
http.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(mbService)) // Added MusicBrainz search endpoint
374
374
+
http.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(spotifyService), sessionManager)) // Spotify Current
375
375
+
http.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(spotifyService), sessionManager)) // Spotify History
376
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
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
262
-
263
384
http.HandleFunc("/oauth/jwks.json", atprotoService.HandleJwks)
264
264
-
265
385
trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
386
386
+
lastfmInterval := time.Duration(viper.GetInt("lastfm.interval_seconds")) * time.Second // Add config for Last.fm interval
387
387
+
if lastfmInterval <= 0 {
388
388
+
lastfmInterval = 1 * time.Minute
389
389
+
}
266
390
267
391
if err := spotifyService.LoadAllUsers(); err != nil {
268
268
-
log.Printf("Warning: Failed to preload users: %v", err)
392
392
+
log.Printf("Warning: Failed to preload Spotify users: %v", err)
269
393
}
394
394
+
go spotifyService.StartListeningTracker(trackerInterval)
270
395
271
271
-
go spotifyService.StartListeningTracker(trackerInterval)
396
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)