···11+MIT License
22+33+Copyright (c) 2025 teal computing, LLC
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+10-4
README.md
···11-A fork of https://github.com/teal-fm/piper
22-31# piper
4253#### what is piper?
···4543- `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email`
4644- `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify`
47454848-- `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`
4949-- `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`
4646+- `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
4747+- `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json`
5048- `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto`
51495250- `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api)
53515452- `TRACKER_INTERVAL` - How long between checks to see if the registered users are listening to new music
5553- `DB_PATH`= Path for the sqlite db. If you are using the docker compose probably want `/db/piper.db` to persist data
5454+5555+##### apple music
5656+5757+requires an apple developer account
5858+5959+- `APPLE_MUSIC_TEAM_ID` - Your Apple Developer Account's Team ID, found at `Membership Details` [here](https://developer.apple.com/account)
6060+- `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.
6161+- `APPLE_MUSIC_PRIVATE_KEY_PATH` - The path to said private key as mentioned above.
56625763## development
5864
+81
cmd/handlers.go
···1212 "github.com/teal-fm/piper/models"
1313 atprotoauth "github.com/teal-fm/piper/oauth/atproto"
1414 pages "github.com/teal-fm/piper/pages"
1515+ "github.com/teal-fm/piper/service/applemusic"
1516 atprotoservice "github.com/teal-fm/piper/service/atproto"
1617 "github.com/teal-fm/piper/service/musicbrainz"
1718 "github.com/teal-fm/piper/service/playingnow"
···135136136137 http.Redirect(w, r, "/", http.StatusSeeOther)
137138 }
139139+}
140140+141141+func handleAppleMusicLink(pg *pages.Pages, am *applemusic.Service) http.HandlerFunc {
142142+ return func(w http.ResponseWriter, r *http.Request) {
143143+ w.Header().Set("Content-Type", "text/html")
144144+ devToken, _, errTok := am.GenerateDeveloperToken()
145145+ if errTok != nil {
146146+ log.Printf("Error generating Apple Music developer token: %v", errTok)
147147+ http.Error(w, "Failed to prepare Apple Music", http.StatusInternalServerError)
148148+ return
149149+ }
150150+ data := struct{
151151+ NavBar pages.NavBar
152152+ DevToken string
153153+ }{DevToken: devToken}
154154+ err := pg.Execute("applemusic_link", w, data)
155155+ if err != nil {
156156+ log.Printf("Error executing template: %v", err)
157157+ }
158158+ }
138159}
139160140161func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc {
···251272 "lastfm_username": lastfmUsername,
252273 "spotify_connected": spotifyConnected,
253274 }
275275+ // do not send Apple token value; just whether present
276276+ response["applemusic_linked"] = (user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "")
254277 if user.LastFMUsername == nil {
255278 response["lastfm_username"] = nil
256279 }
···323346 log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID)
324347 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"})
325348 }
349349+}
350350+351351+// apiAppleMusicAuthorize stores a MusicKit user token for the current user
352352+func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc {
353353+ return func(w http.ResponseWriter, r *http.Request) {
354354+ userID, authenticated := session.GetUserID(r.Context())
355355+ if !authenticated {
356356+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
357357+ return
358358+ }
359359+ if r.Method != http.MethodPost {
360360+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
361361+ return
362362+ }
363363+364364+ var req struct {
365365+ UserToken string `json:"userToken"`
366366+ }
367367+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
368368+ jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
369369+ return
370370+ }
371371+ if req.UserToken == "" {
372372+ jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "userToken is required"})
373373+ return
374374+ }
375375+376376+ if err := database.UpdateAppleMusicUserToken(userID, req.UserToken); err != nil {
377377+ log.Printf("apiAppleMusicAuthorize: failed to save token for user %d: %v", userID, err)
378378+ jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save token"})
379379+ return
380380+ }
381381+382382+ jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
383383+ }
384384+}
385385+386386+// apiAppleMusicUnlink clears the MusicKit user token for the current user
387387+func apiAppleMusicUnlink(database *db.DB) http.HandlerFunc {
388388+ return func(w http.ResponseWriter, r *http.Request) {
389389+ userID, authenticated := session.GetUserID(r.Context())
390390+ if !authenticated {
391391+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
392392+ return
393393+ }
394394+ if r.Method != http.MethodPost {
395395+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
396396+ return
397397+ }
398398+399399+ if err := database.ClearAppleMusicUserToken(userID); err != nil {
400400+ log.Printf("apiAppleMusicUnlink: failed to clear token for user %d: %v", userID, err)
401401+ jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Apple Music"})
402402+ return
403403+ }
404404+405405+ jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"})
406406+ }
326407}
327408328409// apiSubmitListensHandler handles ListenBrainz-compatible submissions
+41-1
cmd/main.go
···77 "net/http"
88 "time"
991010+ "github.com/teal-fm/piper/service/applemusic"
1011 "github.com/teal-fm/piper/service/lastfm"
1112 "github.com/teal-fm/piper/service/playingnow"
1213···3132 mbService *musicbrainz.MusicBrainzService
3233 atprotoService *atproto.ATprotoAuthService
3334 playingNowService *playingnow.PlayingNowService
3535+ appleMusicService *applemusic.Service
3436 pages *pages.Pages
3537}
3638···8789 playingNowService := playingnow.NewPlayingNowService(database, atprotoService)
8890 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService)
8991 lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService)
9292+ // Read Apple Music settings with env fallbacks
9393+ teamID := viper.GetString("applemusic.team_id")
9494+ if teamID == "" {
9595+ teamID = viper.GetString("APPLE_MUSIC_TEAM_ID")
9696+ }
9797+ keyID := viper.GetString("applemusic.key_id")
9898+ if keyID == "" {
9999+ keyID = viper.GetString("APPLE_MUSIC_KEY_ID")
100100+ }
101101+ keyPath := viper.GetString("applemusic.private_key_path")
102102+ if keyPath == "" {
103103+ keyPath = viper.GetString("APPLE_MUSIC_PRIVATE_KEY_PATH")
104104+ }
105105+106106+ var appleMusicService *applemusic.Service
107107+ // Only initialize Apple Music service if all required credentials are present
108108+ if teamID != "" && keyID != "" && keyPath != "" {
109109+ appleMusicService = applemusic.NewService(
110110+ teamID,
111111+ keyID,
112112+ keyPath,
113113+ ).WithPersistence(
114114+ func() (string, time.Time, bool, error) {
115115+ return database.GetAppleMusicDeveloperToken()
116116+ },
117117+ func(token string, exp time.Time) error {
118118+ return database.SaveAppleMusicDeveloperToken(token, exp)
119119+ },
120120+ ).WithDeps(database, atprotoService, mbService, playingNowService)
121121+ } else {
122122+ log.Println("Apple Music credentials not configured (missing team_id, key_id, or private_key_path). Apple Music features will be disabled.")
123123+ }
9012491125 oauthManager := oauth.NewOAuthServiceManager()
92126···112146 spotifyService: spotifyService,
113147 atprotoService: atprotoService,
114148 playingNowService: playingNowService,
149149+ appleMusicService: appleMusicService,
115150 pages: pages.NewPages(),
116151 }
117152···123158124159 go spotifyService.StartListeningTracker(trackerInterval)
125160126126- go lastfmService.StartListeningTracker(lastfmInterval)
161161+ go lastfmService.StartListeningTracker(lastfmInterval)
162162+ // Apple Music tracker uses same tracker.interval as Spotify for now
163163+ // Only start if Apple Music service is configured
164164+ if appleMusicService != nil {
165165+ go appleMusicService.StartListeningTracker(trackerInterval)
166166+ }
127167128168 serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port"))
129169 server := &http.Server{
+5
cmd/routes.go
···2828 mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager))
2929 mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form
3030 mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly
3131+ mux.HandleFunc("/link-applemusic", session.WithAuth(handleAppleMusicLink(app.pages, app.appleMusicService), app.sessionManager))
3132 mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto"))
3233 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager))
3334···3839 mux.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(app.spotifyService), app.sessionManager)) // Spotify Current
3940 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History
4041 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?)
4242+4343+ // Apple Music user authorization (protected with session auth)
4444+ mux.HandleFunc("/api/v1/applemusic/authorize", session.WithAuth(apiAppleMusicAuthorize(app.database), app.sessionManager))
4545+ mux.HandleFunc("/api/v1/applemusic/unlink", session.WithAuth(apiAppleMusicUnlink(app.database), app.sessionManager))
41464247 // ListenBrainz-compatible endpoint
4348 mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
···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
···66 "log"
77 "os"
88 "strconv"
99+ "sync"
910 "time"
10111112 "github.com/bluesky-social/indigo/atproto/client"
···2425 db *db.DB
2526 atprotoService *atprotoauth.ATprotoAuthService
2627 logger *log.Logger
2828+ mu sync.RWMutex
2929+ clearedStatus map[int64]bool // tracks if a user's status has been cleared on their repo
2730}
28312932// NewPlayingNowService creates a new playing now service
···3437 db: database,
3538 atprotoService: atprotoService,
3639 logger: logger,
4040+ clearedStatus: make(map[int64]bool),
3741 }
3842}
3943···7579 Item: playView,
7680 }
77817878- var swapRecord *string
8282+ var swapRecord *comatproto.RepoGetRecord_Output
7983 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
8084 if err != nil {
8185 return err
8286 }
83878888+ var swapCid *string
8989+ if swapRecord != nil {
9090+ swapCid = swapRecord.Cid
9191+ }
9292+9393+ p.logger.Printf("Publishing playing now status for user %d (DID: %s): %s - %s", userID, did, track.Artist[0].Name, track.Name)
9494+8495 // Create the record input
8596 input := comatproto.RepoPutRecord_Input{
8697 Collection: "fm.teal.alpha.actor.status",
8798 Repo: atProtoClient.AccountDID.String(),
8899 Rkey: "self", // Use "self" as the record key for current status
89100 Record: &lexutil.LexiconTypeDecoder{Val: status},
9090- SwapRecord: swapRecord,
101101+ SwapRecord: swapCid,
91102 }
9210393104 // Submit to PDS
···96107 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err)
97108 }
981099999- p.logger.Printf("Successfully published playing now status for user %d (DID: %s): %s - %s",
100100- userID, did, track.Artist[0].Name, track.Name)
110110+ // Resets clear to false since there is a song playing. The publish playing state is kept in the services from
111111+ // if a song has changed/stamped
112112+ p.mu.Lock()
113113+ p.clearedStatus[userID] = false
114114+ p.mu.Unlock()
101115102116 return nil
103117}
104118105119// ClearPlayingNow removes the current playing status by setting an expired status
106120func (p *PlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error {
121121+ // Check if status is already cleared to avoid clearing on the users repo over and over
122122+ p.mu.RLock()
123123+ alreadyCleared := p.clearedStatus[userID]
124124+ p.mu.RUnlock()
125125+126126+ if alreadyCleared {
127127+ return nil
128128+ }
129129+107130 // Get user information
108131 user, err := p.db.GetUserByID(userID)
109132 if err != nil {
···140163 Item: emptyPlayView,
141164 }
142165143143- var swapRecord *string
166166+ var swapRecord *comatproto.RepoGetRecord_Output
144167 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
168168+145169 if err != nil {
146170 return err
147171 }
148172173173+ var swapCid *string
174174+ if swapRecord != nil {
175175+ swapCid = swapRecord.Cid
176176+ }
177177+149178 // Update the record
150179 input := comatproto.RepoPutRecord_Input{
151180 Collection: "fm.teal.alpha.actor.status",
152181 Repo: atProtoClient.AccountDID.String(),
153182 Rkey: "self",
154183 Record: &lexutil.LexiconTypeDecoder{Val: status},
155155- SwapRecord: swapRecord,
184184+ SwapRecord: swapCid,
156185 }
157186158187 if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
···161190 }
162191163192 p.logger.Printf("Successfully cleared playing now status for user %d (DID: %s)", userID, did)
193193+194194+ // Mark status as cleared so we don't clear again until user starts playing a song again
195195+ p.mu.Lock()
196196+ p.clearedStatus[userID] = true
197197+ p.mu.Unlock()
198198+164199 return nil
165200}
166201···216251 // Get submission client agent
217252 submissionAgent := viper.GetString("app.submission_agent")
218253 if submissionAgent == "" {
219219- submissionAgent = "piper/v0.0.2"
254254+ submissionAgent = models.SubmissionAgent
220255 }
221256222257 playView := &teal.AlphaFeedDefs_PlayView{
···238273239274// getStatusSwapRecord retrieves the current swap record (CID) for the actor status record.
240275// Returns (nil, nil) if the record does not exist yet.
241241-func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) {
276276+func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*comatproto.RepoGetRecord_Output, error) {
242277 result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self")
243278244279 if err != nil {
···253288 return nil, fmt.Errorf("error getting the record: %w", err)
254289255290 }
256256- return result.Cid, nil
291291+292292+ return result, nil
257293}