···4141- `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email`
4242- `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify`
43434444-- `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`
4545-- `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`
4444+- `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
4545+- `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
4646- `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto`
47474848- `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api)
+70
cmd/handlers.go
···137137 }
138138}
139139140140+func handleAppleMusicLink(pg *pages.Pages) http.HandlerFunc {
141141+ return func(w http.ResponseWriter, r *http.Request) {
142142+ w.Header().Set("Content-Type", "text/html")
143143+ err := pg.Execute("applemusic_link", w, struct{ NavBar pages.NavBar }{})
144144+ if err != nil {
145145+ log.Printf("Error executing template: %v", err)
146146+ }
147147+ }
148148+}
149149+140150func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc {
141151 return func(w http.ResponseWriter, r *http.Request) {
142152 userID, ok := session.GetUserID(r.Context())
···251261 "lastfm_username": lastfmUsername,
252262 "spotify_connected": spotifyConnected,
253263 }
264264+ // do not send Apple token value; just whether present
265265+ response["applemusic_linked"] = (user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "")
254266 if user.LastFMUsername == nil {
255267 response["lastfm_username"] = nil
256268 }
···323335 log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID)
324336 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"})
325337 }
338338+}
339339+340340+// apiAppleMusicAuthorize stores a MusicKit user token for the current user
341341+func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc {
342342+ return func(w http.ResponseWriter, r *http.Request) {
343343+ userID, authenticated := session.GetUserID(r.Context())
344344+ if !authenticated {
345345+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
346346+ return
347347+ }
348348+ if r.Method != http.MethodPost {
349349+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
350350+ return
351351+ }
352352+353353+ var req struct {
354354+ UserToken string `json:"userToken"`
355355+ }
356356+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
357357+ jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
358358+ return
359359+ }
360360+ if req.UserToken == "" {
361361+ jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "userToken is required"})
362362+ return
363363+ }
364364+365365+ if err := database.UpdateAppleMusicUserToken(userID, req.UserToken); err != nil {
366366+ log.Printf("apiAppleMusicAuthorize: failed to save token for user %d: %v", userID, err)
367367+ jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save token"})
368368+ return
369369+ }
370370+371371+ jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
372372+ }
373373+}
374374+375375+// apiAppleMusicUnlink clears the MusicKit user token for the current user
376376+func apiAppleMusicUnlink(database *db.DB) http.HandlerFunc {
377377+ return func(w http.ResponseWriter, r *http.Request) {
378378+ userID, authenticated := session.GetUserID(r.Context())
379379+ if !authenticated {
380380+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
381381+ return
382382+ }
383383+ if r.Method != http.MethodPost {
384384+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
385385+ return
386386+ }
387387+388388+ if err := database.ClearAppleMusicUserToken(userID); err != nil {
389389+ log.Printf("apiAppleMusicUnlink: failed to clear token for user %d: %v", userID, err)
390390+ jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Apple Music"})
391391+ return
392392+ }
393393+394394+ jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
395395+ }
326396}
327397328398// apiSubmitListensHandler handles ListenBrainz-compatible submissions
···20202121func New(dbPath string) (*DB, error) {
2222 dir := filepath.Dir(dbPath)
2323- if dir != "." && dir != "/" {
2424- os.MkdirAll(dir, 755)
2525- }
2323+ if dir != "." && dir != "/" {
2424+ os.MkdirAll(dir, 0755)
2525+ }
26262727 db, err := sql.Open("sqlite3", dbPath)
2828 if err != nil {
···5050 access_token TEXT, -- Spotify access token
5151 refresh_token TEXT, -- Spotify refresh token
5252 token_expiry TIMESTAMP, -- Spotify token expiry
5353- lastfm_username TEXT, -- Last.fm username
5353+ lastfm_username TEXT, -- Last.fm username
5454+ applemusic_user_token TEXT, -- Apple Music MusicKit user token
5455 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default
5556 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default
5657 )`)
5758 if err != nil {
5959+ return err
6060+ }
6161+6262+ // Add missing columns to users table if they don't exist
6363+ _, err = db.Exec(`ALTER TABLE users ADD COLUMN applemusic_user_token TEXT`)
6464+ if err != nil && err.Error() != "duplicate column name: applemusic_user_token" {
5865 return err
5966 }
6067···141148 return nil
142149}
143150151151+// Apple Music developer token persistence
152152+func (db *DB) ensureAppleMusicTokenTable() error {
153153+ _, err := db.Exec(`
154154+ CREATE TABLE IF NOT EXISTS applemusic_token (
155155+ token TEXT,
156156+ expires_at TIMESTAMP
157157+ )`)
158158+ return err
159159+}
160160+161161+func (db *DB) GetAppleMusicDeveloperToken() (string, time.Time, bool, error) {
162162+ if err := db.ensureAppleMusicTokenTable(); err != nil {
163163+ return "", time.Time{}, false, err
164164+ }
165165+ var token string
166166+ var exp time.Time
167167+ err := db.QueryRow(`SELECT token, expires_at FROM applemusic_token LIMIT 1`).Scan(&token, &exp)
168168+ if err == sql.ErrNoRows {
169169+ return "", time.Time{}, false, nil
170170+ }
171171+ if err != nil {
172172+ return "", time.Time{}, false, err
173173+ }
174174+ return token, exp, true, nil
175175+}
176176+177177+func (db *DB) SaveAppleMusicDeveloperToken(token string, exp time.Time) error {
178178+ if err := db.ensureAppleMusicTokenTable(); err != nil {
179179+ return err
180180+ }
181181+ // Replace existing single row
182182+ _, err := db.Exec(`DELETE FROM applemusic_token`)
183183+ if err != nil {
184184+ return err
185185+ }
186186+ _, err = db.Exec(`INSERT INTO applemusic_token (token, expires_at) VALUES (?, ?)`, token, exp)
187187+ return err
188188+}
189189+144190// create user without spotify id
145191func (db *DB) CreateUser(user *models.User) (int64, error) {
146192 now := time.Now().UTC()
···181227func (db *DB) GetUserByID(ID int64) (*models.User, error) {
182228 user := &models.User{}
183229184184- err := db.QueryRow(`
185185- SELECT id,
186186- username,
187187- email,
188188- atproto_did,
189189- most_recent_at_session_id,
190190- spotify_id,
191191- access_token,
192192- refresh_token,
193193- token_expiry,
194194- lastfm_username,
195195- created_at,
196196- updated_at
197197- FROM users WHERE id = ?`, ID).Scan(
198198- &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID,
199199- &user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
200200- &user.LastFMUsername,
201201- &user.CreatedAt, &user.UpdatedAt)
230230+ err := db.QueryRow(`
231231+ SELECT id,
232232+ username,
233233+ email,
234234+ atproto_did,
235235+ most_recent_at_session_id,
236236+ spotify_id,
237237+ access_token,
238238+ refresh_token,
239239+ token_expiry,
240240+ lastfm_username,
241241+ applemusic_user_token,
242242+ created_at,
243243+ updated_at
244244+ FROM users WHERE id = ?`, ID).Scan(
245245+ &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID,
246246+ &user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
247247+ &user.LastFMUsername, &user.AppleMusicUserToken,
248248+ &user.CreatedAt, &user.UpdatedAt)
202249203250 if err == sql.ErrNoRows {
204251 return nil, nil
···214261func (db *DB) GetUserBySpotifyID(spotifyID string) (*models.User, error) {
215262 user := &models.User{}
216263217217- err := db.QueryRow(`
218218- SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at
219219- FROM users WHERE spotify_id = ?`, spotifyID).Scan(
220220- &user.ID, &user.Username, &user.Email, &user.SpotifyID,
221221- &user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
222222- &user.LastFMUsername,
223223- &user.CreatedAt, &user.UpdatedAt)
264264+ err := db.QueryRow(`
265265+ SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, applemusic_user_token, created_at, updated_at
266266+ FROM users WHERE spotify_id = ?`, spotifyID).Scan(
267267+ &user.ID, &user.Username, &user.Email, &user.SpotifyID,
268268+ &user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
269269+ &user.LastFMUsername, &user.AppleMusicUserToken,
270270+ &user.CreatedAt, &user.UpdatedAt)
224271225272 if err == sql.ErrNoRows {
226273 return nil, nil
···243290 accessToken, refreshToken, expiry, now, userID)
244291245292 return err
293293+}
294294+295295+func (db *DB) UpdateAppleMusicUserToken(userID int64, userToken string) error {
296296+ now := time.Now().UTC()
297297+ _, err := db.Exec(`
298298+ UPDATE users
299299+ SET applemusic_user_token = ?, updated_at = ?
300300+ WHERE id = ?`,
301301+ userToken, now, userID)
302302+ return err
303303+}
304304+305305+// ClearAppleMusicUserToken removes the stored Apple Music user token for a user
306306+func (db *DB) ClearAppleMusicUserToken(userID int64) error {
307307+ now := time.Now().UTC()
308308+ _, err := db.Exec(`
309309+ UPDATE users
310310+ SET applemusic_user_token = NULL, updated_at = ?
311311+ WHERE id = ?`,
312312+ now, userID)
313313+ return err
314314+}
315315+316316+// GetAllAppleMusicLinkedUsers returns users who have an Apple Music user token set
317317+func (db *DB) GetAllAppleMusicLinkedUsers() ([]*models.User, error) {
318318+ rows, err := db.Query(`
319319+ SELECT id, username, email, atproto_did, most_recent_at_session_id,
320320+ spotify_id, access_token, refresh_token, token_expiry,
321321+ lastfm_username, applemusic_user_token, created_at, updated_at
322322+ FROM users
323323+ WHERE applemusic_user_token IS NOT NULL AND applemusic_user_token != ''
324324+ ORDER BY id`)
325325+ if err != nil {
326326+ return nil, err
327327+ }
328328+ defer rows.Close()
329329+330330+ var users []*models.User
331331+ for rows.Next() {
332332+ u := &models.User{}
333333+ if err := rows.Scan(
334334+ &u.ID, &u.Username, &u.Email, &u.ATProtoDID, &u.MostRecentAtProtoSessionID,
335335+ &u.SpotifyID, &u.AccessToken, &u.RefreshToken, &u.TokenExpiry,
336336+ &u.LastFMUsername, &u.AppleMusicUserToken, &u.CreatedAt, &u.UpdatedAt,
337337+ ); err != nil {
338338+ return nil, err
339339+ }
340340+ users = append(users, u)
341341+ }
342342+ if err := rows.Err(); err != nil {
343343+ return nil, err
344344+ }
345345+ return users, nil
246346}
247347248348func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) {
+3
models/user.go
···1717 // lfm information
1818 LastFMUsername *string
19192020+ // Apple Music
2121+ AppleMusicUserToken *string
2222+2023 // atp info
2124 ATProtoDID *string
2225 //This is meant to only be used by the automated music stamping service. If the user ever does an