mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 03:46:57 -08:00
add scrobbling through listenbrainz-like endpoint and lastfm-like endpoint
This commit is contained in:
12
db/db.go
12
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
|
||||
|
||||
2
main.go
2
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()
|
||||
}
|
||||
|
||||
307
scrobble/lastfm.go
Normal file
307
scrobble/lastfm.go
Normal 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
195
scrobble/listenbrainz.go
Normal 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
306
scrobble/scrobble.go
Normal 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
406
scrobble/spotify.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Tab Navigation -->
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-button active" data-tab="import">Import Data</button>
|
||||
<button class="tab-button" data-tab="scrobble">Scrobble API</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@@ -52,8 +53,69 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrobble API Tab -->
|
||||
<div class="tab-panel" id="scrobble">
|
||||
<div class="import-section">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate an API key to receive scrobbles from external apps.</p>
|
||||
{{if .APIKey}}
|
||||
<div class="api-key-display">
|
||||
<label>API Key:</label>
|
||||
<code>{{.APIKey}}</code>
|
||||
</div>
|
||||
<div class="api-key-display">
|
||||
<label>API Secret:</label>
|
||||
<code>{{.APISecret}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/settings/generate-apikey">
|
||||
<button type="submit">{{if .APIKey}}Regenerate{{else}}Generate{{end}} API Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Endpoint URLs</h2>
|
||||
<p>Use these URLs in your scrobbling apps:</p>
|
||||
<div class="api-key-display">
|
||||
<label>Last.fm Compatible:</label>
|
||||
<code>/2.0/</code>
|
||||
</div>
|
||||
<div class="api-key-display">
|
||||
<label>Listenbrainz JSON:</label>
|
||||
<code>/1/submit-listens</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Spotify Integration</h2>
|
||||
<p>Connect your Spotify account to automatically import your listening history.</p>
|
||||
<p>Create a Spotify app at <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a> and enter your credentials below.</p>
|
||||
|
||||
<form method="POST" action="/settings/update-spotify">
|
||||
<input type="text" name="spotify_client_id" placeholder="Spotify Client ID" value="{{.SpotifyClientId}}">
|
||||
<input type="password" name="spotify_client_secret" placeholder="Spotify Client Secret">
|
||||
<button type="submit">Save Spotify Credentials</button>
|
||||
</form>
|
||||
|
||||
{{if .SpotifyConnected}}
|
||||
<p class="success">Spotify is connected and importing!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/files/import.js"></script>
|
||||
<script>
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
button.classList.add('active');
|
||||
document.getElementById(button.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
123
web/settings.go
123
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)
|
||||
}
|
||||
|
||||
11
web/web.go
11
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))
|
||||
|
||||
Reference in New Issue
Block a user