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

initial apple music

+1029 -89
+2 -1
.gitignore
··· 3 3 **/piper.db 4 4 jwk*.json 5 5 **.bak 6 - .idea 6 + .idea 7 + AM_AUTHKEY.p8
+2 -2
README.md
··· 41 41 - `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email` 42 42 - `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify` 43 43 44 - - `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` 45 - - `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` 44 + - `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json` 45 + - `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/oauth-client-metadata.json` 46 46 - `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto` 47 47 48 48 - `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api)
+70
cmd/handlers.go
··· 137 137 } 138 138 } 139 139 140 + func handleAppleMusicLink(pg *pages.Pages) http.HandlerFunc { 141 + return func(w http.ResponseWriter, r *http.Request) { 142 + w.Header().Set("Content-Type", "text/html") 143 + err := pg.Execute("applemusic_link", w, struct{ NavBar pages.NavBar }{}) 144 + if err != nil { 145 + log.Printf("Error executing template: %v", err) 146 + } 147 + } 148 + } 149 + 140 150 func apiCurrentTrack(spotifyService *spotify.SpotifyService) http.HandlerFunc { 141 151 return func(w http.ResponseWriter, r *http.Request) { 142 152 userID, ok := session.GetUserID(r.Context()) ··· 251 261 "lastfm_username": lastfmUsername, 252 262 "spotify_connected": spotifyConnected, 253 263 } 264 + // do not send Apple token value; just whether present 265 + response["applemusic_linked"] = (user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "") 254 266 if user.LastFMUsername == nil { 255 267 response["lastfm_username"] = nil 256 268 } ··· 323 335 log.Printf("API: Successfully unlinked Last.fm username for user ID %d", userID) 324 336 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 325 337 } 338 + } 339 + 340 + // apiAppleMusicAuthorize stores a MusicKit user token for the current user 341 + func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc { 342 + return func(w http.ResponseWriter, r *http.Request) { 343 + userID, authenticated := session.GetUserID(r.Context()) 344 + if !authenticated { 345 + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) 346 + return 347 + } 348 + if r.Method != http.MethodPost { 349 + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) 350 + return 351 + } 352 + 353 + var req struct { 354 + UserToken string `json:"userToken"` 355 + } 356 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 357 + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) 358 + return 359 + } 360 + if req.UserToken == "" { 361 + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "userToken is required"}) 362 + return 363 + } 364 + 365 + if err := database.UpdateAppleMusicUserToken(userID, req.UserToken); err != nil { 366 + log.Printf("apiAppleMusicAuthorize: failed to save token for user %d: %v", userID, err) 367 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save token"}) 368 + return 369 + } 370 + 371 + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"}) 372 + } 373 + } 374 + 375 + // apiAppleMusicUnlink clears the MusicKit user token for the current user 376 + func apiAppleMusicUnlink(database *db.DB) http.HandlerFunc { 377 + return func(w http.ResponseWriter, r *http.Request) { 378 + userID, authenticated := session.GetUserID(r.Context()) 379 + if !authenticated { 380 + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) 381 + return 382 + } 383 + if r.Method != http.MethodPost { 384 + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) 385 + return 386 + } 387 + 388 + if err := database.ClearAppleMusicUserToken(userID); err != nil { 389 + log.Printf("apiAppleMusicUnlink: failed to clear token for user %d: %v", userID, err) 390 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Apple Music"}) 391 + return 392 + } 393 + 394 + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"}) 395 + } 326 396 } 327 397 328 398 // apiSubmitListensHandler handles ListenBrainz-compatible submissions
+32 -1
cmd/main.go
··· 7 7 "net/http" 8 8 "time" 9 9 10 + "github.com/teal-fm/piper/service/applemusic" 10 11 "github.com/teal-fm/piper/service/lastfm" 11 12 "github.com/teal-fm/piper/service/playingnow" 12 13 ··· 31 32 mbService *musicbrainz.MusicBrainzService 32 33 atprotoService *atproto.ATprotoAuthService 33 34 playingNowService *playingnow.PlayingNowService 35 + appleMusicService *applemusic.Service 34 36 pages *pages.Pages 35 37 } 36 38 ··· 87 89 playingNowService := playingnow.NewPlayingNowService(database, atprotoService) 88 90 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService) 89 91 lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService) 92 + // Read Apple Music settings with env fallbacks 93 + teamID := viper.GetString("applemusic.team_id") 94 + if teamID == "" { 95 + teamID = viper.GetString("APPLE_MUSIC_TEAM_ID") 96 + } 97 + keyID := viper.GetString("applemusic.key_id") 98 + if keyID == "" { 99 + keyID = viper.GetString("APPLE_MUSIC_KEY_ID") 100 + } 101 + keyPath := viper.GetString("applemusic.private_key_path") 102 + if keyPath == "" { 103 + keyPath = viper.GetString("APPLE_MUSIC_PRIVATE_KEY_PATH") 104 + } 105 + 106 + appleMusicService := applemusic.NewService( 107 + teamID, 108 + keyID, 109 + keyPath, 110 + ).WithPersistence( 111 + func() (string, time.Time, bool, error) { 112 + return database.GetAppleMusicDeveloperToken() 113 + }, 114 + func(token string, exp time.Time) error { 115 + return database.SaveAppleMusicDeveloperToken(token, exp) 116 + }, 117 + ).WithDeps(database, atprotoService, mbService) 90 118 91 119 oauthManager := oauth.NewOAuthServiceManager() 92 120 ··· 112 140 spotifyService: spotifyService, 113 141 atprotoService: atprotoService, 114 142 playingNowService: playingNowService, 143 + appleMusicService: appleMusicService, 115 144 pages: pages.NewPages(), 116 145 } 117 146 ··· 123 152 124 153 go spotifyService.StartListeningTracker(trackerInterval) 125 154 126 - go lastfmService.StartListeningTracker(lastfmInterval) 155 + go lastfmService.StartListeningTracker(lastfmInterval) 156 + // Apple Music tracker uses same tracker.interval as Spotify for now 157 + go appleMusicService.StartListeningTracker(trackerInterval) 127 158 128 159 serverAddr := fmt.Sprintf("%s:%s", viper.GetString("server.host"), viper.GetString("server.port")) 129 160 server := &http.Server{
+8
cmd/routes.go
··· 28 28 mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager)) 29 29 mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form 30 30 mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly 31 + mux.HandleFunc("/link-applemusic", session.WithAuth(handleAppleMusicLink(app.pages), app.sessionManager)) 31 32 mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) 32 33 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 33 34 ··· 38 39 mux.HandleFunc("/api/v1/current-track", session.WithAPIAuth(apiCurrentTrack(app.spotifyService), app.sessionManager)) // Spotify Current 39 40 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 40 41 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 42 + 43 + // Apple Music developer token (protected with session auth) 44 + mux.HandleFunc("/api/v1/applemusic/developer-token", session.WithAuth(app.appleMusicService.HandleDeveloperToken, app.sessionManager)) 45 + 46 + // Apple Music user authorization (protected with session auth) 47 + mux.HandleFunc("/api/v1/applemusic/authorize", session.WithAuth(apiAppleMusicAuthorize(app.database), app.sessionManager)) 48 + mux.HandleFunc("/api/v1/applemusic/unlink", session.WithAuth(apiAppleMusicUnlink(app.database), app.sessionManager)) 41 49 42 50 // ListenBrainz-compatible endpoint 43 51 mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
+10
config/config.go
··· 23 23 viper.SetDefault("tracker.interval", 30) 24 24 viper.SetDefault("db.path", "./data/piper.db") 25 25 26 + // Apple Music defaults 27 + viper.SetDefault("applemusic.team_id", "") 28 + viper.SetDefault("applemusic.key_id", "") 29 + viper.SetDefault("applemusic.private_key_path", "./AM_AUTHKEY.p8") 30 + 26 31 // server metadata 27 32 viper.SetDefault("server.root_url", "http://localhost:8080") 28 33 viper.SetDefault("atproto.metadata_url", "http://localhost:8080/metadata") 29 34 viper.SetDefault("atproto.callback_url", "/metadata") 30 35 31 36 viper.AutomaticEnv() 37 + 38 + // Support APPLE_MUSIC_* env var aliases 39 + _ = viper.BindEnv("applemusic.team_id", "APPLE_MUSIC_TEAM_ID") 40 + _ = viper.BindEnv("applemusic.key_id", "APPLE_MUSIC_KEY_ID") 41 + _ = viper.BindEnv("applemusic.private_key_path", "APPLE_MUSIC_PRIVATE_KEY_PATH") 32 42 33 43 viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 34 44
+129 -29
db/db.go
··· 20 20 21 21 func New(dbPath string) (*DB, error) { 22 22 dir := filepath.Dir(dbPath) 23 - if dir != "." && dir != "/" { 24 - os.MkdirAll(dir, 755) 25 - } 23 + if dir != "." && dir != "/" { 24 + os.MkdirAll(dir, 0755) 25 + } 26 26 27 27 db, err := sql.Open("sqlite3", dbPath) 28 28 if err != nil { ··· 50 50 access_token TEXT, -- Spotify access token 51 51 refresh_token TEXT, -- Spotify refresh token 52 52 token_expiry TIMESTAMP, -- Spotify token expiry 53 - lastfm_username TEXT, -- Last.fm username 53 + lastfm_username TEXT, -- Last.fm username 54 + applemusic_user_token TEXT, -- Apple Music MusicKit user token 54 55 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Use default 55 56 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- Use default 56 57 )`) 57 58 if err != nil { 59 + return err 60 + } 61 + 62 + // Add missing columns to users table if they don't exist 63 + _, err = db.Exec(`ALTER TABLE users ADD COLUMN applemusic_user_token TEXT`) 64 + if err != nil && err.Error() != "duplicate column name: applemusic_user_token" { 58 65 return err 59 66 } 60 67 ··· 141 148 return nil 142 149 } 143 150 151 + // Apple Music developer token persistence 152 + func (db *DB) ensureAppleMusicTokenTable() error { 153 + _, err := db.Exec(` 154 + CREATE TABLE IF NOT EXISTS applemusic_token ( 155 + token TEXT, 156 + expires_at TIMESTAMP 157 + )`) 158 + return err 159 + } 160 + 161 + func (db *DB) GetAppleMusicDeveloperToken() (string, time.Time, bool, error) { 162 + if err := db.ensureAppleMusicTokenTable(); err != nil { 163 + return "", time.Time{}, false, err 164 + } 165 + var token string 166 + var exp time.Time 167 + err := db.QueryRow(`SELECT token, expires_at FROM applemusic_token LIMIT 1`).Scan(&token, &exp) 168 + if err == sql.ErrNoRows { 169 + return "", time.Time{}, false, nil 170 + } 171 + if err != nil { 172 + return "", time.Time{}, false, err 173 + } 174 + return token, exp, true, nil 175 + } 176 + 177 + func (db *DB) SaveAppleMusicDeveloperToken(token string, exp time.Time) error { 178 + if err := db.ensureAppleMusicTokenTable(); err != nil { 179 + return err 180 + } 181 + // Replace existing single row 182 + _, err := db.Exec(`DELETE FROM applemusic_token`) 183 + if err != nil { 184 + return err 185 + } 186 + _, err = db.Exec(`INSERT INTO applemusic_token (token, expires_at) VALUES (?, ?)`, token, exp) 187 + return err 188 + } 189 + 144 190 // create user without spotify id 145 191 func (db *DB) CreateUser(user *models.User) (int64, error) { 146 192 now := time.Now().UTC() ··· 181 227 func (db *DB) GetUserByID(ID int64) (*models.User, error) { 182 228 user := &models.User{} 183 229 184 - err := db.QueryRow(` 185 - SELECT id, 186 - username, 187 - email, 188 - atproto_did, 189 - most_recent_at_session_id, 190 - spotify_id, 191 - access_token, 192 - refresh_token, 193 - token_expiry, 194 - lastfm_username, 195 - created_at, 196 - updated_at 197 - FROM users WHERE id = ?`, ID).Scan( 198 - &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID, 199 - &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 200 - &user.LastFMUsername, 201 - &user.CreatedAt, &user.UpdatedAt) 230 + err := db.QueryRow(` 231 + SELECT id, 232 + username, 233 + email, 234 + atproto_did, 235 + most_recent_at_session_id, 236 + spotify_id, 237 + access_token, 238 + refresh_token, 239 + token_expiry, 240 + lastfm_username, 241 + applemusic_user_token, 242 + created_at, 243 + updated_at 244 + FROM users WHERE id = ?`, ID).Scan( 245 + &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID, 246 + &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 247 + &user.LastFMUsername, &user.AppleMusicUserToken, 248 + &user.CreatedAt, &user.UpdatedAt) 202 249 203 250 if err == sql.ErrNoRows { 204 251 return nil, nil ··· 214 261 func (db *DB) GetUserBySpotifyID(spotifyID string) (*models.User, error) { 215 262 user := &models.User{} 216 263 217 - err := db.QueryRow(` 218 - SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at 219 - FROM users WHERE spotify_id = ?`, spotifyID).Scan( 220 - &user.ID, &user.Username, &user.Email, &user.SpotifyID, 221 - &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 222 - &user.LastFMUsername, 223 - &user.CreatedAt, &user.UpdatedAt) 264 + err := db.QueryRow(` 265 + SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, applemusic_user_token, created_at, updated_at 266 + FROM users WHERE spotify_id = ?`, spotifyID).Scan( 267 + &user.ID, &user.Username, &user.Email, &user.SpotifyID, 268 + &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 269 + &user.LastFMUsername, &user.AppleMusicUserToken, 270 + &user.CreatedAt, &user.UpdatedAt) 224 271 225 272 if err == sql.ErrNoRows { 226 273 return nil, nil ··· 243 290 accessToken, refreshToken, expiry, now, userID) 244 291 245 292 return err 293 + } 294 + 295 + func (db *DB) UpdateAppleMusicUserToken(userID int64, userToken string) error { 296 + now := time.Now().UTC() 297 + _, err := db.Exec(` 298 + UPDATE users 299 + SET applemusic_user_token = ?, updated_at = ? 300 + WHERE id = ?`, 301 + userToken, now, userID) 302 + return err 303 + } 304 + 305 + // ClearAppleMusicUserToken removes the stored Apple Music user token for a user 306 + func (db *DB) ClearAppleMusicUserToken(userID int64) error { 307 + now := time.Now().UTC() 308 + _, err := db.Exec(` 309 + UPDATE users 310 + SET applemusic_user_token = NULL, updated_at = ? 311 + WHERE id = ?`, 312 + now, userID) 313 + return err 314 + } 315 + 316 + // GetAllAppleMusicLinkedUsers returns users who have an Apple Music user token set 317 + func (db *DB) GetAllAppleMusicLinkedUsers() ([]*models.User, error) { 318 + rows, err := db.Query(` 319 + SELECT id, username, email, atproto_did, most_recent_at_session_id, 320 + spotify_id, access_token, refresh_token, token_expiry, 321 + lastfm_username, applemusic_user_token, created_at, updated_at 322 + FROM users 323 + WHERE applemusic_user_token IS NOT NULL AND applemusic_user_token != '' 324 + ORDER BY id`) 325 + if err != nil { 326 + return nil, err 327 + } 328 + defer rows.Close() 329 + 330 + var users []*models.User 331 + for rows.Next() { 332 + u := &models.User{} 333 + if err := rows.Scan( 334 + &u.ID, &u.Username, &u.Email, &u.ATProtoDID, &u.MostRecentAtProtoSessionID, 335 + &u.SpotifyID, &u.AccessToken, &u.RefreshToken, &u.TokenExpiry, 336 + &u.LastFMUsername, &u.AppleMusicUserToken, &u.CreatedAt, &u.UpdatedAt, 337 + ); err != nil { 338 + return nil, err 339 + } 340 + users = append(users, u) 341 + } 342 + if err := rows.Err(); err != nil { 343 + return nil, err 344 + } 345 + return users, nil 246 346 } 247 347 248 348 func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) {
+3
models/user.go
··· 17 17 // lfm information 18 18 LastFMUsername *string 19 19 20 + // Apple Music 21 + AppleMusicUserToken *string 22 + 20 23 // atp info 21 24 ATProtoDID *string 22 25 //This is meant to only be used by the automated music stamping service. If the user ever does an
+186
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 fetchDeveloperToken() { 40 + const res = await fetch("/api/v1/applemusic/developer-token"); 41 + if (!res.ok) { 42 + const text = await res.text().catch(() => ""); 43 + console.error("Developer token fetch failed", { 44 + status: res.status, 45 + body: text, 46 + }); 47 + throw new Error( 48 + `Failed to get developer token (status ${res.status})` 49 + ); 50 + } 51 + const data = await res.json(); 52 + if (!data || !data.token) { 53 + console.error("Developer token response malformed", { data }); 54 + throw new Error("Developer token missing in response"); 55 + } 56 + console.debug("Fetched developer token (length)", data.token.length); 57 + return data.token; 58 + } 59 + 60 + async function saveUserToken(userToken) { 61 + const res = await fetch("/api/v1/applemusic/authorize", { 62 + method: "POST", 63 + headers: { "Content-Type": "application/json" }, 64 + credentials: "same-origin", 65 + body: JSON.stringify({ userToken }), 66 + }); 67 + if (!res.ok) { 68 + const text = await res.text().catch(() => ""); 69 + console.error("Saving user token failed", { 70 + status: res.status, 71 + body: text, 72 + }); 73 + throw new Error(`Failed to save user token (status ${res.status})`); 74 + } 75 + } 76 + 77 + async function setup() { 78 + const status = document.getElementById("status"); 79 + try { 80 + // Use JavaScript configuration flow exclusively 81 + const devToken = await fetchDeveloperToken(); 82 + // wait for musickit to be loaded 83 + if (!window.MusicKit) { 84 + await new Promise((resolve) => { 85 + document.addEventListener("musickitloaded", resolve, { 86 + once: true, 87 + }); 88 + }); 89 + } 90 + try { 91 + window.MusicKit.configure({ 92 + developerToken: devToken, 93 + app: { name: "Piper", build: "1.0.0" }, 94 + }); 95 + } catch (cfgErr) { 96 + console.error("MusicKit.configure failed", cfgErr); 97 + throw cfgErr; 98 + } 99 + const music = window.MusicKit.getInstance(); 100 + window.musicInstance = music; // Make available globally for handleUnlink 101 + 102 + const toggleUnlinkVisibility = () => { 103 + const form = document.getElementById("unlinkForm"); 104 + if (!form) return; 105 + if (music.isAuthorized) { 106 + form.style.display = "inline-block"; 107 + } else { 108 + form.style.display = "none"; 109 + } 110 + }; 111 + 112 + async function handleUnlink(event) { 113 + event.preventDefault(); 114 + if (!confirm("Unlink Apple Music from your account?")) { 115 + return; 116 + } 117 + try { 118 + const music = window.MusicKit.getInstance(); 119 + await music.unauthorize(); 120 + document.getElementById("unlinkForm").submit(); 121 + } catch (e) { 122 + console.error("Error unauthorizing:", e); 123 + status.textContent = 124 + "Error unauthorizing: " + 125 + (e && e.message ? e.message : String(e)); 126 + } 127 + } 128 + try { 129 + music.addEventListener("authorizationStatusDidChange", (e) => { 130 + console.debug( 131 + "authorizationStatusDidChange", 132 + e && e.authorizationStatus 133 + ); 134 + toggleUnlinkVisibility(); 135 + }); 136 + music.addEventListener("userTokenDidChange", (e) => { 137 + console.debug("userTokenDidChange", !!(e && e.userToken)); 138 + }); 139 + music.addEventListener("playbackStateDidChange", (e) => { 140 + console.debug("playbackStateDidChange", e && e.state); 141 + }); 142 + music.addEventListener("mediaPlayerPlaybackError", (e) => { 143 + console.error("mediaPlayerPlaybackError", e); 144 + }); 145 + } catch (evtErr) { 146 + console.warn( 147 + "Failed to attach some MusicKit event listeners", 148 + evtErr 149 + ); 150 + } 151 + toggleUnlinkVisibility(); 152 + document 153 + .getElementById("authorizeBtn") 154 + .addEventListener("click", async () => { 155 + try { 156 + const music = window.MusicKit.getInstance(); 157 + const userToken = await music.authorize(); 158 + await saveUserToken(userToken); 159 + status.textContent = "Authorized and saved."; 160 + toggleUnlinkVisibility(); 161 + } catch (e) { 162 + console.error("Authorization failed", e); 163 + status.textContent = 164 + "Authorization failed: " + 165 + (e && e.message ? e.message : String(e)); 166 + } 167 + }); 168 + } catch (e) { 169 + console.error("MusicKit setup failed", e); 170 + status.textContent = 171 + "Setup failed: " + (e && e.message ? e.message : String(e)); 172 + } 173 + } 174 + // Global error hooks for extra diagnostics 175 + window.addEventListener("error", (ev) => { 176 + console.error("Window error", ev && ev.error ? ev.error : ev); 177 + }); 178 + window.addEventListener("unhandledrejection", (ev) => { 179 + console.error("Unhandled rejection", ev && ev.reason ? ev.reason : ev); 180 + }); 181 + 182 + setup(); 183 + </script> 184 + </body> 185 + </html> 186 + {{ end }}
+32 -17
pages/templates/components/navBar.gohtml
··· 1 1 {{ define "components/navBar" }} 2 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> 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 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 }} 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 1 {{ define "content" }} 3 2 4 - <h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 5 - {{ template "components/navBar" .NavBar }} 3 + <h1 class="text-[#1DB954]"> 4 + Piper - Multi-User Spotify & Last.fm Tracker via ATProto 5 + </h1> 6 + {{ template "components/navBar" .NavBar }} 6 7 8 + <div class="border border-gray-300 rounded-lg p-5 mb-5"> 9 + <h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2> 10 + <p class="mb-3"> 11 + Piper is a multi-user application that records what you're listening to on 12 + Spotify and Last.fm, saving your listening history. 13 + </p> 7 14 8 - <div class="border border-gray-300 rounded-lg p-5 mb-5"> 9 - <h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2> 10 - <p class="mb-3">Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p> 11 - 12 - {{if .NavBar.IsLoggedIn}} 13 - <p class="mb-2">You're logged in!</p> 14 - <ul class="list-disc pl-5 mb-3"> 15 - <li><a class="text-[#1DB954] font-bold" href="/login/spotify">Connect your Spotify account</a> to start tracking.</li> 16 - <li><a class="text-[#1DB954] font-bold" href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li> 17 - </ul> 18 - <p class="mb-2">Once connected, you can check out your:</p> 19 - <ul class="list-disc pl-5 mb-3"> 20 - <li><a class="text-[#1DB954] font-bold" href="/current-track">Spotify current track</a> or <a class="text-[#1DB954] font-bold" href="/history">listening history</a>.</li> 21 - {{ if .NavBar.LastFMUsername }} 22 - <li><a class="text-[#1DB954] font-bold" href="/lastfm/recent">Last.fm recent tracks</a>.</li> 23 - {{ end }} 24 - 25 - </ul> 26 - <p class="mb-3">You can also manage your <a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for programmatic access.</p> 27 - 28 - {{ if .NavBar.LastFMUsername }} 29 - <p class='italic text-gray-600'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p> 30 - {{else }} 31 - <p class='italic text-gray-600'>Last.fm account not linked.</p> 32 - {{end}} 33 - 15 + {{if .NavBar.IsLoggedIn}} 16 + <p class="mb-2">You're logged in!</p> 17 + <ul class="list-disc pl-5 mb-3"> 18 + <li> 19 + <a class="text-[#1DB954] font-bold" href="/login/spotify" 20 + >Connect your Spotify account</a 21 + > 22 + to start tracking. 23 + </li> 24 + <li> 25 + <a class="text-[#1DB954] font-bold" href="/link-lastfm" 26 + >Link your Last.fm account</a 27 + > 28 + to track scrobbles. 29 + </li> 30 + <li> 31 + <a class="text-[#1DB954] font-bold" href="/link-applemusic" 32 + >Link your Apple Music account</a 33 + > 34 + to fetch recently played. 35 + </li> 36 + </ul> 37 + <p class="mb-2">Once connected, you can check out your:</p> 38 + <ul class="list-disc pl-5 mb-3"> 39 + <li> 40 + <a class="text-[#1DB954] font-bold" href="/current-track" 41 + >Spotify current track</a 42 + > 43 + or 44 + <a class="text-[#1DB954] font-bold" href="/history">listening history</a>. 45 + </li> 46 + {{ if .NavBar.LastFMUsername }} 47 + <li> 48 + <a class="text-[#1DB954] font-bold" href="/lastfm/recent" 49 + >Last.fm recent tracks</a 50 + >. 51 + </li> 52 + {{ 53 + end 54 + }} 55 + </ul> 56 + <p class="mb-3"> 57 + You can also manage your 58 + <a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for 59 + programmatic access. 60 + </p> 34 61 35 - {{ else }} 62 + {{ if .NavBar.LastFMUsername }} 63 + <p class="italic text-gray-600"> 64 + Last.fm Username: {{ .NavBar.LastFMUsername }} 65 + </p> 66 + {{else }} 67 + <p class="italic text-gray-600">Last.fm account not linked.</p> 68 + {{ end }} 36 69 37 - <p class="mb-3">Login with ATProto to get started!</p> 38 - <form class="space-y-2" action="/login/atproto"> 39 - <label class="block" for="handle">handle:</label> 40 - <input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="handle" name="handle" > 41 - <input class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="submit"> 42 - </form> 70 + {{ else }} 43 71 72 + <p class="mb-3">Login with ATProto to get started!</p> 73 + <form class="space-y-2" action="/login/atproto"> 74 + <label class="block" for="handle">handle:</label> 75 + <input 76 + class="block w-[95%] p-2 border border-gray-300 rounded" 77 + type="text" 78 + id="handle" 79 + name="handle" 80 + /> 81 + <input 82 + class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" 83 + type="submit" 84 + value="submit" 85 + /> 86 + </form> 44 87 45 - {{ end }} 46 - </div> <!-- Close card div --> 88 + {{ end }} 89 + </div> 90 + <!-- Close card div --> 47 91 48 - {{ end }} 92 + {{ end }}
+472
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 + httpClient *http.Client 48 + logger *log.Logger 49 + } 50 + 51 + func NewService(teamID, keyID, privateKeyPath string) *Service { 52 + return &Service{ 53 + teamID: teamID, 54 + keyID: keyID, 55 + privateKeyPath: privateKeyPath, 56 + httpClient: &http.Client{Timeout: 10 * time.Second}, 57 + logger: log.New(os.Stdout, "applemusic: ", log.LstdFlags|log.Lmsgprefix), 58 + } 59 + } 60 + 61 + // WithPersistence wires DB-backed getters/setters for token caching 62 + func (s *Service) WithPersistence( 63 + get func() (string, time.Time, bool, error), 64 + save func(string, time.Time) error, 65 + ) *Service { 66 + s.getToken = get 67 + s.saveToken = save 68 + return s 69 + } 70 + 71 + // WithDeps wires services needed for ingestion 72 + func (s *Service) WithDeps(database *db.DB, atproto *atprotoauth.ATprotoAuthService, mb *musicbrainz.MusicBrainzService) *Service { 73 + s.DB = database 74 + s.atprotoService = atproto 75 + s.mbService = mb 76 + return s 77 + } 78 + 79 + func (s *Service) HandleDeveloperToken(w http.ResponseWriter, r *http.Request) { 80 + force := r.URL.Query().Get("refresh") == "1" 81 + token, exp, err := s.GenerateDeveloperTokenWithForce(force) 82 + if err != nil { 83 + http.Error(w, fmt.Sprintf("failed to generate token: %v", err), http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + w.WriteHeader(http.StatusOK) 89 + w.Write([]byte(fmt.Sprintf(`{"token":"%s","expiresAt":"%s"}`, token, exp.UTC().Format(time.RFC3339)))) 90 + } 91 + 92 + // GenerateDeveloperTokenWithForce allows bypassing caches when force is true. 93 + func (s *Service) GenerateDeveloperTokenWithForce(force bool) (string, time.Time, error) { 94 + if !force { 95 + return s.GenerateDeveloperToken() 96 + } 97 + 98 + // Bypass caches and regenerate 99 + privKey, err := s.loadPrivateKey() 100 + if err != nil { 101 + return "", time.Time{}, err 102 + } 103 + 104 + if s.keyID == "" { 105 + return "", time.Time{}, errors.New("applemusic key_id is not configured") 106 + } 107 + 108 + now := time.Now().UTC() 109 + exp := now.Add(180 * 24 * time.Hour).Add(-1 * time.Hour) 110 + 111 + builder := jwt.NewBuilder(). 112 + Issuer(s.teamID). 113 + IssuedAt(now). 114 + Expiration(exp); 115 + 116 + unsignedToken, err := builder.Build() 117 + if err != nil { 118 + return "", time.Time{}, err 119 + } 120 + 121 + headers := jws.NewHeaders() 122 + _ = headers.Set(jws.KeyIDKey, s.keyID) 123 + signed, err := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privKey, jws.WithProtectedHeaders(headers))) 124 + if err != nil { 125 + return "", time.Time{}, err 126 + } 127 + 128 + final := string(signed) 129 + 130 + s.mu.Lock() 131 + s.cachedToken = final 132 + s.cachedExpiry = exp 133 + s.mu.Unlock() 134 + 135 + if s.saveToken != nil { 136 + _ = s.saveToken(final, exp) 137 + } 138 + 139 + return final, exp, nil 140 + } 141 + 142 + // GenerateDeveloperToken returns a cached valid token or creates a new one. 143 + func (s *Service) GenerateDeveloperToken() (string, time.Time, error) { 144 + if s.keyID == "" { 145 + return "", time.Time{}, errors.New("applemusic key_id is not configured") 146 + } 147 + s.mu.RLock() 148 + if s.cachedToken != "" && time.Until(s.cachedExpiry) > 5*time.Minute { 149 + token := s.cachedToken 150 + exp := s.cachedExpiry 151 + s.mu.RUnlock() 152 + // Validate cached token claims (aud, iss) to avoid serving bad tokens 153 + if s.isTokenStructurallyValid(token) { 154 + return token, exp, nil 155 + } 156 + } else { 157 + s.mu.RUnlock() 158 + } 159 + 160 + // Try DB cache if available 161 + if s.getToken != nil { 162 + if t, e, ok, err := s.getToken(); err == nil && ok { 163 + if time.Until(e) > 5*time.Minute && s.isTokenStructurallyValid(t) { 164 + s.mu.Lock() 165 + s.cachedToken = t 166 + s.cachedExpiry = e 167 + s.mu.Unlock() 168 + return t, e, nil 169 + } 170 + } 171 + } 172 + 173 + privKey, err := s.loadPrivateKey() 174 + if err != nil { 175 + return "", time.Time{}, err 176 + } 177 + 178 + now := time.Now().UTC() 179 + // Apple allows up to 6 months validity; choose 6 months minus a small buffer 180 + exp := now.Add(180 * 24 * time.Hour).Add(-1 * time.Hour) 181 + 182 + builder := jwt.NewBuilder(). 183 + Issuer(s.teamID). 184 + IssuedAt(now). 185 + Expiration(exp) 186 + 187 + unsignedToken, err := builder.Build() 188 + if err != nil { 189 + return "", time.Time{}, err 190 + } 191 + 192 + headers := jws.NewHeaders() 193 + _ = headers.Set(jws.KeyIDKey, s.keyID) 194 + signed, err := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privKey, jws.WithProtectedHeaders(headers))) 195 + if err != nil { 196 + return "", time.Time{}, err 197 + } 198 + 199 + final := string(signed) 200 + 201 + s.mu.Lock() 202 + s.cachedToken = final 203 + s.cachedExpiry = exp 204 + s.mu.Unlock() 205 + 206 + if s.saveToken != nil { 207 + _ = s.saveToken(final, exp) 208 + } 209 + 210 + return final, exp, nil 211 + } 212 + 213 + // isTokenStructurallyValid parses without verification and checks claims for iss and exp 214 + func (s *Service) isTokenStructurallyValid(token string) bool { 215 + if token == "" { 216 + return false 217 + } 218 + parsed, err := jwt.Parse([]byte(token), jwt.WithVerify(false)) 219 + if err != nil { 220 + return false 221 + } 222 + // Check issuer 223 + if parsed.Issuer() != s.teamID { 224 + return false 225 + } 226 + // Check expiration not too close 227 + if time.Until(parsed.Expiration()) <= 5*time.Minute { 228 + return false 229 + } 230 + return true 231 + } 232 + 233 + func (s *Service) loadPrivateKey() (*ecdsa.PrivateKey, error) { 234 + if s.privateKeyPath == "" { 235 + return nil, errors.New("applemusic private key path not configured") 236 + } 237 + pemBytes, err := os.ReadFile(s.privateKeyPath) 238 + if err != nil { 239 + return nil, fmt.Errorf("reading private key: %w", err) 240 + } 241 + block, _ := pem.Decode(pemBytes) 242 + if block == nil || len(block.Bytes) == 0 { 243 + return nil, errors.New("invalid PEM data for private key") 244 + } 245 + pkcs8, err := x509.ParsePKCS8PrivateKey(block.Bytes) 246 + if err != nil { 247 + return nil, fmt.Errorf("parsing PKCS#8 key: %w", err) 248 + } 249 + key, ok := pkcs8.(*ecdsa.PrivateKey) 250 + if !ok { 251 + return nil, errors.New("private key is not ECDSA") 252 + } 253 + return key, nil 254 + } 255 + 256 + // ------- Recent Played Tracks ingestion ------- 257 + 258 + // appleRecentTrack models a subset of Apple Music API track response 259 + type appleRecentTrack struct { 260 + ID string `json:"id"` 261 + Attributes struct { 262 + Name string `json:"name"` 263 + ArtistName string `json:"artistName"` 264 + AlbumName string `json:"albumName"` 265 + DurationInMillis *int64 `json:"durationInMillis"` 266 + Isrc *string `json:"isrc"` 267 + URL string `json:"url"` 268 + PlayParams *struct { 269 + ID string `json:"id"` 270 + Kind string `json:"kind"` 271 + } `json:"playParams"` 272 + } `json:"attributes"` 273 + } 274 + 275 + type recentPlayedResponse struct { 276 + Data []appleRecentTrack `json:"data"` 277 + } 278 + 279 + // FetchRecentPlayedTracks calls Apple Music API for a user token 280 + func (s *Service) FetchRecentPlayedTracks(ctx context.Context, userToken string, limit int) ([]appleRecentTrack, error) { 281 + if limit <= 0 || limit > 50 { 282 + limit = 25 283 + } 284 + devToken, _, err := s.GenerateDeveloperToken() 285 + if err != nil { 286 + return nil, err 287 + } 288 + endpoint := &url.URL{Scheme: "https", Host: "api.music.apple.com", Path: "/v1/me/recent/played/tracks"} 289 + q := endpoint.Query() 290 + q.Set("limit", fmt.Sprintf("%d", limit)) 291 + endpoint.RawQuery = q.Encode() 292 + 293 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) 294 + if err != nil { 295 + return nil, err 296 + } 297 + req.Header.Set("Authorization", "Bearer "+devToken) 298 + req.Header.Set("Music-User-Token", userToken) 299 + 300 + resp, err := s.httpClient.Do(req) 301 + if err != nil { 302 + return nil, err 303 + } 304 + defer resp.Body.Close() 305 + 306 + // Read the full response body to log it 307 + bodyBytes, err := io.ReadAll(resp.Body) 308 + if err != nil { 309 + return nil, fmt.Errorf("failed to read response body: %w", err) 310 + } 311 + 312 + 313 + if resp.StatusCode != http.StatusOK { 314 + return nil, fmt.Errorf("apple music api error: %s", resp.Status) 315 + } 316 + 317 + var parsed recentPlayedResponse 318 + if err := json.Unmarshal(bodyBytes, &parsed); err != nil { 319 + return nil, err 320 + } 321 + return parsed.Data, nil 322 + } 323 + 324 + // toTrack converts appleRecentTrack to internal models.Track 325 + func (s *Service) toTrack(t appleRecentTrack, userID int64) *models.Track { 326 + var duration int64 327 + if t.Attributes.DurationInMillis != nil { 328 + duration = *t.Attributes.DurationInMillis 329 + } 330 + isrc := "" 331 + if t.Attributes.Isrc != nil { 332 + isrc = *t.Attributes.Isrc 333 + } 334 + 335 + // Similar stamping logic to Spotify: stamp if played more than half (or 30 seconds whichever is greater) 336 + // Since Apple Music recent played tracks don't provide play progress, we assume full plays 337 + isStamped := duration > 30000 && duration >= duration/2 338 + 339 + track := &models.Track{ 340 + Name: t.Attributes.Name, 341 + Artist: []models.Artist{{Name: t.Attributes.ArtistName}}, 342 + Album: t.Attributes.AlbumName, 343 + URL: t.Attributes.URL, 344 + DurationMs: duration, 345 + ProgressMs: duration, // Assume full play since Apple Music doesn't provide partial plays 346 + ServiceBaseUrl: "music.apple.com", 347 + ISRC: isrc, 348 + HasStamped: isStamped, 349 + Timestamp: time.Now().UTC(), 350 + } 351 + 352 + if s.mbService != nil { 353 + hydrated, err := musicbrainz.HydrateTrack(s.mbService, *track) 354 + if err == nil && hydrated != nil { 355 + track = hydrated 356 + } 357 + } 358 + return track 359 + } 360 + 361 + // GetCurrentAppleMusicTrack fetches the most recent Apple Music track for a user 362 + func (s *Service) GetCurrentAppleMusicTrack(ctx context.Context, user *models.User) (*appleRecentTrack, error) { 363 + if user.AppleMusicUserToken == nil || *user.AppleMusicUserToken == "" { 364 + return nil, nil 365 + } 366 + 367 + // Only fetch the most recent track (limit=1) 368 + items, err := s.FetchRecentPlayedTracks(ctx, *user.AppleMusicUserToken, 1) 369 + if err != nil { 370 + return nil, err 371 + } 372 + 373 + if len(items) == 0 { 374 + return nil, nil 375 + } 376 + 377 + return &items[0], nil 378 + } 379 + 380 + // ProcessUser checks for new Apple Music tracks and processes them 381 + func (s *Service) ProcessUser(ctx context.Context, user *models.User) error { 382 + if user.AppleMusicUserToken == nil || *user.AppleMusicUserToken == "" { 383 + return nil 384 + } 385 + 386 + // Fetch only the most recent track 387 + currentAppleTrack, err := s.GetCurrentAppleMusicTrack(ctx, user) 388 + if err != nil { 389 + s.logger.Printf("failed to get current Apple Music track for user %d: %v", user.ID, err) 390 + return err 391 + } 392 + 393 + if currentAppleTrack == nil { 394 + s.logger.Printf("no current Apple Music track for user %d", user.ID) 395 + return nil 396 + } 397 + 398 + // Get the last saved track to compare PlayParams.id 399 + lastTracks, err := s.DB.GetRecentTracks(user.ID, 1) 400 + if err != nil { 401 + s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err) 402 + } 403 + 404 + // Check if this is a new track (by PlayParams.id) 405 + if len(lastTracks) > 0 { 406 + lastTrack := lastTracks[0] 407 + // If the URL matches, it's the same track 408 + if lastTrack.URL == currentAppleTrack.Attributes.URL { 409 + s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName) 410 + return nil 411 + } 412 + } 413 + 414 + // Convert to internal track format 415 + track := s.toTrack(*currentAppleTrack, user.ID) 416 + if track == nil || strings.TrimSpace(track.Name) == "" || len(track.Artist) == 0 { 417 + s.logger.Printf("invalid track data for user %d", user.ID) 418 + return nil 419 + } 420 + 421 + // Hydration is handled in toTrack() using MusicBrainz search; no ISRC-only hydration here 422 + 423 + // Save the new track 424 + if _, err := s.DB.SaveTrack(user.ID, track); err != nil { 425 + s.logger.Printf("failed saving apple track for user %d: %v", user.ID, err) 426 + return err 427 + } 428 + 429 + s.logger.Printf("saved new track for user %d: %s by %s", user.ID, track.Name, track.Artist[0].Name) 430 + 431 + // Submit to PDS 432 + if user.ATProtoDID != nil && user.MostRecentAtProtoSessionID != nil && s.atprotoService != nil { 433 + if err := atprotoservice.SubmitPlayToPDS(ctx, *user.ATProtoDID, *user.MostRecentAtProtoSessionID, track, s.atprotoService); err != nil { 434 + s.logger.Printf("failed submit to PDS for user %d: %v", user.ID, err) 435 + } 436 + } 437 + 438 + return nil 439 + } 440 + 441 + // StartListeningTracker periodically fetches recent plays for Apple Music linked users 442 + func (s *Service) StartListeningTracker(interval time.Duration) { 443 + if s.DB == nil { 444 + if s.logger != nil { s.logger.Printf("DB not configured; Apple Music tracker disabled") } 445 + return 446 + } 447 + ticker := time.NewTicker(interval) 448 + go func() { 449 + s.runOnce(context.Background()) 450 + for range ticker.C { 451 + s.runOnce(context.Background()) 452 + } 453 + }() 454 + } 455 + 456 + func (s *Service) runOnce(ctx context.Context) { 457 + users, err := s.DB.GetAllAppleMusicLinkedUsers() 458 + if err != nil { 459 + s.logger.Printf("error loading Apple Music users: %v", err) 460 + return 461 + } 462 + for _, u := range users { 463 + if ctx.Err() != nil { 464 + return 465 + } 466 + if err := s.ProcessUser(ctx, u); err != nil { 467 + s.logger.Printf("error processing user %d: %v", u.ID, err) 468 + } 469 + } 470 + } 471 + 472 +