A fork of https://github.com/teal-fm/piper

Compare changes

Choose any two refs to compare.

+8
.dockerignore
···
··· 1 + # flyctl launch added from .gitignore 2 + **/**.env 3 + **/tmp 4 + **/**/piper.db 5 + **/jwk*.json 6 + **/**.bak 7 + **/.idea 8 + fly.toml
+3 -1
.gitignore
··· 3 **/piper.db 4 jwk*.json 5 **.bak 6 - .idea
··· 3 **/piper.db 4 jwk*.json 5 **.bak 6 + .idea 7 + AM_AUTHKEY.p8 8 + .DS_Store
+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
··· 1 - A fork of https://github.com/teal-fm/piper 2 - 3 # piper 4 5 #### what is piper? ··· 45 - `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email` 46 - `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify` 47 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` 50 - `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto` 51 52 - `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api) 53 54 - `TRACKER_INTERVAL` - How long between checks to see if the registered users are listening to new music 55 - `DB_PATH`= Path for the sqlite db. If you are using the docker compose probably want `/db/piper.db` to persist data 56 57 ## development 58
··· 1 # piper 2 3 #### what is piper? ··· 43 - `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email` 44 - `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify` 45 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` 48 - `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto` 49 50 - `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api) 51 52 - `TRACKER_INTERVAL` - How long between checks to see if the registered users are listening to new music 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. 62 63 ## development 64
+81
cmd/handlers.go
··· 12 "github.com/teal-fm/piper/models" 13 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 14 pages "github.com/teal-fm/piper/pages" 15 atprotoservice "github.com/teal-fm/piper/service/atproto" 16 "github.com/teal-fm/piper/service/musicbrainz" 17 "github.com/teal-fm/piper/service/playingnow" ··· 135 136 http.Redirect(w, r, "/", http.StatusSeeOther) 137 } 138 } 139 140 func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc { ··· 251 "lastfm_username": lastfmUsername, 252 "spotify_connected": spotifyConnected, 253 } 254 if user.LastFMUsername == nil { 255 response["lastfm_username"] = nil 256 } ··· 323 log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID) 324 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 325 } 326 } 327 328 // apiSubmitListensHandler handles ListenBrainz-compatible submissions
··· 12 "github.com/teal-fm/piper/models" 13 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 14 pages "github.com/teal-fm/piper/pages" 15 + "github.com/teal-fm/piper/service/applemusic" 16 atprotoservice "github.com/teal-fm/piper/service/atproto" 17 "github.com/teal-fm/piper/service/musicbrainz" 18 "github.com/teal-fm/piper/service/playingnow" ··· 136 137 http.Redirect(w, r, "/", http.StatusSeeOther) 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 + } 159 } 160 161 func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc { ··· 272 "lastfm_username": lastfmUsername, 273 "spotify_connected": spotifyConnected, 274 } 275 + // do not send Apple token value; just whether present 276 + response["applemusic_linked"] = (user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "") 277 if user.LastFMUsername == nil { 278 response["lastfm_username"] = nil 279 } ··· 346 log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID) 347 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 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 + } 407 } 408 409 // apiSubmitListensHandler handles ListenBrainz-compatible submissions
+41 -1
cmd/main.go
··· 7 "net/http" 8 "time" 9 10 "github.com/teal-fm/piper/service/lastfm" 11 "github.com/teal-fm/piper/service/playingnow" 12 ··· 31 mbService *musicbrainz.MusicBrainzService 32 atprotoService *atproto.ATprotoAuthService 33 playingNowService *playingnow.PlayingNowService 34 pages *pages.Pages 35 } 36 ··· 87 playingNowService := playingnow.NewPlayingNowService(database, atprotoService) 88 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService) 89 lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService) 90 91 oauthManager := oauth.NewOAuthServiceManager() 92 ··· 112 spotifyService: spotifyService, 113 atprotoService: atprotoService, 114 playingNowService: playingNowService, 115 pages: pages.NewPages(), 116 } 117 ··· 123 124 go spotifyService.StartListeningTracker(trackerInterval) 125 126 - go lastfmService.StartListeningTracker(lastfmInterval) 127 128 serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port")) 129 server := &http.Server{
··· 7 "net/http" 8 "time" 9 10 + "github.com/teal-fm/piper/service/applemusic" 11 "github.com/teal-fm/piper/service/lastfm" 12 "github.com/teal-fm/piper/service/playingnow" 13 ··· 32 mbService *musicbrainz.MusicBrainzService 33 atprotoService *atproto.ATprotoAuthService 34 playingNowService *playingnow.PlayingNowService 35 + appleMusicService *applemusic.Service 36 pages *pages.Pages 37 } 38 ··· 89 playingNowService := playingnow.NewPlayingNowService(database, atprotoService) 90 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService) 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 + } 124 125 oauthManager := oauth.NewOAuthServiceManager() 126 ··· 146 spotifyService: spotifyService, 147 atprotoService: atprotoService, 148 playingNowService: playingNowService, 149 + appleMusicService: appleMusicService, 150 pages: pages.NewPages(), 151 } 152 ··· 158 159 go spotifyService.StartListeningTracker(trackerInterval) 160 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 + } 167 168 serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port")) 169 server := &http.Server{
+5
cmd/routes.go
··· 28 mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager)) 29 mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form 30 mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly 31 mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) 32 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 33 ··· 38 mux.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(app.spotifyService), app.sessionManager)) // Spotify Current 39 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 40 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 41 42 // ListenBrainz-compatible endpoint 43 mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
··· 28 mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager)) 29 mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form 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)) 32 mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) 33 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 34 ··· 39 mux.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(app.spotifyService), app.sessionManager)) // Spotify Current 40 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 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)) 46 47 // ListenBrainz-compatible endpoint 48 mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
+10
config/config.go
··· 23 viper.SetDefault("tracker.interval", 30) 24 viper.SetDefault("db.path", "./data/piper.db") 25 26 // server metadata 27 viper.SetDefault("server.root_url", "http://localhost:8080") 28 viper.SetDefault("atproto.metadata_url", "http://localhost:8080/metadata") 29 viper.SetDefault("atproto.callback_url", "/metadata") 30 31 viper.AutomaticEnv() 32 33 viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 34
··· 23 viper.SetDefault("tracker.interval", 30) 24 viper.SetDefault("db.path", "./data/piper.db") 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 + 31 // server metadata 32 viper.SetDefault("server.root_url", "http://localhost:8080") 33 viper.SetDefault("atproto.metadata_url", "http://localhost:8080/metadata") 34 viper.SetDefault("atproto.callback_url", "/metadata") 35 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") 42 43 viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 44
+129 -29
db/db.go
··· 20 21 func New(dbPath string) (*DB, error) { 22 dir := filepath.Dir(dbPath) 23 - if dir != "." && dir != "/" { 24 - os.MkdirAll(dir, 755) 25 - } 26 27 db, err := sql.Open("sqlite3", dbPath) 28 if err != nil { ··· 50 access_token TEXT, -- Spotify access token 51 refresh_token TEXT, -- Spotify refresh token 52 token_expiry TIMESTAMP, -- Spotify token expiry 53 - lastfm_username TEXT, -- Last.fm username 54 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default 55 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default 56 )`) 57 if err != nil { 58 return err 59 } 60 ··· 141 return nil 142 } 143 144 // create user without spotify id 145 func (db *DB) CreateUser(user *models.User) (int64, error) { 146 now := time.Now().UTC() ··· 181 func (db *DB) GetUserByID(ID int64) (*models.User, error) { 182 user := &models.User{} 183 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) 202 203 if err == sql.ErrNoRows { 204 return nil, nil ··· 214 func (db *DB) GetUserBySpotifyID(spotifyID string) (*models.User, error) { 215 user := &models.User{} 216 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) 224 225 if err == sql.ErrNoRows { 226 return nil, nil ··· 243 accessToken, refreshToken, expiry, now, userID) 244 245 return err 246 } 247 248 func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) {
··· 20 21 func New(dbPath string) (*DB, error) { 22 dir := filepath.Dir(dbPath) 23 + if dir != "." && dir != "/" { 24 + os.MkdirAll(dir, 0755) 25 + } 26 27 db, err := sql.Open("sqlite3", dbPath) 28 if err != nil { ··· 50 access_token TEXT, -- Spotify access token 51 refresh_token TEXT, -- Spotify refresh token 52 token_expiry TIMESTAMP, -- Spotify token expiry 53 + lastfm_username TEXT, -- Last.fm username 54 + applemusic_user_token TEXT, -- Apple Music MusicKit user token 55 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default 56 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default 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" { 65 return err 66 } 67 ··· 148 return nil 149 } 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 + 190 // create user without spotify id 191 func (db *DB) CreateUser(user *models.User) (int64, error) { 192 now := time.Now().UTC() ··· 227 func (db *DB) GetUserByID(ID int64) (*models.User, error) { 228 user := &models.User{} 229 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) 249 250 if err == sql.ErrNoRows { 251 return nil, nil ··· 261 func (db *DB) GetUserBySpotifyID(spotifyID string) (*models.User, error) { 262 user := &models.User{} 263 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) 271 272 if err == sql.ErrNoRows { 273 return nil, nil ··· 290 accessToken, refreshToken, expiry, now, userID) 291 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 346 } 347 348 func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) {
+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/constants.go
···
··· 1 + package models 2 + 3 + const SubmissionAgent = "piper/v0.0.3"
+3
models/user.go
··· 17 // lfm information 18 LastFMUsername *string 19 20 // atp info 21 ATProtoDID *string 22 //This is meant to only be used by the automated music stamping service. If the user ever does an
··· 17 // lfm information 18 LastFMUsername *string 19 20 + // Apple Music 21 + AppleMusicUserToken *string 22 + 23 // atp info 24 ATProtoDID *string 25 //This is meant to only be used by the automated music stamping service. If the user ever does an
+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 }}
+32 -17
pages/templates/components/navBar.gohtml
··· 1 {{ define "components/navBar" }} 2 3 - <nav class="flex flex-wrap mb-5 gap-x-4 gap-y-1"> 4 - <a class="text-[#1DB954] font-bold no-underline" href="/">Home</a> 5 6 - {{if .IsLoggedIn}} 7 - <a class="text-[#1DB954] font-bold no-underline" href="/current-track">Spotify Current</a> 8 - <a class="text-[#1DB954] font-bold no-underline" href="/history">Spotify History</a> 9 - <a class="text-[#1DB954] font-bold no-underline" href="/link-lastfm">Link Last.fm</a> 10 - {{ if .LastFMUsername }} 11 - <a class="text-[#1DB954] font-bold no-underline" href="/lastfm/recent">Last.fm Recent</a> 12 - {{ end }} 13 - <a class="text-[#1DB954] font-bold no-underline" href="/api-keys">API Keys</a> 14 - <a class="text-[#1DB954] font-bold no-underline" href="/login/spotify">Connect Spotify Account</a> 15 - <a class="text-[#1DB954] font-bold no-underline" href="/logout">Logout</a> 16 - {{ else }} 17 - <a class="text-[#1DB954] font-bold no-underline" href="/login/atproto">Login with ATProto</a> 18 - {{ end }} 19 - </nav> 20 - {{ end }}
··· 1 {{ define "components/navBar" }} 2 3 + <nav class="flex flex-wrap mb-5 gap-x-4 gap-y-1"> 4 + <a class="text-[#1DB954] font-bold no-underline" href="/">Home</a> 5 6 + {{if .IsLoggedIn}} 7 + <a class="text-[#1DB954] font-bold no-underline" href="/current-track" 8 + >Spotify Current</a 9 + > 10 + <a class="text-[#1DB954] font-bold no-underline" href="/history" 11 + >Spotify History</a 12 + > 13 + <a class="text-[#1DB954] font-bold no-underline" href="/link-lastfm" 14 + >Link Last.fm</a 15 + > 16 + <a class="text-[#1DB954] font-bold no-underline" href="/link-applemusic" 17 + >Link Apple Music</a 18 + > 19 + {{ if .LastFMUsername }} 20 + <a class="text-[#1DB954] font-bold no-underline" href="/lastfm/recent" 21 + >Last.fm Recent</a 22 + > 23 + {{ end }} 24 + <a class="text-[#1DB954] font-bold no-underline" href="/api-keys">API Keys</a> 25 + <a class="text-[#1DB954] font-bold no-underline" href="/login/spotify" 26 + >Connect Spotify Account</a 27 + > 28 + <a class="text-[#1DB954] font-bold no-underline" href="/logout">Logout</a> 29 + {{ else }} 30 + <a class="text-[#1DB954] font-bold no-underline" href="/login/atproto" 31 + >Login with ATProto</a 32 + > 33 + {{ end }} 34 + </nav> 35 + {{ end }}
+83 -39
pages/templates/home.gohtml
··· 1 - 2 {{ define "content" }} 3 4 - <h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 5 - {{ 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">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 - 34 35 - {{ else }} 36 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> 43 44 45 - {{ end }} 46 - </div> <!-- Close card div --> 47 48 - {{ end }}
··· 1 {{ define "content" }} 2 3 + <h1 class="text-[#1DB954]"> 4 + Piper - Multi-User Spotify & Last.fm Tracker via ATProto 5 + </h1> 6 + {{ template "components/navBar" .NavBar }} 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> 14 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> 61 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 }} 69 70 + {{ else }} 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> 87 88 + {{ end }} 89 + </div> 90 + <!-- Close card div --> 91 92 + {{ end }}
+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
··· 99 // Get submission client agent 100 submissionAgent := viper.GetString("app.submission_agent") 101 if submissionAgent == "" { 102 - submissionAgent = "piper/v0.0.1" 103 } 104 105 playRecord := &teal.AlphaFeedPlay{
··· 99 // Get submission client agent 100 submissionAgent := viper.GetString("app.submission_agent") 101 if submissionAgent == "" { 102 + submissionAgent = models.SubmissionAgent 103 } 104 105 playRecord := &teal.AlphaFeedPlay{
+45 -9
service/playingnow/playingnow.go
··· 6 "log" 7 "os" 8 "strconv" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/client" ··· 24 db *db.DB 25 atprotoService *atprotoauth.ATprotoAuthService 26 logger *log.Logger 27 } 28 29 // NewPlayingNowService creates a new playing now service ··· 34 db: database, 35 atprotoService: atprotoService, 36 logger: logger, 37 } 38 } 39 ··· 75 Item: playView, 76 } 77 78 - var swapRecord *string 79 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 80 if err != nil { 81 return err 82 } 83 84 // Create the record input 85 input := comatproto.RepoPutRecord_Input{ 86 Collection: "fm.teal.alpha.actor.status", 87 Repo: atProtoClient.AccountDID.String(), 88 Rkey: "self", // Use "self" as the record key for current status 89 Record: &lexutil.LexiconTypeDecoder{Val: status}, 90 - SwapRecord: swapRecord, 91 } 92 93 // Submit to PDS ··· 96 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err) 97 } 98 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) 101 102 return nil 103 } 104 105 // ClearPlayingNow removes the current playing status by setting an expired status 106 func (p *PlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error { 107 // Get user information 108 user, err := p.db.GetUserByID(userID) 109 if err != nil { ··· 140 Item: emptyPlayView, 141 } 142 143 - var swapRecord *string 144 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 145 if err != nil { 146 return err 147 } 148 149 // Update the record 150 input := comatproto.RepoPutRecord_Input{ 151 Collection: "fm.teal.alpha.actor.status", 152 Repo: atProtoClient.AccountDID.String(), 153 Rkey: "self", 154 Record: &lexutil.LexiconTypeDecoder{Val: status}, 155 - SwapRecord: swapRecord, 156 } 157 158 if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil { ··· 161 } 162 163 p.logger.Printf("Successfully cleared playing now status for user %d (DID: %s)", userID, did) 164 return nil 165 } 166 ··· 216 // Get submission client agent 217 submissionAgent := viper.GetString("app.submission_agent") 218 if submissionAgent == "" { 219 - submissionAgent = "piper/v0.0.2" 220 } 221 222 playView := &teal.AlphaFeedDefs_PlayView{ ··· 238 239 // getStatusSwapRecord retrieves the current swap record (CID) for the actor status record. 240 // Returns (nil, nil) if the record does not exist yet. 241 - func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) { 242 result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self") 243 244 if err != nil { ··· 253 return nil, fmt.Errorf("error getting the record: %w", err) 254 255 } 256 - return result.Cid, nil 257 }
··· 6 "log" 7 "os" 8 "strconv" 9 + "sync" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/client" ··· 25 db *db.DB 26 atprotoService *atprotoauth.ATprotoAuthService 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 30 } 31 32 // NewPlayingNowService creates a new playing now service ··· 37 db: database, 38 atprotoService: atprotoService, 39 logger: logger, 40 + clearedStatus: make(map[int64]bool), 41 } 42 } 43 ··· 79 Item: playView, 80 } 81 82 + var swapRecord *comatproto.RepoGetRecord_Output 83 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 84 if err != nil { 85 return err 86 } 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 + 95 // Create the record input 96 input := comatproto.RepoPutRecord_Input{ 97 Collection: "fm.teal.alpha.actor.status", 98 Repo: atProtoClient.AccountDID.String(), 99 Rkey: "self", // Use "self" as the record key for current status 100 Record: &lexutil.LexiconTypeDecoder{Val: status}, 101 + SwapRecord: swapCid, 102 } 103 104 // Submit to PDS ··· 107 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err) 108 } 109 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() 115 116 return nil 117 } 118 119 // ClearPlayingNow removes the current playing status by setting an expired status 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 + 130 // Get user information 131 user, err := p.db.GetUserByID(userID) 132 if err != nil { ··· 163 Item: emptyPlayView, 164 } 165 166 + var swapRecord *comatproto.RepoGetRecord_Output 167 swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 168 + 169 if err != nil { 170 return err 171 } 172 173 + var swapCid *string 174 + if swapRecord != nil { 175 + swapCid = swapRecord.Cid 176 + } 177 + 178 // Update the record 179 input := comatproto.RepoPutRecord_Input{ 180 Collection: "fm.teal.alpha.actor.status", 181 Repo: atProtoClient.AccountDID.String(), 182 Rkey: "self", 183 Record: &lexutil.LexiconTypeDecoder{Val: status}, 184 + SwapRecord: swapCid, 185 } 186 187 if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil { ··· 190 } 191 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 + 199 return nil 200 } 201 ··· 251 // Get submission client agent 252 submissionAgent := viper.GetString("app.submission_agent") 253 if submissionAgent == "" { 254 + submissionAgent = models.SubmissionAgent 255 } 256 257 playView := &teal.AlphaFeedDefs_PlayView{ ··· 273 274 // getStatusSwapRecord retrieves the current swap record (CID) for the actor status record. 275 // Returns (nil, nil) if the record does not exist yet. 276 + func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*comatproto.RepoGetRecord_Output, error) { 277 result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self") 278 279 if err != nil { ··· 288 return nil, fmt.Errorf("error getting the record: %w", err) 289 290 } 291 + 292 + return result, nil 293 }
+5
service/spotify/spotify.go
··· 490 }) 491 } 492 493 // assemble Track 494 track := &models.Track{ 495 Name: response.Item.Name,
··· 490 }) 491 } 492 493 + // ignore tracks with no artists (podcasts, audiobooks, etc) 494 + if len(artists) == 0 { 495 + return nil, nil 496 + } 497 + 498 // assemble Track 499 track := &models.Track{ 500 Name: response.Item.Name,