+8
.dockerignore
+8
.dockerignore
+3
-1
.gitignore
+3
-1
.gitignore
+21
LICENSE
+21
LICENSE
···
1
+
MIT License
2
+
3
+
Copyright (c) 2025 teal computing, LLC
4
+
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy
6
+
of this software and associated documentation files (the "Software"), to deal
7
+
in the Software without restriction, including without limitation the rights
8
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+
copies of the Software, and to permit persons to whom the Software is
10
+
furnished to do so, subject to the following conditions:
11
+
12
+
The above copyright notice and this permission notice shall be included in all
13
+
copies or substantial portions of the Software.
14
+
15
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+
SOFTWARE.
+10
-4
README.md
+10
-4
README.md
···
1
-
A fork of https://github.com/teal-fm/piper
2
-
3
1
# piper
4
2
5
3
#### what is piper?
···
45
43
- `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email`
46
44
- `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify`
47
45
48
-
- `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/.well-known/client-metadata.json`
49
-
- `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/.well-known/client-metadata.json`
46
+
- `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
47
+
- `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
50
48
- `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto`
51
49
52
50
- `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api)
53
51
54
52
- `TRACKER_INTERVAL` - How long between checks to see if the registered users are listening to new music
55
53
- `DB_PATH`= Path for the sqlite db. If you are using the docker compose probably want `/db/piper.db` to persist data
54
+
55
+
##### apple music
56
+
57
+
requires an apple developer account
58
+
59
+
- `APPLE_MUSIC_TEAM_ID` - Your Apple Developer Account's Team ID, found at `Membership Details` [here](https://developer.apple.com/account)
60
+
- `APPLE_MUSIC_KEY_ID` - Your Key ID from the key you made in [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/authkeys/list). You'll need to make a Media ID [here](https://developer.apple.com/account/resources/identifiers/list), then link a new key for MediaKit [there](https://developer.apple.com/account/resources/authkeys/list) to your new identifier. Download the private key and save the Key ID here.
61
+
- `APPLE_MUSIC_PRIVATE_KEY_PATH` - The path to said private key as mentioned above.
56
62
57
63
## development
58
64
+81
cmd/handlers.go
+81
cmd/handlers.go
···
12
12
"github.com/teal-fm/piper/models"
13
13
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
14
14
pages "github.com/teal-fm/piper/pages"
15
+
"github.com/teal-fm/piper/service/applemusic"
15
16
atprotoservice "github.com/teal-fm/piper/service/atproto"
16
17
"github.com/teal-fm/piper/service/musicbrainz"
17
18
"github.com/teal-fm/piper/service/playingnow"
···
135
136
136
137
http.Redirect(w, r, "/", http.StatusSeeOther)
137
138
}
139
+
}
140
+
141
+
func handleAppleMusicLink(pg *pages.Pages, am *applemusic.Service) http.HandlerFunc {
142
+
return func(w http.ResponseWriter, r *http.Request) {
143
+
w.Header().Set("Content-Type", "text/html")
144
+
devToken, _, errTok := am.GenerateDeveloperToken()
145
+
if errTok != nil {
146
+
log.Printf("Error generating Apple Music developer token: %v", errTok)
147
+
http.Error(w, "Failed to prepare Apple Music", http.StatusInternalServerError)
148
+
return
149
+
}
150
+
data := struct{
151
+
NavBar pages.NavBar
152
+
DevToken string
153
+
}{DevToken: devToken}
154
+
err := pg.Execute("applemusic_link", w, data)
155
+
if err != nil {
156
+
log.Printf("Error executing template: %v", err)
157
+
}
158
+
}
138
159
}
139
160
140
161
func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc {
···
251
272
"lastfm_username": lastfmUsername,
252
273
"spotify_connected": spotifyConnected,
253
274
}
275
+
// do not send Apple token value; just whether present
276
+
response["applemusic_linked"] = (user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "")
254
277
if user.LastFMUsername == nil {
255
278
response["lastfm_username"] = nil
256
279
}
···
323
346
log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID)
324
347
jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"})
325
348
}
349
+
}
350
+
351
+
// apiAppleMusicAuthorize stores a MusicKit user token for the current user
352
+
func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc {
353
+
return func(w http.ResponseWriter, r *http.Request) {
354
+
userID, authenticated := session.GetUserID(r.Context())
355
+
if !authenticated {
356
+
jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
357
+
return
358
+
}
359
+
if r.Method != http.MethodPost {
360
+
jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
361
+
return
362
+
}
363
+
364
+
var req struct {
365
+
UserToken string `json:"userToken"`
366
+
}
367
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
368
+
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
369
+
return
370
+
}
371
+
if req.UserToken == "" {
372
+
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "userToken is required"})
373
+
return
374
+
}
375
+
376
+
if err := database.UpdateAppleMusicUserToken(userID, req.UserToken); err != nil {
377
+
log.Printf("apiAppleMusicAuthorize: failed to save token for user %d: %v", userID, err)
378
+
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save token"})
379
+
return
380
+
}
381
+
382
+
jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
383
+
}
384
+
}
385
+
386
+
// apiAppleMusicUnlink clears the MusicKit user token for the current user
387
+
func apiAppleMusicUnlink(database *db.DB) http.HandlerFunc {
388
+
return func(w http.ResponseWriter, r *http.Request) {
389
+
userID, authenticated := session.GetUserID(r.Context())
390
+
if !authenticated {
391
+
jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
392
+
return
393
+
}
394
+
if r.Method != http.MethodPost {
395
+
jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
396
+
return
397
+
}
398
+
399
+
if err := database.ClearAppleMusicUserToken(userID); err != nil {
400
+
log.Printf("apiAppleMusicUnlink: failed to clear token for user %d: %v", userID, err)
401
+
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Apple Music"})
402
+
return
403
+
}
404
+
405
+
jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
406
+
}
326
407
}
327
408
328
409
// apiSubmitListensHandler handles ListenBrainz-compatible submissions
+41
-1
cmd/main.go
+41
-1
cmd/main.go
···
7
7
"net/http"
8
8
"time"
9
9
10
+
"github.com/teal-fm/piper/service/applemusic"
10
11
"github.com/teal-fm/piper/service/lastfm"
11
12
"github.com/teal-fm/piper/service/playingnow"
12
13
···
31
32
mbService *musicbrainz.MusicBrainzService
32
33
atprotoService *atproto.ATprotoAuthService
33
34
playingNowService *playingnow.PlayingNowService
35
+
appleMusicService *applemusic.Service
34
36
pages *pages.Pages
35
37
}
36
38
···
87
89
playingNowService := playingnow.NewPlayingNowService(database, atprotoService)
88
90
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService)
89
91
lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService)
92
+
// Read Apple Music settings with env fallbacks
93
+
teamID := viper.GetString("applemusic.team_id")
94
+
if teamID == "" {
95
+
teamID = viper.GetString("APPLE_MUSIC_TEAM_ID")
96
+
}
97
+
keyID := viper.GetString("applemusic.key_id")
98
+
if keyID == "" {
99
+
keyID = viper.GetString("APPLE_MUSIC_KEY_ID")
100
+
}
101
+
keyPath := viper.GetString("applemusic.private_key_path")
102
+
if keyPath == "" {
103
+
keyPath = viper.GetString("APPLE_MUSIC_PRIVATE_KEY_PATH")
104
+
}
105
+
106
+
var appleMusicService *applemusic.Service
107
+
// Only initialize Apple Music service if all required credentials are present
108
+
if teamID != "" && keyID != "" && keyPath != "" {
109
+
appleMusicService = applemusic.NewService(
110
+
teamID,
111
+
keyID,
112
+
keyPath,
113
+
).WithPersistence(
114
+
func() (string, time.Time, bool, error) {
115
+
return database.GetAppleMusicDeveloperToken()
116
+
},
117
+
func(token string, exp time.Time) error {
118
+
return database.SaveAppleMusicDeveloperToken(token, exp)
119
+
},
120
+
).WithDeps(database, atprotoService, mbService, playingNowService)
121
+
} else {
122
+
log.Println("Apple Music credentials not configured (missing team_id, key_id, or private_key_path). Apple Music features will be disabled.")
123
+
}
90
124
91
125
oauthManager := oauth.NewOAuthServiceManager()
92
126
···
112
146
spotifyService: spotifyService,
113
147
atprotoService: atprotoService,
114
148
playingNowService: playingNowService,
149
+
appleMusicService: appleMusicService,
115
150
pages: pages.NewPages(),
116
151
}
117
152
···
123
158
124
159
go spotifyService.StartListeningTracker(trackerInterval)
125
160
126
-
go lastfmService.StartListeningTracker(lastfmInterval)
161
+
go lastfmService.StartListeningTracker(lastfmInterval)
162
+
// Apple Music tracker uses same tracker.interval as Spotify for now
163
+
// Only start if Apple Music service is configured
164
+
if appleMusicService != nil {
165
+
go appleMusicService.StartListeningTracker(trackerInterval)
166
+
}
127
167
128
168
serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port"))
129
169
server := &http.Server{
+5
cmd/routes.go
+5
cmd/routes.go
···
28
28
mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager))
29
29
mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form
30
30
mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly
31
+
mux.HandleFunc("/link-applemusic", session.WithAuth(handleAppleMusicLink(app.pages, app.appleMusicService), app.sessionManager))
31
32
mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto"))
32
33
mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager))
33
34
···
38
39
mux.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(app.spotifyService), app.sessionManager)) // Spotify Current
39
40
mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History
40
41
mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?)
42
+
43
+
// Apple Music user authorization (protected with session auth)
44
+
mux.HandleFunc("/api/v1/applemusic/authorize", session.WithAuth(apiAppleMusicAuthorize(app.database), app.sessionManager))
45
+
mux.HandleFunc("/api/v1/applemusic/unlink", session.WithAuth(apiAppleMusicUnlink(app.database), app.sessionManager))
41
46
42
47
// ListenBrainz-compatible endpoint
43
48
mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
+10
config/config.go
+10
config/config.go
···
23
23
viper.SetDefault("tracker.interval", 30)
24
24
viper.SetDefault("db.path", "./data/piper.db")
25
25
26
+
// Apple Music defaults
27
+
viper.SetDefault("applemusic.team_id", "")
28
+
viper.SetDefault("applemusic.key_id", "")
29
+
viper.SetDefault("applemusic.private_key_path", "./AM_AUTHKEY.p8")
30
+
26
31
// server metadata
27
32
viper.SetDefault("server.root_url", "http://localhost:8080")
28
33
viper.SetDefault("atproto.metadata_url", "http://localhost:8080/metadata")
29
34
viper.SetDefault("atproto.callback_url", "/metadata")
30
35
31
36
viper.AutomaticEnv()
37
+
38
+
// Support APPLE_MUSIC_* env var aliases
39
+
_ = viper.BindEnv("applemusic.team_id", "APPLE_MUSIC_TEAM_ID")
40
+
_ = viper.BindEnv("applemusic.key_id", "APPLE_MUSIC_KEY_ID")
41
+
_ = viper.BindEnv("applemusic.private_key_path", "APPLE_MUSIC_PRIVATE_KEY_PATH")
32
42
33
43
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
34
44
+129
-29
db/db.go
+129
-29
db/db.go
···
20
20
21
21
func New(dbPath string) (*DB, error) {
22
22
dir := filepath.Dir(dbPath)
23
-
if dir != "." && dir != "/" {
24
-
os.MkdirAll(dir, 755)
25
-
}
23
+
if dir != "." && dir != "/" {
24
+
os.MkdirAll(dir, 0755)
25
+
}
26
26
27
27
db, err := sql.Open("sqlite3", dbPath)
28
28
if err != nil {
···
50
50
access_token TEXT, -- Spotify access token
51
51
refresh_token TEXT, -- Spotify refresh token
52
52
token_expiry TIMESTAMP, -- Spotify token expiry
53
-
lastfm_username TEXT, -- Last.fm username
53
+
lastfm_username TEXT, -- Last.fm username
54
+
applemusic_user_token TEXT, -- Apple Music MusicKit user token
54
55
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default
55
56
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default
56
57
)`)
57
58
if err != nil {
59
+
return err
60
+
}
61
+
62
+
// Add missing columns to users table if they don't exist
63
+
_, err = db.Exec(`ALTER TABLE users ADD COLUMN applemusic_user_token TEXT`)
64
+
if err != nil && err.Error() != "duplicate column name: applemusic_user_token" {
58
65
return err
59
66
}
60
67
···
141
148
return nil
142
149
}
143
150
151
+
// Apple Music developer token persistence
152
+
func (db *DB) ensureAppleMusicTokenTable() error {
153
+
_, err := db.Exec(`
154
+
CREATE TABLE IF NOT EXISTS applemusic_token (
155
+
token TEXT,
156
+
expires_at TIMESTAMP
157
+
)`)
158
+
return err
159
+
}
160
+
161
+
func (db *DB) GetAppleMusicDeveloperToken() (string, time.Time, bool, error) {
162
+
if err := db.ensureAppleMusicTokenTable(); err != nil {
163
+
return "", time.Time{}, false, err
164
+
}
165
+
var token string
166
+
var exp time.Time
167
+
err := db.QueryRow(`SELECT token, expires_at FROM applemusic_token LIMIT 1`).Scan(&token, &exp)
168
+
if err == sql.ErrNoRows {
169
+
return "", time.Time{}, false, nil
170
+
}
171
+
if err != nil {
172
+
return "", time.Time{}, false, err
173
+
}
174
+
return token, exp, true, nil
175
+
}
176
+
177
+
func (db *DB) SaveAppleMusicDeveloperToken(token string, exp time.Time) error {
178
+
if err := db.ensureAppleMusicTokenTable(); err != nil {
179
+
return err
180
+
}
181
+
// Replace existing single row
182
+
_, err := db.Exec(`DELETE FROM applemusic_token`)
183
+
if err != nil {
184
+
return err
185
+
}
186
+
_, err = db.Exec(`INSERT INTO applemusic_token (token, expires_at) VALUES (?, ?)`, token, exp)
187
+
return err
188
+
}
189
+
144
190
// create user without spotify id
145
191
func (db *DB) CreateUser(user *models.User) (int64, error) {
146
192
now := time.Now().UTC()
···
181
227
func (db *DB) GetUserByID(ID int64) (*models.User, error) {
182
228
user := &models.User{}
183
229
184
-
err := db.QueryRow(`
185
-
SELECT id,
186
-
username,
187
-
email,
188
-
atproto_did,
189
-
most_recent_at_session_id,
190
-
spotify_id,
191
-
access_token,
192
-
refresh_token,
193
-
token_expiry,
194
-
lastfm_username,
195
-
created_at,
196
-
updated_at
197
-
FROM users WHERE id = ?`, ID).Scan(
198
-
&user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID,
199
-
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
200
-
&user.LastFMUsername,
201
-
&user.CreatedAt, &user.UpdatedAt)
230
+
err := db.QueryRow(`
231
+
SELECT id,
232
+
username,
233
+
email,
234
+
atproto_did,
235
+
most_recent_at_session_id,
236
+
spotify_id,
237
+
access_token,
238
+
refresh_token,
239
+
token_expiry,
240
+
lastfm_username,
241
+
applemusic_user_token,
242
+
created_at,
243
+
updated_at
244
+
FROM users WHERE id = ?`, ID).Scan(
245
+
&user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID,
246
+
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
247
+
&user.LastFMUsername, &user.AppleMusicUserToken,
248
+
&user.CreatedAt, &user.UpdatedAt)
202
249
203
250
if err == sql.ErrNoRows {
204
251
return nil, nil
···
214
261
func (db *DB) GetUserBySpotifyID(spotifyID string) (*models.User, error) {
215
262
user := &models.User{}
216
263
217
-
err := db.QueryRow(`
218
-
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at
219
-
FROM users WHERE spotify_id = ?`, spotifyID).Scan(
220
-
&user.ID, &user.Username, &user.Email, &user.SpotifyID,
221
-
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
222
-
&user.LastFMUsername,
223
-
&user.CreatedAt, &user.UpdatedAt)
264
+
err := db.QueryRow(`
265
+
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, applemusic_user_token, created_at, updated_at
266
+
FROM users WHERE spotify_id = ?`, spotifyID).Scan(
267
+
&user.ID, &user.Username, &user.Email, &user.SpotifyID,
268
+
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
269
+
&user.LastFMUsername, &user.AppleMusicUserToken,
270
+
&user.CreatedAt, &user.UpdatedAt)
224
271
225
272
if err == sql.ErrNoRows {
226
273
return nil, nil
···
243
290
accessToken, refreshToken, expiry, now, userID)
244
291
245
292
return err
293
+
}
294
+
295
+
func (db *DB) UpdateAppleMusicUserToken(userID int64, userToken string) error {
296
+
now := time.Now().UTC()
297
+
_, err := db.Exec(`
298
+
UPDATE users
299
+
SET applemusic_user_token = ?, updated_at = ?
300
+
WHERE id = ?`,
301
+
userToken, now, userID)
302
+
return err
303
+
}
304
+
305
+
// ClearAppleMusicUserToken removes the stored Apple Music user token for a user
306
+
func (db *DB) ClearAppleMusicUserToken(userID int64) error {
307
+
now := time.Now().UTC()
308
+
_, err := db.Exec(`
309
+
UPDATE users
310
+
SET applemusic_user_token = NULL, updated_at = ?
311
+
WHERE id = ?`,
312
+
now, userID)
313
+
return err
314
+
}
315
+
316
+
// GetAllAppleMusicLinkedUsers returns users who have an Apple Music user token set
317
+
func (db *DB) GetAllAppleMusicLinkedUsers() ([]*models.User, error) {
318
+
rows, err := db.Query(`
319
+
SELECT id, username, email, atproto_did, most_recent_at_session_id,
320
+
spotify_id, access_token, refresh_token, token_expiry,
321
+
lastfm_username, applemusic_user_token, created_at, updated_at
322
+
FROM users
323
+
WHERE applemusic_user_token IS NOT NULL AND applemusic_user_token != ''
324
+
ORDER BY id`)
325
+
if err != nil {
326
+
return nil, err
327
+
}
328
+
defer rows.Close()
329
+
330
+
var users []*models.User
331
+
for rows.Next() {
332
+
u := &models.User{}
333
+
if err := rows.Scan(
334
+
&u.ID, &u.Username, &u.Email, &u.ATProtoDID, &u.MostRecentAtProtoSessionID,
335
+
&u.SpotifyID, &u.AccessToken, &u.RefreshToken, &u.TokenExpiry,
336
+
&u.LastFMUsername, &u.AppleMusicUserToken, &u.CreatedAt, &u.UpdatedAt,
337
+
); err != nil {
338
+
return nil, err
339
+
}
340
+
users = append(users, u)
341
+
}
342
+
if err := rows.Err(); err != nil {
343
+
return nil, err
344
+
}
345
+
return users, nil
246
346
}
247
347
248
348
func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) {
+42
fly.toml
+42
fly.toml
···
1
+
# fly.toml app configuration file generated for atpiper on 2025-11-07T17:20:14Z
2
+
#
3
+
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+
#
5
+
6
+
app = 'atpiper'
7
+
primary_region = 'lhr'
8
+
9
+
10
+
[env]
11
+
SERVER_PORT = "8080"
12
+
SERVER_HOST = "0.0.0.0"
13
+
DB_PATH = "/data/piper.db"
14
+
SERVER_ROOT_URL = "https://atpiper.fly.dev"
15
+
16
+
ATPROTO_CLIENT_ID = "https://atpiper.fly.dev/oauth-client-metadata.json"
17
+
ATPROTO_METADATA_URL = "https://atpiper.fly.dev/oauth-client-metadata.json"
18
+
ATPROTO_CALLBACK_URL = "https://atpiper.fly.dev/callback/atproto"
19
+
20
+
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
21
+
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
22
+
SPOTIFY_SCOPES = "user-read-currently-playing user-read-email"
23
+
CALLBACK_SPOTIFY = "https://atpiper.fly.dev/callback/spotify"
24
+
25
+
[build]
26
+
27
+
[http_service]
28
+
internal_port = 8080
29
+
force_https = true
30
+
auto_stop_machines = 'off'
31
+
auto_start_machines = true
32
+
min_machines_running = 1
33
+
processes = ['app']
34
+
35
+
[[vm]]
36
+
memory = '512mb'
37
+
cpu_kind = 'shared'
38
+
cpus = 1
39
+
40
+
[mounts]
41
+
source = "piper_data"
42
+
destination = "/data"
+3
models/user.go
+3
models/user.go
+150
pages/templates/applemusic_link.gohtml
+150
pages/templates/applemusic_link.gohtml
···
1
+
{{ define "applemusic_link" }}
2
+
{{ template "layouts/base" . }}
3
+
{{ end }}
4
+
5
+
{{ define "layouts/base" }}
6
+
<!DOCTYPE html>
7
+
<html>
8
+
<head>
9
+
<meta charset="utf-8" />
10
+
<title>Link Apple Music</title>
11
+
<link rel="stylesheet" href="/static/main.css" />
12
+
<script
13
+
src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"
14
+
data-web-components
15
+
async
16
+
></script>
17
+
</head>
18
+
<body>
19
+
<main style="max-width: 720px; margin: 2rem auto">
20
+
<h1>Link Apple Music</h1>
21
+
<p>Authorize with Apple Music to enable MusicKit features.</p>
22
+
<div
23
+
style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap"
24
+
>
25
+
<button id="authorizeBtn">Authorize Apple Music</button>
26
+
<br />
27
+
<form
28
+
id="unlinkForm"
29
+
method="post"
30
+
action="/api/v1/applemusic/unlink"
31
+
onsubmit="handleUnlink(event)"
32
+
>
33
+
<button type="submit">Unlink Apple Music</button>
34
+
</form>
35
+
</div>
36
+
<pre id="status" style="margin-top: 1rem"></pre>
37
+
</main>
38
+
<script>
39
+
async function saveUserToken(userToken) {
40
+
const res = await fetch("/api/v1/applemusic/authorize", {
41
+
method: "POST",
42
+
headers: { "Content-Type": "application/json" },
43
+
credentials: "same-origin",
44
+
body: JSON.stringify({ userToken }),
45
+
});
46
+
if (!res.ok) {
47
+
const text = await res.text().catch(() => "");
48
+
console.error("Saving user token failed", {
49
+
status: res.status,
50
+
body: text,
51
+
});
52
+
throw new Error(`Failed to save user token (status ${res.status})`);
53
+
}
54
+
}
55
+
56
+
async function setup() {
57
+
const status = document.getElementById("status");
58
+
try {
59
+
// Use server-provided developer token (not user-facing)
60
+
const devToken = "{{.DevToken}}";
61
+
// wait for musickit to be loaded
62
+
if (!window.MusicKit) {
63
+
await new Promise((resolve) => {
64
+
document.addEventListener("musickitloaded", resolve, {
65
+
once: true,
66
+
});
67
+
});
68
+
}
69
+
try {
70
+
window.MusicKit.configure({
71
+
developerToken: devToken,
72
+
app: { name: "Piper", build: "1.0.0" },
73
+
});
74
+
} catch (cfgErr) {
75
+
console.error("MusicKit.configure failed", cfgErr);
76
+
throw cfgErr;
77
+
}
78
+
const music = window.MusicKit.getInstance();
79
+
async function handleUnlink(event) {
80
+
event.preventDefault();
81
+
if (!confirm("Unlink Apple Music from your account?")) {
82
+
return;
83
+
}
84
+
try {
85
+
const music = window.MusicKit.getInstance();
86
+
await music.unauthorize();
87
+
document.getElementById("unlinkForm").submit();
88
+
} catch (e) {
89
+
console.error("Error unauthorizing:", e);
90
+
status.textContent =
91
+
"Error unauthorizing: " +
92
+
(e && e.message ? e.message : String(e));
93
+
}
94
+
}
95
+
try {
96
+
music.addEventListener("authorizationStatusDidChange", (e) => {
97
+
console.debug(
98
+
"authorizationStatusDidChange",
99
+
e && e.authorizationStatus
100
+
);
101
+
});
102
+
music.addEventListener("userTokenDidChange", (e) => {
103
+
console.debug("userTokenDidChange", !!(e && e.userToken));
104
+
});
105
+
music.addEventListener("playbackStateDidChange", (e) => {
106
+
console.debug("playbackStateDidChange", e && e.state);
107
+
});
108
+
music.addEventListener("mediaPlayerPlaybackError", (e) => {
109
+
console.error("mediaPlayerPlaybackError", e);
110
+
});
111
+
} catch (evtErr) {
112
+
console.warn(
113
+
"Failed to attach some MusicKit event listeners",
114
+
evtErr
115
+
);
116
+
}
117
+
document
118
+
.getElementById("authorizeBtn")
119
+
.addEventListener("click", async () => {
120
+
try {
121
+
const music = window.MusicKit.getInstance();
122
+
const userToken = await music.authorize();
123
+
await saveUserToken(userToken);
124
+
status.textContent = "Authorized and saved.";
125
+
} catch (e) {
126
+
console.error("Authorization failed", e);
127
+
status.textContent =
128
+
"Authorization failed: " +
129
+
(e && e.message ? e.message : String(e));
130
+
}
131
+
});
132
+
} catch (e) {
133
+
console.error("MusicKit setup failed", e);
134
+
status.textContent =
135
+
"Setup failed: " + (e && e.message ? e.message : String(e));
136
+
}
137
+
}
138
+
// Global error hooks for extra diagnostics
139
+
window.addEventListener("error", (ev) => {
140
+
console.error("Window error", ev && ev.error ? ev.error : ev);
141
+
});
142
+
window.addEventListener("unhandledrejection", (ev) => {
143
+
console.error("Unhandled rejection", ev && ev.reason ? ev.reason : ev);
144
+
});
145
+
146
+
setup();
147
+
</script>
148
+
</body>
149
+
</html>
150
+
{{ end }}
+83
-39
pages/templates/home.gohtml
+83
-39
pages/templates/home.gohtml
···
1
-
2
1
{{ define "content" }}
3
2
4
-
<h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1>
5
-
{{ template "components/navBar" .NavBar }}
3
+
<h1 class="text-[#1DB954]">
4
+
Piper - Multi-User Spotify & Last.fm Tracker via ATProto
5
+
</h1>
6
+
{{ template "components/navBar" .NavBar }}
6
7
8
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
9
+
<h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2>
10
+
<p class="mb-3">
11
+
Piper is a multi-user application that records what you're listening to on
12
+
Spotify and Last.fm, saving your listening history.
13
+
</p>
7
14
8
-
<div class="border border-gray-300 rounded-lg p-5 mb-5">
9
-
<h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2>
10
-
<p class="mb-3">Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p>
11
-
12
-
{{if .NavBar.IsLoggedIn}}
13
-
<p class="mb-2">You're logged in!</p>
14
-
<ul class="list-disc pl-5 mb-3">
15
-
<li><a class="text-[#1DB954] font-bold" href="/login/spotify">Connect your Spotify account</a> to start tracking.</li>
16
-
<li><a class="text-[#1DB954] font-bold" href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li>
17
-
</ul>
18
-
<p class="mb-2">Once connected, you can check out your:</p>
19
-
<ul class="list-disc pl-5 mb-3">
20
-
<li><a class="text-[#1DB954] font-bold" href="/current-track">Spotify current track</a> or <a class="text-[#1DB954] font-bold" href="/history">listening history</a>.</li>
21
-
{{ if .NavBar.LastFMUsername }}
22
-
<li><a class="text-[#1DB954] font-bold" href="/lastfm/recent">Last.fm recent tracks</a>.</li>
23
-
{{ end }}
24
-
25
-
</ul>
26
-
<p class="mb-3">You can also manage your <a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for programmatic access.</p>
27
-
28
-
{{ if .NavBar.LastFMUsername }}
29
-
<p class='italic text-gray-600'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p>
30
-
{{else }}
31
-
<p class='italic text-gray-600'>Last.fm account not linked.</p>
32
-
{{end}}
33
-
15
+
{{if .NavBar.IsLoggedIn}}
16
+
<p class="mb-2">You're logged in!</p>
17
+
<ul class="list-disc pl-5 mb-3">
18
+
<li>
19
+
<a class="text-[#1DB954] font-bold" href="/login/spotify"
20
+
>Connect your Spotify account</a
21
+
>
22
+
to start tracking.
23
+
</li>
24
+
<li>
25
+
<a class="text-[#1DB954] font-bold" href="/link-lastfm"
26
+
>Link your Last.fm account</a
27
+
>
28
+
to track scrobbles.
29
+
</li>
30
+
<li>
31
+
<a class="text-[#1DB954] font-bold" href="/link-applemusic"
32
+
>Link your Apple Music account</a
33
+
>
34
+
to fetch recently played.
35
+
</li>
36
+
</ul>
37
+
<p class="mb-2">Once connected, you can check out your:</p>
38
+
<ul class="list-disc pl-5 mb-3">
39
+
<li>
40
+
<a class="text-[#1DB954] font-bold" href="/current-track"
41
+
>Spotify current track</a
42
+
>
43
+
or
44
+
<a class="text-[#1DB954] font-bold" href="/history">listening history</a>.
45
+
</li>
46
+
{{ if .NavBar.LastFMUsername }}
47
+
<li>
48
+
<a class="text-[#1DB954] font-bold" href="/lastfm/recent"
49
+
>Last.fm recent tracks</a
50
+
>.
51
+
</li>
52
+
{{
53
+
end
54
+
}}
55
+
</ul>
56
+
<p class="mb-3">
57
+
You can also manage your
58
+
<a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for
59
+
programmatic access.
60
+
</p>
34
61
35
-
{{ else }}
62
+
{{ if .NavBar.LastFMUsername }}
63
+
<p class="italic text-gray-600">
64
+
Last.fm Username: {{ .NavBar.LastFMUsername }}
65
+
</p>
66
+
{{else }}
67
+
<p class="italic text-gray-600">Last.fm account not linked.</p>
68
+
{{ end }}
36
69
37
-
<p class="mb-3">Login with ATProto to get started!</p>
38
-
<form class="space-y-2" action="/login/atproto">
39
-
<label class="block" for="handle">handle:</label>
40
-
<input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="handle" name="handle" >
41
-
<input class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="submit">
42
-
</form>
70
+
{{ else }}
43
71
72
+
<p class="mb-3">Login with ATProto to get started!</p>
73
+
<form class="space-y-2" action="/login/atproto">
74
+
<label class="block" for="handle">handle:</label>
75
+
<input
76
+
class="block w-[95%] p-2 border border-gray-300 rounded"
77
+
type="text"
78
+
id="handle"
79
+
name="handle"
80
+
/>
81
+
<input
82
+
class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90"
83
+
type="submit"
84
+
value="submit"
85
+
/>
86
+
</form>
44
87
45
-
{{ end }}
46
-
</div> <!-- Close card div -->
88
+
{{ end }}
89
+
</div>
90
+
<!-- Close card div -->
47
91
48
-
{{ end }}
92
+
{{ end }}
+493
service/applemusic/applemusic.go
+493
service/applemusic/applemusic.go
···
1
+
package applemusic
2
+
3
+
import (
4
+
"context"
5
+
"crypto/ecdsa"
6
+
"crypto/x509"
7
+
"encoding/json"
8
+
"encoding/pem"
9
+
"errors"
10
+
"fmt"
11
+
"io"
12
+
"log"
13
+
"net/http"
14
+
"net/url"
15
+
"os"
16
+
"strings"
17
+
"sync"
18
+
"time"
19
+
20
+
"github.com/lestrrat-go/jwx/v2/jwa"
21
+
"github.com/lestrrat-go/jwx/v2/jws"
22
+
"github.com/lestrrat-go/jwx/v2/jwt"
23
+
"github.com/teal-fm/piper/db"
24
+
"github.com/teal-fm/piper/models"
25
+
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
26
+
atprotoservice "github.com/teal-fm/piper/service/atproto"
27
+
"github.com/teal-fm/piper/service/musicbrainz"
28
+
)
29
+
30
+
type Service struct {
31
+
teamID string
32
+
keyID string
33
+
privateKeyPath string
34
+
35
+
mu sync.RWMutex
36
+
cachedToken string
37
+
cachedExpiry time.Time
38
+
39
+
// optional DB-backed persistence
40
+
getToken func() (string, time.Time, bool, error)
41
+
saveToken func(string, time.Time) error
42
+
43
+
// ingestion deps
44
+
DB *db.DB
45
+
atprotoService *atprotoauth.ATprotoAuthService
46
+
mbService *musicbrainz.MusicBrainzService
47
+
playingNowService interface {
48
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
49
+
ClearPlayingNow(ctx context.Context, userID int64) error
50
+
}
51
+
httpClient *http.Client
52
+
logger *log.Logger
53
+
}
54
+
55
+
func NewService(teamID, keyID, privateKeyPath string) *Service {
56
+
return &Service{
57
+
teamID: teamID,
58
+
keyID: keyID,
59
+
privateKeyPath: privateKeyPath,
60
+
httpClient: &http.Client{Timeout: 10 * time.Second},
61
+
logger: log.New(os.Stdout, "applemusic: ", log.LstdFlags|log.Lmsgprefix),
62
+
}
63
+
}
64
+
65
+
// WithPersistence wires DB-backed getters/setters for token caching
66
+
func (s *Service) WithPersistence(
67
+
get func() (string, time.Time, bool, error),
68
+
save func(string, time.Time) error,
69
+
) *Service {
70
+
s.getToken = get
71
+
s.saveToken = save
72
+
return s
73
+
}
74
+
75
+
// WithDeps wires services needed for ingestion
76
+
func (s *Service) WithDeps(database *db.DB, atproto *atprotoauth.ATprotoAuthService, mb *musicbrainz.MusicBrainzService, playingNowService interface {
77
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
78
+
ClearPlayingNow(ctx context.Context, userID int64) error
79
+
}) *Service {
80
+
s.DB = database
81
+
s.atprotoService = atproto
82
+
s.mbService = mb
83
+
s.playingNowService = playingNowService
84
+
return s
85
+
}
86
+
87
+
func (s *Service) HandleDeveloperToken(w http.ResponseWriter, r *http.Request) {
88
+
force := r.URL.Query().Get("refresh") == "1"
89
+
token, exp, err := s.GenerateDeveloperTokenWithForce(force)
90
+
if err != nil {
91
+
http.Error(w, fmt.Sprintf("failed to generate token: %v", err), http.StatusInternalServerError)
92
+
return
93
+
}
94
+
95
+
w.Header().Set("Content-Type", "application/json")
96
+
w.WriteHeader(http.StatusOK)
97
+
w.Write([]byte(fmt.Sprintf(`{"token":"%s","expiresAt":"%s"}`, token, exp.UTC().Format(time.RFC3339))))
98
+
}
99
+
100
+
// GenerateDeveloperTokenWithForce allows bypassing caches when force is true.
101
+
func (s *Service) GenerateDeveloperTokenWithForce(force bool) (string, time.Time, error) {
102
+
if !force {
103
+
return s.GenerateDeveloperToken()
104
+
}
105
+
106
+
// Bypass caches and regenerate
107
+
privKey, err := s.loadPrivateKey()
108
+
if err != nil {
109
+
return "", time.Time{}, err
110
+
}
111
+
112
+
if s.keyID == "" {
113
+
return "", time.Time{}, errors.New("applemusic key_id is not configured")
114
+
}
115
+
116
+
now := time.Now().UTC()
117
+
exp := now.Add(180 * 24 * time.Hour).Add(-1 * time.Hour)
118
+
119
+
builder := jwt.NewBuilder().
120
+
Issuer(s.teamID).
121
+
IssuedAt(now).
122
+
Expiration(exp);
123
+
124
+
unsignedToken, err := builder.Build()
125
+
if err != nil {
126
+
return "", time.Time{}, err
127
+
}
128
+
129
+
headers := jws.NewHeaders()
130
+
_ = headers.Set(jws.KeyIDKey, s.keyID)
131
+
signed, err := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privKey, jws.WithProtectedHeaders(headers)))
132
+
if err != nil {
133
+
return "", time.Time{}, err
134
+
}
135
+
136
+
final := string(signed)
137
+
138
+
s.mu.Lock()
139
+
s.cachedToken = final
140
+
s.cachedExpiry = exp
141
+
s.mu.Unlock()
142
+
143
+
if s.saveToken != nil {
144
+
_ = s.saveToken(final, exp)
145
+
}
146
+
147
+
return final, exp, nil
148
+
}
149
+
150
+
// GenerateDeveloperToken returns a cached valid token or creates a new one.
151
+
func (s *Service) GenerateDeveloperToken() (string, time.Time, error) {
152
+
if s.keyID == "" {
153
+
return "", time.Time{}, errors.New("applemusic key_id is not configured")
154
+
}
155
+
s.mu.RLock()
156
+
if s.cachedToken != "" && time.Until(s.cachedExpiry) > 5*time.Minute {
157
+
token := s.cachedToken
158
+
exp := s.cachedExpiry
159
+
s.mu.RUnlock()
160
+
// Validate cached token claims (aud, iss) to avoid serving bad tokens
161
+
if s.isTokenStructurallyValid(token) {
162
+
return token, exp, nil
163
+
}
164
+
} else {
165
+
s.mu.RUnlock()
166
+
}
167
+
168
+
// Try DB cache if available
169
+
if s.getToken != nil {
170
+
if t, e, ok, err := s.getToken(); err == nil && ok {
171
+
if time.Until(e) > 5*time.Minute && s.isTokenStructurallyValid(t) {
172
+
s.mu.Lock()
173
+
s.cachedToken = t
174
+
s.cachedExpiry = e
175
+
s.mu.Unlock()
176
+
return t, e, nil
177
+
}
178
+
}
179
+
}
180
+
181
+
privKey, err := s.loadPrivateKey()
182
+
if err != nil {
183
+
return "", time.Time{}, err
184
+
}
185
+
186
+
now := time.Now().UTC()
187
+
// Apple allows up to 6 months validity; choose 6 months minus a small buffer
188
+
exp := now.Add(180 * 24 * time.Hour).Add(-1 * time.Hour)
189
+
190
+
builder := jwt.NewBuilder().
191
+
Issuer(s.teamID).
192
+
IssuedAt(now).
193
+
Expiration(exp)
194
+
195
+
unsignedToken, err := builder.Build()
196
+
if err != nil {
197
+
return "", time.Time{}, err
198
+
}
199
+
200
+
headers := jws.NewHeaders()
201
+
_ = headers.Set(jws.KeyIDKey, s.keyID)
202
+
signed, err := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privKey, jws.WithProtectedHeaders(headers)))
203
+
if err != nil {
204
+
return "", time.Time{}, err
205
+
}
206
+
207
+
final := string(signed)
208
+
209
+
s.mu.Lock()
210
+
s.cachedToken = final
211
+
s.cachedExpiry = exp
212
+
s.mu.Unlock()
213
+
214
+
if s.saveToken != nil {
215
+
_ = s.saveToken(final, exp)
216
+
}
217
+
218
+
return final, exp, nil
219
+
}
220
+
221
+
// isTokenStructurallyValid parses without verification and checks claims for iss and exp
222
+
func (s *Service) isTokenStructurallyValid(token string) bool {
223
+
if token == "" {
224
+
return false
225
+
}
226
+
parsed, err := jwt.Parse([]byte(token), jwt.WithVerify(false))
227
+
if err != nil {
228
+
return false
229
+
}
230
+
// Check issuer
231
+
if parsed.Issuer() != s.teamID {
232
+
return false
233
+
}
234
+
// Check expiration not too close
235
+
if time.Until(parsed.Expiration()) <= 5*time.Minute {
236
+
return false
237
+
}
238
+
return true
239
+
}
240
+
241
+
func (s *Service) loadPrivateKey() (*ecdsa.PrivateKey, error) {
242
+
if s.privateKeyPath == "" {
243
+
return nil, errors.New("applemusic private key path not configured")
244
+
}
245
+
pemBytes, err := os.ReadFile(s.privateKeyPath)
246
+
if err != nil {
247
+
return nil, fmt.Errorf("reading private key: %w", err)
248
+
}
249
+
block, _ := pem.Decode(pemBytes)
250
+
if block == nil || len(block.Bytes) == 0 {
251
+
return nil, errors.New("invalid PEM data for private key")
252
+
}
253
+
pkcs8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
254
+
if err != nil {
255
+
return nil, fmt.Errorf("parsing PKCS#8 key: %w", err)
256
+
}
257
+
key, ok := pkcs8.(*ecdsa.PrivateKey)
258
+
if !ok {
259
+
return nil, errors.New("private key is not ECDSA")
260
+
}
261
+
return key, nil
262
+
}
263
+
264
+
// ------- Recent Played Tracks ingestion -------
265
+
266
+
// appleRecentTrack models a subset of Apple Music API track response
267
+
type appleRecentTrack struct {
268
+
ID string `json:"id"`
269
+
Attributes struct {
270
+
Name string `json:"name"`
271
+
ArtistName string `json:"artistName"`
272
+
AlbumName string `json:"albumName"`
273
+
DurationInMillis *int64 `json:"durationInMillis"`
274
+
Isrc *string `json:"isrc"`
275
+
URL string `json:"url"`
276
+
PlayParams *struct {
277
+
ID string `json:"id"`
278
+
Kind string `json:"kind"`
279
+
} `json:"playParams"`
280
+
} `json:"attributes"`
281
+
}
282
+
283
+
type recentPlayedResponse struct {
284
+
Data []appleRecentTrack `json:"data"`
285
+
}
286
+
287
+
// FetchRecentPlayedTracks calls Apple Music API for a user token
288
+
func (s *Service) FetchRecentPlayedTracks(ctx context.Context, userToken string, limit int) ([]appleRecentTrack, error) {
289
+
if limit <= 0 || limit > 50 {
290
+
limit = 25
291
+
}
292
+
devToken, _, err := s.GenerateDeveloperToken()
293
+
if err != nil {
294
+
return nil, err
295
+
}
296
+
endpoint := &url.URL{Scheme: "https", Host: "api.music.apple.com", Path: "/v1/me/recent/played/tracks"}
297
+
q := endpoint.Query()
298
+
q.Set("limit", fmt.Sprintf("%d", limit))
299
+
endpoint.RawQuery = q.Encode()
300
+
301
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
302
+
if err != nil {
303
+
return nil, err
304
+
}
305
+
req.Header.Set("Authorization", "Bearer "+devToken)
306
+
req.Header.Set("Music-User-Token", userToken)
307
+
308
+
resp, err := s.httpClient.Do(req)
309
+
if err != nil {
310
+
return nil, err
311
+
}
312
+
defer resp.Body.Close()
313
+
314
+
// Read the full response body to log it
315
+
bodyBytes, err := io.ReadAll(resp.Body)
316
+
if err != nil {
317
+
return nil, fmt.Errorf("failed to read response body: %w", err)
318
+
}
319
+
320
+
321
+
if resp.StatusCode != http.StatusOK {
322
+
return nil, fmt.Errorf("apple music api error: %s", resp.Status)
323
+
}
324
+
325
+
var parsed recentPlayedResponse
326
+
if err := json.Unmarshal(bodyBytes, &parsed); err != nil {
327
+
return nil, err
328
+
}
329
+
return parsed.Data, nil
330
+
}
331
+
332
+
// toTrack converts appleRecentTrack to internal models.Track
333
+
func (s *Service) toTrack(t appleRecentTrack, userID int64) *models.Track {
334
+
var duration int64
335
+
if t.Attributes.DurationInMillis != nil {
336
+
duration = *t.Attributes.DurationInMillis
337
+
}
338
+
isrc := ""
339
+
if t.Attributes.Isrc != nil {
340
+
isrc = *t.Attributes.Isrc
341
+
}
342
+
343
+
// Similar stamping logic to Spotify: stamp if played more than half (or 30 seconds whichever is greater)
344
+
// Since Apple Music recent played tracks don't provide play progress, we assume full plays
345
+
isStamped := duration > 30000 && duration >= duration/2
346
+
347
+
track := &models.Track{
348
+
Name: t.Attributes.Name,
349
+
Artist: []models.Artist{{Name: t.Attributes.ArtistName}},
350
+
Album: t.Attributes.AlbumName,
351
+
URL: t.Attributes.URL,
352
+
DurationMs: duration,
353
+
ProgressMs: duration, // Assume full play since Apple Music doesn't provide partial plays
354
+
ServiceBaseUrl: "music.apple.com",
355
+
ISRC: isrc,
356
+
HasStamped: isStamped,
357
+
Timestamp: time.Now().UTC(),
358
+
}
359
+
360
+
if s.mbService != nil {
361
+
hydrated, err := musicbrainz.HydrateTrack(s.mbService, *track)
362
+
if err == nil && hydrated != nil {
363
+
track = hydrated
364
+
}
365
+
}
366
+
return track
367
+
}
368
+
369
+
// GetCurrentAppleMusicTrack fetches the most recent Apple Music track for a user
370
+
func (s *Service) GetCurrentAppleMusicTrack(ctx context.Context, user *models.User) (*appleRecentTrack, error) {
371
+
if user.AppleMusicUserToken == nil || *user.AppleMusicUserToken == "" {
372
+
return nil, nil
373
+
}
374
+
375
+
// Only fetch the most recent track (limit=1)
376
+
items, err := s.FetchRecentPlayedTracks(ctx, *user.AppleMusicUserToken, 1)
377
+
if err != nil {
378
+
return nil, err
379
+
}
380
+
381
+
if len(items) == 0 {
382
+
return nil, nil
383
+
}
384
+
385
+
return &items[0], nil
386
+
}
387
+
388
+
// ProcessUser checks for new Apple Music tracks and processes them
389
+
func (s *Service) ProcessUser(ctx context.Context, user *models.User) error {
390
+
if user.AppleMusicUserToken == nil || *user.AppleMusicUserToken == "" {
391
+
return nil
392
+
}
393
+
394
+
// Fetch only the most recent track
395
+
currentAppleTrack, err := s.GetCurrentAppleMusicTrack(ctx, user)
396
+
if err != nil {
397
+
s.logger.Printf("failed to get current Apple Music track for user %d: %v", user.ID, err)
398
+
return err
399
+
}
400
+
401
+
if currentAppleTrack == nil {
402
+
s.logger.Printf("no current Apple Music track for user %d", user.ID)
403
+
// Clear playing now status if no track is playing
404
+
if s.playingNowService != nil {
405
+
if err := s.playingNowService.ClearPlayingNow(ctx, user.ID); err != nil {
406
+
s.logger.Printf("Error clearing playing now for user %d: %v", user.ID, err)
407
+
}
408
+
}
409
+
return nil
410
+
}
411
+
412
+
// Get the last saved track to compare PlayParams.id
413
+
lastTracks, err := s.DB.GetRecentTracks(user.ID, 1)
414
+
if err != nil {
415
+
s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err)
416
+
}
417
+
418
+
// Check if this is a new track (by PlayParams.id)
419
+
if len(lastTracks) > 0 {
420
+
lastTrack := lastTracks[0]
421
+
// If the URL matches, it's the same track
422
+
if lastTrack.URL == currentAppleTrack.Attributes.URL {
423
+
s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName)
424
+
return nil
425
+
}
426
+
}
427
+
428
+
// Convert to internal track format
429
+
track := s.toTrack(*currentAppleTrack, user.ID)
430
+
if track == nil || strings.TrimSpace(track.Name) == "" || len(track.Artist) == 0 {
431
+
s.logger.Printf("invalid track data for user %d", user.ID)
432
+
return nil
433
+
}
434
+
435
+
// Hydration is handled in toTrack() using MusicBrainz search; no ISRC-only hydration here
436
+
437
+
// Save the new track
438
+
if _, err := s.DB.SaveTrack(user.ID, track); err != nil {
439
+
s.logger.Printf("failed saving apple track for user %d: %v", user.ID, err)
440
+
return err
441
+
}
442
+
443
+
s.logger.Printf("saved new track for user %d: %s by %s", user.ID, track.Name, track.Artist[0].Name)
444
+
445
+
// Publish playing now status
446
+
if s.playingNowService != nil {
447
+
if err := s.playingNowService.PublishPlayingNow(ctx, user.ID, track); err != nil {
448
+
s.logger.Printf("Error publishing playing now for user %d: %v", user.ID, err)
449
+
}
450
+
}
451
+
452
+
// Submit to PDS
453
+
if user.ATProtoDID != nil && user.MostRecentAtProtoSessionID != nil && s.atprotoService != nil {
454
+
if err := atprotoservice.SubmitPlayToPDS(ctx, *user.ATProtoDID, *user.MostRecentAtProtoSessionID, track, s.atprotoService); err != nil {
455
+
s.logger.Printf("failed submit to PDS for user %d: %v", user.ID, err)
456
+
}
457
+
}
458
+
459
+
return nil
460
+
}
461
+
462
+
// StartListeningTracker periodically fetches recent plays for Apple Music linked users
463
+
func (s *Service) StartListeningTracker(interval time.Duration) {
464
+
if s.DB == nil {
465
+
if s.logger != nil { s.logger.Printf("DB not configured; Apple Music tracker disabled") }
466
+
return
467
+
}
468
+
ticker := time.NewTicker(interval)
469
+
go func() {
470
+
s.runOnce(context.Background())
471
+
for range ticker.C {
472
+
s.runOnce(context.Background())
473
+
}
474
+
}()
475
+
}
476
+
477
+
func (s *Service) runOnce(ctx context.Context) {
478
+
users, err := s.DB.GetAllAppleMusicLinkedUsers()
479
+
if err != nil {
480
+
s.logger.Printf("error loading Apple Music users: %v", err)
481
+
return
482
+
}
483
+
for _, u := range users {
484
+
if ctx.Err() != nil {
485
+
return
486
+
}
487
+
if err := s.ProcessUser(ctx, u); err != nil {
488
+
s.logger.Printf("error processing user %d: %v", u.ID, err)
489
+
}
490
+
}
491
+
}
492
+
493
+
+1
-1
service/atproto/submission.go
+1
-1
service/atproto/submission.go
+45
-9
service/playingnow/playingnow.go
+45
-9
service/playingnow/playingnow.go
···
6
6
"log"
7
7
"os"
8
8
"strconv"
9
+
"sync"
9
10
"time"
10
11
11
12
"github.com/bluesky-social/indigo/atproto/client"
···
24
25
db *db.DB
25
26
atprotoService *atprotoauth.ATprotoAuthService
26
27
logger *log.Logger
28
+
mu sync.RWMutex
29
+
clearedStatus map[int64]bool // tracks if a user's status has been cleared on their repo
27
30
}
28
31
29
32
// NewPlayingNowService creates a new playing now service
···
34
37
db: database,
35
38
atprotoService: atprotoService,
36
39
logger: logger,
40
+
clearedStatus: make(map[int64]bool),
37
41
}
38
42
}
39
43
···
75
79
Item: playView,
76
80
}
77
81
78
-
var swapRecord *string
82
+
var swapRecord *comatproto.RepoGetRecord_Output
79
83
swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
80
84
if err != nil {
81
85
return err
82
86
}
83
87
88
+
var swapCid *string
89
+
if swapRecord != nil {
90
+
swapCid = swapRecord.Cid
91
+
}
92
+
93
+
p.logger.Printf("Publishing playing now status for user %d (DID: %s): %s - %s", userID, did, track.Artist[0].Name, track.Name)
94
+
84
95
// Create the record input
85
96
input := comatproto.RepoPutRecord_Input{
86
97
Collection: "fm.teal.alpha.actor.status",
87
98
Repo: atProtoClient.AccountDID.String(),
88
99
Rkey: "self", // Use "self" as the record key for current status
89
100
Record: &lexutil.LexiconTypeDecoder{Val: status},
90
-
SwapRecord: swapRecord,
101
+
SwapRecord: swapCid,
91
102
}
92
103
93
104
// Submit to PDS
···
96
107
return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err)
97
108
}
98
109
99
-
p.logger.Printf("Successfully published playing now status for user %d (DID: %s): %s - %s",
100
-
userID, did, track.Artist[0].Name, track.Name)
110
+
// Resets clear to false since there is a song playing. The publish playing state is kept in the services from
111
+
// if a song has changed/stamped
112
+
p.mu.Lock()
113
+
p.clearedStatus[userID] = false
114
+
p.mu.Unlock()
101
115
102
116
return nil
103
117
}
104
118
105
119
// ClearPlayingNow removes the current playing status by setting an expired status
106
120
func (p *PlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error {
121
+
// Check if status is already cleared to avoid clearing on the users repo over and over
122
+
p.mu.RLock()
123
+
alreadyCleared := p.clearedStatus[userID]
124
+
p.mu.RUnlock()
125
+
126
+
if alreadyCleared {
127
+
return nil
128
+
}
129
+
107
130
// Get user information
108
131
user, err := p.db.GetUserByID(userID)
109
132
if err != nil {
···
140
163
Item: emptyPlayView,
141
164
}
142
165
143
-
var swapRecord *string
166
+
var swapRecord *comatproto.RepoGetRecord_Output
144
167
swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
168
+
145
169
if err != nil {
146
170
return err
147
171
}
148
172
173
+
var swapCid *string
174
+
if swapRecord != nil {
175
+
swapCid = swapRecord.Cid
176
+
}
177
+
149
178
// Update the record
150
179
input := comatproto.RepoPutRecord_Input{
151
180
Collection: "fm.teal.alpha.actor.status",
152
181
Repo: atProtoClient.AccountDID.String(),
153
182
Rkey: "self",
154
183
Record: &lexutil.LexiconTypeDecoder{Val: status},
155
-
SwapRecord: swapRecord,
184
+
SwapRecord: swapCid,
156
185
}
157
186
158
187
if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
···
161
190
}
162
191
163
192
p.logger.Printf("Successfully cleared playing now status for user %d (DID: %s)", userID, did)
193
+
194
+
// Mark status as cleared so we don't clear again until user starts playing a song again
195
+
p.mu.Lock()
196
+
p.clearedStatus[userID] = true
197
+
p.mu.Unlock()
198
+
164
199
return nil
165
200
}
166
201
···
216
251
// Get submission client agent
217
252
submissionAgent := viper.GetString("app.submission_agent")
218
253
if submissionAgent == "" {
219
-
submissionAgent = "piper/v0.0.2"
254
+
submissionAgent = models.SubmissionAgent
220
255
}
221
256
222
257
playView := &teal.AlphaFeedDefs_PlayView{
···
238
273
239
274
// getStatusSwapRecord retrieves the current swap record (CID) for the actor status record.
240
275
// Returns (nil, nil) if the record does not exist yet.
241
-
func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) {
276
+
func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*comatproto.RepoGetRecord_Output, error) {
242
277
result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self")
243
278
244
279
if err != nil {
···
253
288
return nil, fmt.Errorf("error getting the record: %w", err)
254
289
255
290
}
256
-
return result.Cid, nil
291
+
292
+
return result, nil
257
293
}