package scrobble import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "sync" "time" ) const SpotifyTokenURL = "https://accounts.spotify.com/api/token" const SpotifyAuthURL = "https://accounts.spotify.com/authorize" const SpotifyAPIURL = "https://api.spotify.com/v1" var ( spotifyClient = &http.Client{Timeout: 30 * time.Second} spotifyMu sync.Mutex ) type SpotifyHandler struct{} func NewSpotifyHandler() *SpotifyHandler { return &SpotifyHandler{} } type SpotifyTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` } type SpotifyCurrentlyPlaying struct { Timestamp int64 `json:"timestamp"` ProgressMs int `json:"progress_ms"` Item SpotifyTrack `json:"item"` CurrentlyPlayingType string `json:"currently_playing_type"` IsPlaying bool `json:"is_playing"` } type SpotifyTrack struct { Name string `json:"name"` DurationMs int `json:"duration_ms"` Artists []SpotifyArtist `json:"artists"` Album SpotifyAlbum `json:"album"` } type SpotifyArtist struct { Name string `json:"name"` } type SpotifyAlbum struct { Name string `json:"name"` } type SpotifyRecentPlays struct { Items []SpotifyPlayItem `json:"items"` Cursors SpotifyCursors `json:"cursors"` } type SpotifyPlayItem struct { Track SpotifyTrack `json:"track"` PlayedAt string `json:"played_at"` PlayedAtMs int64 `json:"played_at_ms"` } type SpotifyCursors struct { After string `json:"after"` Before string `json:"before"` } func (h *SpotifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/scrobble/spotify/authorize" { h.handleAuthorize(w, r) } else if path == "/scrobble/spotify/callback" { h.handleCallback(w, r) } else { http.Error(w, "Not found", http.StatusNotFound) } } func (h *SpotifyHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) { userId := r.URL.Query().Get("user_id") if userId == "" { http.Error(w, "Missing user_id", http.StatusBadRequest) return } clientId, _, _, _, _, err := GetUserSpotifyCredentials(userIdToInt(userId)) fmt.Fprintf(os.Stderr, "handleAuthorize: userId=%s, clientId='%s', err=%v\n", userId, clientId, err) if err != nil || clientId == "" { http.Error(w, "Spotify credentials not configured", http.StatusBadRequest) return } baseURL := getBaseURL(r) redirectURI := baseURL + "/scrobble/spotify/callback" scope := "user-read-currently-playing user-read-recently-played" authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s", SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId) http.Redirect(w, r, authURL, http.StatusSeeOther) } func (h *SpotifyHandler) handleCallback(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") userId := userIdToInt(state) if code == "" || state == "" { http.Error(w, "Missing parameters", http.StatusBadRequest) return } clientId, clientSecret, _, _, _, err := GetUserSpotifyCredentials(userId) if err != nil || clientId == "" { http.Error(w, "Spotify credentials not configured", http.StatusBadRequest) return } baseURL := getBaseURL(r) redirectURI := baseURL + "/scrobble/spotify/callback" token, err := exchangeCodeForToken(clientId, clientSecret, code, redirectURI) if err != nil { fmt.Fprintf(os.Stderr, "Error exchanging code for token: %v\n", err) http.Error(w, "Failed to authenticate", http.StatusInternalServerError) return } err = UpdateUserSpotifyTokens(userId, token.AccessToken, token.RefreshToken, token.ExpiresIn) if err != nil { fmt.Fprintf(os.Stderr, "Error saving Spotify tokens: %v\n", err) http.Error(w, "Failed to save credentials", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, `
You can close this window.
`) } func exchangeCodeForToken(clientId, clientSecret, code, redirectURI string) (*SpotifyTokenResponse, error) { data := url.Values{} data.Set("grant_type", "authorization_code") data.Set("code", code) data.Set("redirect_uri", redirectURI) data.Set("client_id", clientId) data.Set("client_secret", clientSecret) req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := spotifyClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("Spotify token exchange failed: %s", string(body)) } var token SpotifyTokenResponse if err := json.Unmarshal(body, &token); err != nil { return nil, err } return &token, nil } func refreshSpotifyToken(clientId, clientSecret, refreshToken string) (*SpotifyTokenResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientId) data.Set("client_secret", clientSecret) req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := spotifyClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("Spotify token refresh failed: %s", string(body)) } var token SpotifyTokenResponse if err := json.Unmarshal(body, &token); err != nil { return nil, err } return &token, nil } func StartSpotifyPoller() { ticker := time.NewTicker(30 * time.Second) go func() { for range ticker.C { spotifyMu.Lock() users, err := GetUsersWithSpotify() if err != nil { fmt.Fprintf(os.Stderr, "Error getting users with Spotify: %v\n", err) spotifyMu.Unlock() continue } for _, userId := range users { err := pollSpotify(userId) if err != nil { fmt.Fprintf(os.Stderr, "Error polling Spotify for user %d: %v\n", userId, err) } } spotifyMu.Unlock() } }() } func pollSpotify(userId int) error { clientId, clientSecret, accessToken, refreshToken, expiresAt, err := GetUserSpotifyCredentials(userId) if err != nil { return err } if accessToken == "" { return fmt.Errorf("no access token") } if time.Now().After(expiresAt.Add(-60 * time.Second)) { token, err := refreshSpotifyToken(clientId, clientSecret, refreshToken) if err != nil { return err } accessToken = token.AccessToken if token.RefreshToken != "" { refreshToken = token.RefreshToken } UpdateUserSpotifyTokens(userId, accessToken, refreshToken, token.ExpiresIn) } err = checkCurrentlyPlaying(userId, accessToken) if err != nil { fmt.Fprintf(os.Stderr, "Error checking currently playing: %v\n", err) } err = checkRecentPlays(userId, accessToken) if err != nil { fmt.Fprintf(os.Stderr, "Error checking recent plays: %v\n", err) } UpdateUserSpotifyCheck(userId) return nil } func checkCurrentlyPlaying(userId int, accessToken string) error { req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/currently-playing", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := spotifyClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == 204 { ClearNowPlaying(userId) return nil } if resp.StatusCode != 200 { return fmt.Errorf("currently playing returned %d", resp.StatusCode) } var playing SpotifyCurrentlyPlaying if err := json.NewDecoder(resp.Body).Decode(&playing); err != nil { return err } if !playing.IsPlaying || playing.Item.Name == "" { ClearNowPlaying(userId) return nil } artistName := "" if len(playing.Item.Artists) > 0 { artistName = playing.Item.Artists[0].Name } UpdateNowPlaying(NowPlaying{ UserId: userId, SongName: playing.Item.Name, Artist: artistName, Album: playing.Item.Album.Name, MsPlayed: playing.Item.DurationMs, Platform: "spotify", UpdatedAt: time.Now(), }) return nil } func checkRecentPlays(userId int, accessToken string) error { req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/recently-played?limit=50", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := spotifyClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("recently played returned %d", resp.StatusCode) } var recent SpotifyRecentPlays if err := json.NewDecoder(resp.Body).Decode(&recent); err != nil { return err } if len(recent.Items) == 0 { return nil } scrobbles := make([]Scrobble, 0, len(recent.Items)) for _, item := range recent.Items { artistName := "" if len(item.Track.Artists) > 0 { artistName = item.Track.Artists[0].Name } scrobbles = append(scrobbles, Scrobble{ UserId: userId, Timestamp: time.Unix(item.PlayedAtMs/1000, 0).UTC(), SongName: item.Track.Name, Artist: artistName, Album: item.Track.Album.Name, MsPlayed: item.Track.DurationMs, Platform: "spotify", }) } SaveScrobbles(scrobbles) return nil } func userIdToInt(s string) int { var id int fmt.Sscanf(s, "%d", &id) return id } func getBaseURL(r *http.Request) string { scheme := "http" if r.TLS != nil { scheme = "https" } host := r.Host if host == "localhost:1234" || host == "localhost" { host = "127.0.0.1:1234" } return scheme + "://" + host } func GetSpotifyAuthURL(userId int, baseURL string) (string, error) { clientId, _, _, _, _, err := GetUserSpotifyCredentials(userId) if err != nil || clientId == "" { return "", fmt.Errorf("Spotify credentials not configured") } redirectURI := baseURL + "/scrobble/spotify/callback" scope := "user-read-currently-playing user-read-recently-played" return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%d", SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId), nil }