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))