diff --git a/db/db.go b/db/db.go
index c0f596e..aa5723c 100644
--- a/db/db.go
+++ b/db/db.go
@@ -88,7 +88,6 @@ func CreateHistoryTable() error {
return nil
}
-// TODO: move user settings to jsonb in db
func CreateUsersTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS users (
@@ -97,8 +96,17 @@ func CreateUsersTable() error {
bio TEXT DEFAULT 'This profile has no bio.',
pfp TEXT DEFAULT '/files/assets/pfps/default.png',
allow_duplicate_edits BOOLEAN DEFAULT FALSE,
+ api_key TEXT,
+ api_secret TEXT,
+ spotify_client_id TEXT,
+ spotify_client_secret TEXT,
+ spotify_access_token TEXT,
+ spotify_refresh_token TEXT,
+ spotify_token_expires TIMESTAMPTZ,
+ last_spotify_check TIMESTAMPTZ,
pk SERIAL PRIMARY KEY
- );`)
+ );
+ CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
return err
diff --git a/main.go b/main.go
index f154523..b914dd0 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
"os"
"muzi/db"
+ "muzi/scrobble"
"muzi/web"
"github.com/jackc/pgx/v5/pgxpool"
@@ -28,5 +29,6 @@ func main() {
check("ensuring all tables exist", db.CreateAllTables())
check("cleaning expired sessions", db.CleanupExpiredSessions())
+ scrobble.StartSpotifyPoller()
web.Start()
}
diff --git a/scrobble/lastfm.go b/scrobble/lastfm.go
new file mode 100644
index 0000000..70d3cd1
--- /dev/null
+++ b/scrobble/lastfm.go
@@ -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, `
+
+
+ %s
+
+`, 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(`
+
+ %s
+`, 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(`
+
+
+ %s
+ %s
+ 0
+
+`, 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, `
+`)
+}
+
+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(`
+
+
+`, 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
+}
diff --git a/scrobble/listenbrainz.go b/scrobble/listenbrainz.go
new file mode 100644
index 0000000..fd379b4
--- /dev/null
+++ b/scrobble/listenbrainz.go
@@ -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")
+ }
+}
diff --git a/scrobble/scrobble.go b/scrobble/scrobble.go
new file mode 100644
index 0000000..1d402dd
--- /dev/null
+++ b/scrobble/scrobble.go
@@ -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)
+}
diff --git a/scrobble/spotify.go b/scrobble/spotify.go
new file mode 100644
index 0000000..b95f007
--- /dev/null
+++ b/scrobble/spotify.go
@@ -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, `
Spotify connected successfully!
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"
+ }
+ 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
+}
diff --git a/static/style.css b/static/style.css
index a077651..8413e78 100644
--- a/static/style.css
+++ b/static/style.css
@@ -397,3 +397,40 @@
font-size: 14px;
margin-top: 10px;
}
+
+/* Tab Panels */
+.tab-panel {
+ display: none;
+}
+
+.tab-panel.active {
+ display: block;
+}
+
+/* API Key Display */
+.api-key-display {
+ margin: 15px 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 5px;
+}
+
+.api-key-display label {
+ color: #888;
+ font-size: 14px;
+}
+
+.api-key-display code {
+ background: #111;
+ padding: 8px 12px;
+ border-radius: 4px;
+ color: #AFA;
+ font-family: monospace;
+ word-break: break-all;
+}
+
+.success {
+ color: #8F8;
+ margin-top: 10px;
+}
diff --git a/templates/settings.gohtml b/templates/settings.gohtml
index 9a316d2..6811582 100644
--- a/templates/settings.gohtml
+++ b/templates/settings.gohtml
@@ -5,6 +5,7 @@
+
@@ -52,8 +53,69 @@
+
+
+
+
+
API Keys
+
Generate an API key to receive scrobbles from external apps.
+ {{if .APIKey}}
+
+
+ {{.APIKey}}
+
+
+
+ {{.APISecret}}
+
+ {{end}}
+
+
+
+
+
Endpoint URLs
+
Use these URLs in your scrobbling apps:
+
+
+ /2.0/
+
+
+
+ /1/submit-listens
+
+
+
+
+
Spotify Integration
+
Connect your Spotify account to automatically import your listening history.
+
Create a Spotify app at developer.spotify.com and enter your credentials below.
+
+
+
+ {{if .SpotifyConnected}}
+
Spotify is connected and importing!
+ {{end}}
+
+
+
{{end}}
diff --git a/web/settings.go b/web/settings.go
index c8b5d4a..f15e387 100644
--- a/web/settings.go
+++ b/web/settings.go
@@ -1,6 +1,22 @@
package web
-import "net/http"
+import (
+ "fmt"
+ "net/http"
+ "os"
+
+ "muzi/scrobble"
+)
+
+type settingsData struct {
+ Title string
+ LoggedInUsername string
+ TemplateName string
+ APIKey string
+ APISecret string
+ SpotifyClientId string
+ SpotifyConnected bool
+}
func settingsPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -9,19 +25,112 @@ func settingsPageHandler() http.HandlerFunc {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
- type data struct {
- Title string
- LoggedInUsername string
- TemplateName string
+
+ userId, err := getUserIdByUsername(r.Context(), username)
+ if err != nil {
+ http.Error(w, "User not found", http.StatusInternalServerError)
+ return
}
- d := data{
+
+ user, err := scrobble.GetUserById(userId)
+ if err != nil {
+ http.Error(w, "Error loading user", http.StatusInternalServerError)
+ return
+ }
+
+ d := settingsData{
Title: "muzi | Settings",
LoggedInUsername: username,
TemplateName: "settings",
+ APIKey: "",
+ APISecret: "",
+ SpotifyClientId: "",
+ SpotifyConnected: user.IsSpotifyConnected(),
}
- err := templates.ExecuteTemplate(w, "base", d)
+
+ if user.ApiKey != nil {
+ d.APIKey = *user.ApiKey
+ }
+ if user.ApiSecret != nil {
+ d.APISecret = *user.ApiSecret
+ }
+ if user.SpotifyClientId != nil {
+ d.SpotifyClientId = *user.SpotifyClientId
+ }
+
+ err = templates.ExecuteTemplate(w, "base", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
+
+func generateAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
+ username := getLoggedInUsername(r)
+ if username == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+
+ userId, err := getUserIdByUsername(r.Context(), username)
+ if err != nil {
+ http.Error(w, "User not found", http.StatusInternalServerError)
+ return
+ }
+
+ apiKey, err := scrobble.GenerateAPIKey()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating API key: %v\n", err)
+ http.Error(w, "Error generating API key", http.StatusInternalServerError)
+ return
+ }
+
+ apiSecret, err := scrobble.GenerateAPISecret()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating API secret: %v\n", err)
+ http.Error(w, "Error generating API secret", http.StatusInternalServerError)
+ return
+ }
+
+ err = scrobble.UpdateUserAPIKey(userId, apiKey, apiSecret)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error saving API key: %v\n", err)
+ http.Error(w, "Error saving API key", http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
+
+func updateSpotifyCredentialsHandler(w http.ResponseWriter, r *http.Request) {
+ username := getLoggedInUsername(r)
+ if username == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+
+ userId, err := getUserIdByUsername(r.Context(), username)
+ if err != nil {
+ http.Error(w, "User not found", http.StatusInternalServerError)
+ return
+ }
+
+ clientId := r.FormValue("spotify_client_id")
+ clientSecret := r.FormValue("spotify_client_secret")
+
+ if clientId == "" || clientSecret == "" {
+ err = scrobble.DeleteUserSpotifyCredentials(userId)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error removing Spotify credentials: %v\n", err)
+ }
+ } else {
+ err = scrobble.UpdateUserSpotifyCredentials(userId, clientId, clientSecret)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error saving Spotify credentials: %v\n", err)
+ http.Error(w, "Error saving Spotify credentials", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
diff --git a/web/web.go b/web/web.go
index 7db35ce..ed95948 100644
--- a/web/web.go
+++ b/web/web.go
@@ -10,6 +10,7 @@ import (
"os"
"muzi/db"
+ "muzi/scrobble"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -82,7 +83,17 @@ func Start() {
r.Post("/import/spotify", importSpotifyHandler)
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
+
+ r.Post("/2.0/", http.HandlerFunc(scrobble.NewLastFMHandler().ServeHTTP))
+ r.Post("/1/submit-listens", http.HandlerFunc(scrobble.NewListenbrainzHandler().ServeHTTP))
+ r.Route("/scrobble/spotify", func(r chi.Router) {
+ r.Get("/authorize", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
+ r.Get("/callback", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
+ })
+
r.Get("/settings", settingsPageHandler())
+ r.Post("/settings/generate-apikey", generateAPIKeyHandler)
+ r.Post("/settings/update-spotify", updateSpotifyCredentialsHandler)
fmt.Printf("WebUI starting on %s\n", addr)
prot := http.NewCrossOriginProtection()
http.ListenAndServe(addr, prot.Handler(r))