add scrobbling through listenbrainz-like endpoint and lastfm-like endpoint

This commit is contained in:
2026-02-27 23:27:02 -08:00
parent 90121b4fd1
commit 9979456719
10 changed files with 1452 additions and 9 deletions

307
scrobble/lastfm.go Normal file
View File

@@ -0,0 +1,307 @@
package scrobble
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"muzi/db"
)
type LastFMHandler struct{}
func NewLastFMHandler() *LastFMHandler {
return &LastFMHandler{}
}
func (h *LastFMHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseForm()
if err != nil {
h.respond(w, "failed", 400, "Invalid request")
return
}
method := r.FormValue("method")
apiKey := r.FormValue("api_key")
switch method {
case "auth.gettoken":
h.handleGetToken(w, apiKey)
case "auth.getsession":
h.handleGetSession(w, r)
case "track.updateNowPlaying":
h.handleNowPlaying(w, r)
case "track.scrobble":
h.handleScrobble(w, r)
default:
h.respond(w, "failed", 400, fmt.Sprintf("Invalid method: %s", method))
}
}
func (h *LastFMHandler) respond(w http.ResponseWriter, status string, code int, message string) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
fmt.Fprintf(w, `<?xml version="1.0" encoding="utf-8"?>
<lfm status="%s">
<error code="%d">
<message>%s</message>
</error>
</lfm>`, status, code, message)
}
func (h *LastFMHandler) respondOK(w http.ResponseWriter, content string) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Write([]byte(content))
}
func (h *LastFMHandler) handleGetToken(w http.ResponseWriter, apiKey string) {
userId, _, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respond(w, "failed", 10, "Invalid API key")
return
}
token, err := GenerateSessionKey()
if err != nil {
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<token>%s</token>
</lfm>`, token))
_ = userId
}
func (h *LastFMHandler) handleGetSession(w http.ResponseWriter, r *http.Request) {
apiKey := r.FormValue("api_key")
userId, username, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respond(w, "failed", 10, "Invalid API key")
return
}
sessionKey, err := GenerateSessionKey()
if err != nil {
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
_, err = db.Pool.Exec(context.Background(),
`UPDATE users SET api_secret = $1 WHERE pk = $2`,
sessionKey, userId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating session key: %v\n", err)
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<session>
<name>%s</name>
<key>%s</key>
<subscriber>0</subscriber>
</session>
</lfm>`, username, sessionKey))
}
func (h *LastFMHandler) handleNowPlaying(w http.ResponseWriter, r *http.Request) {
sessionKey := r.FormValue("sk")
if sessionKey == "" {
h.respond(w, "failed", 9, "Invalid session")
return
}
userId, _, err := GetUserBySessionKey(sessionKey)
if err != nil {
h.respond(w, "failed", 9, "Invalid session")
return
}
artist := r.FormValue("artist")
track := r.FormValue("track")
album := r.FormValue("album")
duration := r.FormValue("duration")
msPlayed := 0
if duration != "" {
if d, err := strconv.Atoi(duration); err == nil {
msPlayed = d * 1000
}
}
if track != "" {
UpdateNowPlaying(NowPlaying{
UserId: userId,
SongName: track,
Artist: artist,
Album: album,
MsPlayed: msPlayed,
Platform: "lastfm_api",
UpdatedAt: time.Now(),
})
}
h.respondOK(w, `<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok"></lfm>`)
}
func (h *LastFMHandler) handleScrobble(w http.ResponseWriter, r *http.Request) {
sessionKey := r.FormValue("sk")
if sessionKey == "" {
h.respond(w, "failed", 9, "Invalid session")
return
}
userId, _, err := GetUserBySessionKey(sessionKey)
if err != nil {
h.respond(w, "failed", 9, "Invalid session")
return
}
scrobbles := h.parseScrobbles(r.Form, userId)
if len(scrobbles) == 0 {
h.respond(w, "failed", 1, "No scrobbles to submit")
return
}
accepted, ignored := 0, 0
for _, scrobble := range scrobbles {
err := SaveScrobble(scrobble)
if err != nil {
if err.Error() == "duplicate scrobble" {
ignored++
}
continue
}
accepted++
}
ClearNowPlaying(userId)
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<scrobbles accepted="%d" ignored="%d"></scrobbles>
</lfm>`, accepted, ignored))
}
func (h *LastFMHandler) parseScrobbles(form url.Values, userId int) []Scrobble {
var scrobbles []Scrobble
for i := 0; i < 50; i++ {
var artist, track, album, timestampStr string
if i == 0 {
artist = form.Get("artist")
track = form.Get("track")
album = form.Get("album")
timestampStr = form.Get("timestamp")
} else {
artist = form.Get(fmt.Sprintf("artist[%d]", i-1))
track = form.Get(fmt.Sprintf("track[%d]", i-1))
album = form.Get(fmt.Sprintf("album[%d]", i-1))
timestampStr = form.Get(fmt.Sprintf("timestamp[%d]", i-1))
}
if artist == "" || track == "" || timestampStr == "" {
break
}
ts, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
continue
}
duration := form.Get(fmt.Sprintf("duration[%d]", i-1))
msPlayed := 0
if duration != "" {
if d, err := strconv.Atoi(duration); err == nil {
msPlayed = d * 1000
}
}
scrobbles = append(scrobbles, Scrobble{
UserId: userId,
Timestamp: time.Unix(ts, 0).UTC(),
SongName: track,
Artist: artist,
Album: album,
MsPlayed: msPlayed,
Platform: "lastfm_api",
})
}
return scrobbles
}
func SignRequest(params map[string]string, secret string) string {
var keys []string
for k := range params {
keys = append(keys, k)
}
for i := 0; i < len(keys)-1; i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var str string
for _, k := range keys {
str += k + params[k]
}
str += secret
hash := md5.Sum([]byte(str))
return hex.EncodeToString(hash[:])
}
func SignAPIRequest(params map[string]string, secret string) string {
var pairs []string
for k, v := range params {
pairs = append(pairs, k+"="+url.QueryEscape(v))
}
signature := SignRequest(map[string]string{"api_key": params["api_key"], "method": params["method"]}, secret)
return signature
}
func FetchURL(client *http.Client, endpoint, method string, params map[string]string) (string, error) {
data := url.Values{}
for k, v := range params {
data.Set(k, v)
}
req, err := http.NewRequest(method, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}

195
scrobble/listenbrainz.go Normal file
View File

@@ -0,0 +1,195 @@
package scrobble
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type ListenbrainzHandler struct{}
func NewListenbrainzHandler() *ListenbrainzHandler {
return &ListenbrainzHandler{}
}
type SubmitListensRequest struct {
ListenType string `json:"listen_type"`
Payload []ListenPayload `json:"payload"`
}
type ListenPayload struct {
ListenedAt int64 `json:"listened_at"`
TrackMetadata TrackMetadata `json:"track_metadata"`
}
type TrackMetadata struct {
ArtistName string `json:"artist_name"`
TrackName string `json:"track_name"`
ReleaseName string `json:"release_name"`
AdditionalInfo AdditionalInfo `json:"additional_info"`
}
type AdditionalInfo struct {
Duration int `json:"duration"`
}
func (h *ListenbrainzHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey := r.Header.Get("Authorization")
if apiKey == "" {
apiKey = r.URL.Query().Get("token")
}
if apiKey == "" {
h.respondError(w, "No authorization token provided", 401)
return
}
apiKey = stripBearer(apiKey)
userId, _, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respondError(w, "Invalid authorization token", 401)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
h.respondError(w, "Invalid request body", 400)
return
}
defer r.Body.Close()
var req SubmitListensRequest
if err := json.Unmarshal(body, &req); err != nil {
h.respondError(w, "Invalid JSON", 400)
return
}
switch req.ListenType {
case "single":
h.handleScrobbles(w, userId, req.Payload)
case "playing_now":
h.handleNowPlaying(w, userId, req.Payload)
case "import":
h.handleScrobbles(w, userId, req.Payload)
default:
h.respondError(w, "Invalid listen_type", 400)
}
}
func (h *ListenbrainzHandler) respondError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"message": message,
})
}
func (h *ListenbrainzHandler) respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func (h *ListenbrainzHandler) handleScrobbles(w http.ResponseWriter, userId int, payload []ListenPayload) {
if len(payload) == 0 {
h.respondError(w, "No listens provided", 400)
return
}
scrobbles := make([]Scrobble, 0, len(payload))
for _, p := range payload {
duration := 0
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
}
scrobbles = append(scrobbles, Scrobble{
UserId: userId,
Timestamp: time.Unix(p.ListenedAt, 0).UTC(),
SongName: p.TrackMetadata.TrackName,
Artist: p.TrackMetadata.ArtistName,
Album: p.TrackMetadata.ReleaseName,
MsPlayed: duration,
Platform: "listenbrainz",
})
}
accepted, ignored, err := SaveScrobbles(scrobbles)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobbles: %v\n", err)
h.respondError(w, "Error saving scrobbles", 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"accepted": accepted,
"ignored": ignored,
"mbids": []string{},
"submit_token": "",
})
}
func (h *ListenbrainzHandler) handleNowPlaying(w http.ResponseWriter, userId int, payload []ListenPayload) {
if len(payload) == 0 {
h.respondError(w, "No payload provided", 400)
return
}
p := payload[0]
duration := 0
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
}
UpdateNowPlaying(NowPlaying{
UserId: userId,
SongName: p.TrackMetadata.TrackName,
Artist: p.TrackMetadata.ArtistName,
Album: p.TrackMetadata.ReleaseName,
MsPlayed: duration,
Platform: "listenbrainz",
UpdatedAt: time.Now(),
})
h.respondOK(w)
}
func stripBearer(token string) string {
if len(token) > 7 && strings.HasPrefix(token, "Bearer ") {
return token[7:]
}
if len(token) > 6 && strings.HasPrefix(token, "Token ") {
return token[6:]
}
return token
}
func ParseTimestamp(ts interface{}) (time.Time, error) {
switch v := ts.(type) {
case float64:
return time.Unix(int64(v), 0).UTC(), nil
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(i, 0).UTC(), nil
default:
return time.Time{}, fmt.Errorf("unknown timestamp type")
}
}

306
scrobble/scrobble.go Normal file
View File

@@ -0,0 +1,306 @@
package scrobble
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"time"
"muzi/db"
"github.com/jackc/pgtype"
)
const DuplicateToleranceSeconds = 20
type Scrobble struct {
UserId int
Timestamp time.Time
SongName string
Artist string
Album string
MsPlayed int
Platform string
Source string
}
type NowPlaying struct {
UserId int
SongName string
Artist string
Album string
MsPlayed int
Platform string
UpdatedAt time.Time
}
var CurrentNowPlaying = make(map[int]NowPlaying)
func GenerateAPIKey() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GenerateAPISecret() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GenerateSessionKey() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GetUserByAPIKey(apiKey string) (int, string, error) {
if apiKey == "" {
return 0, "", fmt.Errorf("empty API key")
}
var userId int
var username string
err := db.Pool.QueryRow(context.Background(),
"SELECT pk, username FROM users WHERE api_key = $1", apiKey).Scan(&userId, &username)
if err != nil {
return 0, "", err
}
return userId, username, nil
}
func GetUserBySessionKey(sessionKey string) (int, string, error) {
if sessionKey == "" {
return 0, "", fmt.Errorf("empty session key")
}
var userId int
var username string
err := db.Pool.QueryRow(context.Background(),
"SELECT pk, username FROM users WHERE api_secret = $1", sessionKey).Scan(&userId, &username)
if err != nil {
return 0, "", err
}
return userId, username, nil
}
func SaveScrobble(scrobble Scrobble) error {
exists, err := checkDuplicate(scrobble.UserId, scrobble.Artist, scrobble.SongName, scrobble.Timestamp)
if err != nil {
return err
}
if exists {
return fmt.Errorf("duplicate scrobble")
}
_, err = db.Pool.Exec(context.Background(),
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist,
scrobble.Album, scrobble.MsPlayed, scrobble.Platform)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
return err
}
return nil
}
func SaveScrobbles(scrobbles []Scrobble) (int, int, error) {
if len(scrobbles) == 0 {
return 0, 0, nil
}
accepted := 0
ignored := 0
batchSize := 100
for i := 0; i < len(scrobbles); i += batchSize {
end := i + batchSize
if end > len(scrobbles) {
end = len(scrobbles)
}
for _, scrobble := range scrobbles[i:end] {
err := SaveScrobble(scrobble)
if err != nil {
if err.Error() == "duplicate scrobble" {
ignored++
} else {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
}
continue
}
accepted++
}
}
return accepted, ignored, nil
}
func checkDuplicate(userId int, artist, songName string, timestamp time.Time) (bool, error) {
var exists bool
err := db.Pool.QueryRow(context.Background(),
`SELECT EXISTS(
SELECT 1 FROM history
WHERE user_id = $1
AND artist = $2
AND song_name = $3
AND ABS(EXTRACT(EPOCH FROM (timestamp - $4))) < $5
)`,
userId, artist, songName, timestamp, DuplicateToleranceSeconds).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func UpdateNowPlaying(np NowPlaying) {
CurrentNowPlaying[np.UserId] = np
}
func GetNowPlaying(userId int) (NowPlaying, bool) {
np, ok := CurrentNowPlaying[userId]
return np, ok
}
func ClearNowPlaying(userId int) {
delete(CurrentNowPlaying, userId)
}
func GetUserSpotifyCredentials(userId int) (clientId, clientSecret, accessToken, refreshToken string, expiresAt time.Time, err error) {
err = db.Pool.QueryRow(context.Background(),
`SELECT spotify_client_id, spotify_client_secret, spotify_access_token,
spotify_refresh_token, spotify_token_expires
FROM users WHERE pk = $1`,
userId).Scan(&clientId, &clientSecret, &accessToken, &refreshToken, &expiresAt)
if err != nil {
return "", "", "", "", time.Time{}, err
}
return clientId, clientSecret, accessToken, refreshToken, expiresAt, nil
}
func UpdateUserSpotifyTokens(userId int, accessToken, refreshToken string, expiresIn int) error {
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET
spotify_access_token = $1,
spotify_refresh_token = $2,
spotify_token_expires = $3
WHERE pk = $4`,
accessToken, refreshToken, expiresAt, userId)
return err
}
func UpdateUserSpotifyCheck(userId int) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET last_spotify_check = $1 WHERE pk = $2`,
time.Now(), userId)
return err
}
func GetUsersWithSpotify() ([]int, error) {
rows, err := db.Pool.Query(context.Background(),
`SELECT pk FROM users WHERE spotify_client_id IS NOT NULL AND spotify_client_secret IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
var userIds []int
for rows.Next() {
var userId int
if err := rows.Scan(&userId); err != nil {
return nil, err
}
userIds = append(userIds, userId)
}
return userIds, nil
}
type User struct {
Pk int
Username string
Bio string
Pfp string
AllowDuplicateEdits bool
ApiKey *string
ApiSecret *string
SpotifyClientId *string
SpotifyClientSecret *string
}
func GetUserById(userId int) (User, error) {
var user User
var apiKey, apiSecret, spotifyClientId, spotifyClientSecret pgtype.Text
err := db.Pool.QueryRow(context.Background(),
`SELECT pk, username, bio, pfp, allow_duplicate_edits, api_key, api_secret,
spotify_client_id, spotify_client_secret
FROM users WHERE pk = $1`,
userId).Scan(&user.Pk, &user.Username, &user.Bio, &user.Pfp,
&user.AllowDuplicateEdits, &apiKey, &apiSecret, &spotifyClientId, &spotifyClientSecret)
if err != nil {
return User{}, err
}
if apiKey.Status == pgtype.Present {
user.ApiKey = &apiKey.String
}
if apiSecret.Status == pgtype.Present {
user.ApiSecret = &apiSecret.String
}
if spotifyClientId.Status == pgtype.Present {
user.SpotifyClientId = &spotifyClientId.String
}
if spotifyClientSecret.Status == pgtype.Present {
user.SpotifyClientSecret = &spotifyClientSecret.String
}
return user, nil
}
func UpdateUserAPIKey(userId int, apiKey, apiSecret string) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET api_key = $1, api_secret = $2 WHERE pk = $3`,
apiKey, apiSecret, userId)
return err
}
func UpdateUserSpotifyCredentials(userId int, clientId, clientSecret string) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET spotify_client_id = $1, spotify_client_secret = $2 WHERE pk = $3`,
clientId, clientSecret, userId)
return err
}
func DeleteUserSpotifyCredentials(userId int) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET
spotify_client_id = NULL,
spotify_client_secret = NULL,
spotify_access_token = NULL,
spotify_refresh_token = NULL,
spotify_token_expires = NULL
WHERE pk = $1`,
userId)
return err
}
func (u *User) IsSpotifyConnected() bool {
_, _, accessToken, _, expiresAt, err := GetUserSpotifyCredentials(u.Pk)
if err != nil || accessToken == "" {
return false
}
return time.Now().Before(expiresAt)
}

406
scrobble/spotify.go Normal file
View File

@@ -0,0 +1,406 @@
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 == "/authorize" {
h.handleAuthorize(w, r)
} else if path == "/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))
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, `<html><body><h1>Spotify connected successfully!</h1><p>You can close this window.</p><script>setTimeout(() => window.close(), 2000);</script></body></html>`)
}
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"
}
return scheme + "://" + r.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
}