mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 11:56:57 -08:00
Compare commits
7 Commits
90121b4fd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ffbbdce4 | ||
| 78712188d2 | |||
| 1af3efd7b4 | |||
| a5d0860292 | |||
| 659b68f11d | |||
| d2d325ba46 | |||
| 9979456719 |
@@ -12,12 +12,11 @@
|
|||||||
- Apple Music \[Planned\]
|
- Apple Music \[Planned\]
|
||||||
|
|
||||||
- WebUI \[In Progress\]
|
- WebUI \[In Progress\]
|
||||||
- Full listening history with time \[Functional\]
|
- Full listening history with time \[Complete\]
|
||||||
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
|
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
|
||||||
- Ability to specify a certain point in time from one datetime to another to list data
|
- Ability to specify a certain point in time from one datetime to another to list data
|
||||||
- Grid maker (3x3-10x10)
|
- Grid maker (3x3-10x10)
|
||||||
- Ability to change artist image
|
- Ability to change artist image
|
||||||
- Multi artist scrobbling
|
- Multi artist scrobbling
|
||||||
- Ability to "sync" offline scrobbles (send from a device to the server)
|
- Live scrobbling to the server (With Now playing status) \[Complete\]
|
||||||
- Live scrobbling to the server (Now playing)
|
|
||||||
- Batch scrobble editor
|
- Batch scrobble editor
|
||||||
|
|||||||
38
db/db.go
38
db/db.go
@@ -18,7 +18,10 @@ func CreateAllTables() error {
|
|||||||
if err := CreateUsersTable(); err != nil {
|
if err := CreateUsersTable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return CreateSessionsTable()
|
if err := CreateSessionsTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return CreateSpotifyLastTrackTable()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDbUrl(dbName bool) string {
|
func GetDbUrl(dbName bool) string {
|
||||||
@@ -88,7 +91,6 @@ func CreateHistoryTable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move user settings to jsonb in db
|
|
||||||
func CreateUsersTable() error {
|
func CreateUsersTable() error {
|
||||||
_, err := Pool.Exec(context.Background(),
|
_, err := Pool.Exec(context.Background(),
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -97,8 +99,17 @@ func CreateUsersTable() error {
|
|||||||
bio TEXT DEFAULT 'This profile has no bio.',
|
bio TEXT DEFAULT 'This profile has no bio.',
|
||||||
pfp TEXT DEFAULT '/files/assets/pfps/default.png',
|
pfp TEXT DEFAULT '/files/assets/pfps/default.png',
|
||||||
allow_duplicate_edits BOOLEAN DEFAULT FALSE,
|
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
|
pk SERIAL PRIMARY KEY
|
||||||
);`)
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
|
||||||
return err
|
return err
|
||||||
@@ -126,7 +137,26 @@ func CleanupExpiredSessions() error {
|
|||||||
_, err := Pool.Exec(context.Background(),
|
_, err := Pool.Exec(context.Background(),
|
||||||
"DELETE FROM sessions WHERE expires_at < NOW();")
|
"DELETE FROM sessions WHERE expires_at < NOW();")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error cleaning up expired sessions: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error cleaning up sessions: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSpotifyLastTrackTable() error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`CREATE TABLE IF NOT EXISTS spotify_last_track (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(pk) ON DELETE CASCADE,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
song_name TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
album_name TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
progress_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating spotify_last_track table: %v\n", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"muzi/db"
|
"muzi/db"
|
||||||
|
"muzi/scrobble"
|
||||||
"muzi/web"
|
"muzi/web"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -28,5 +29,6 @@ func main() {
|
|||||||
|
|
||||||
check("ensuring all tables exist", db.CreateAllTables())
|
check("ensuring all tables exist", db.CreateAllTables())
|
||||||
check("cleaning expired sessions", db.CleanupExpiredSessions())
|
check("cleaning expired sessions", db.CleanupExpiredSessions())
|
||||||
|
scrobble.StartSpotifyPoller()
|
||||||
web.Start()
|
web.Start()
|
||||||
}
|
}
|
||||||
|
|||||||
362
scrobble/lastfm.go
Normal file
362
scrobble/lastfm.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
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 == "GET" {
|
||||||
|
if r.URL.Query().Get("hs") == "true" {
|
||||||
|
h.handleHandshake(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PostForm.Get("method")
|
||||||
|
apiKey := r.PostForm.Get("api_key")
|
||||||
|
sk := r.PostForm.Get("s")
|
||||||
|
track := r.PostForm.Get("t")
|
||||||
|
|
||||||
|
if method != "" {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sk != "" {
|
||||||
|
if r.PostForm.Get("a[0]") != "" && (r.PostForm.Get("t[0]") != "" || r.PostForm.Get("i[0]") != "") {
|
||||||
|
h.handleScrobble(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if track != "" {
|
||||||
|
h.handleNowPlaying(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respond(w, "failed", 400, "Missing required parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LastFMHandler) respond(w http.ResponseWriter, status string, code int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Write([]byte(fmt.Sprintf("FAILED %s", 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) handleHandshake(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := r.URL.Query().Get("u")
|
||||||
|
token := r.URL.Query().Get("t")
|
||||||
|
authToken := r.URL.Query().Get("a")
|
||||||
|
|
||||||
|
if username == "" || token == "" || authToken == "" {
|
||||||
|
w.Write([]byte("BADAUTH"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
w.Write([]byte("BADAUTH"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey, err := GenerateSessionKey()
|
||||||
|
if err != nil {
|
||||||
|
w.Write([]byte("FAILED Could not generate session"))
|
||||||
|
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)
|
||||||
|
w.Write([]byte("FAILED Database error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte(fmt.Sprintf("OK\n%s\nhttp://127.0.0.1:1234/2.0/\nhttp://127.0.0.1:1234/2.0/\n", sessionKey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PostForm.Get("s")
|
||||||
|
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.PostForm.Get("a")
|
||||||
|
track := r.PostForm.Get("t")
|
||||||
|
album := r.PostForm.Get("b")
|
||||||
|
|
||||||
|
if track == "" {
|
||||||
|
h.respondOK(w, "OK")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := r.PostForm.Get("l")
|
||||||
|
msPlayed := 0
|
||||||
|
if duration != "" {
|
||||||
|
if d, err := strconv.Atoi(duration); err == nil {
|
||||||
|
msPlayed = d * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNowPlaying(NowPlaying{
|
||||||
|
UserId: userId,
|
||||||
|
SongName: track,
|
||||||
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
|
MsPlayed: msPlayed,
|
||||||
|
Platform: "lastfm_api",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
h.respondOK(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LastFMHandler) handleScrobble(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionKey := r.PostForm.Get("s")
|
||||||
|
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.PostForm, 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("OK\n%d\n%d\n", 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("a[0]")
|
||||||
|
track = form.Get("t[0]")
|
||||||
|
album = form.Get("b[0]")
|
||||||
|
timestampStr = form.Get("i[0]")
|
||||||
|
} else {
|
||||||
|
artist = form.Get(fmt.Sprintf("a[%d]", i))
|
||||||
|
track = form.Get(fmt.Sprintf("t[%d]", i))
|
||||||
|
album = form.Get(fmt.Sprintf("b[%d]", i))
|
||||||
|
timestampStr = form.Get(fmt.Sprintf("i[%d]", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist == "" || track == "" || timestampStr == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := form.Get(fmt.Sprintf("l[%d]", i))
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
359
scrobble/scrobble.go
Normal file
359
scrobble/scrobble.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
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]map[string]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 GetUserByUsername(username string) (int, error) {
|
||||||
|
if username == "" {
|
||||||
|
return 0, fmt.Errorf("empty username")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId int
|
||||||
|
err := db.Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT pk FROM users WHERE username = $1", username).Scan(&userId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return userId, 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) {
|
||||||
|
if CurrentNowPlaying[np.UserId] == nil {
|
||||||
|
CurrentNowPlaying[np.UserId] = make(map[string]NowPlaying)
|
||||||
|
}
|
||||||
|
CurrentNowPlaying[np.UserId][np.Platform] = np
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNowPlaying(userId int) (NowPlaying, bool) {
|
||||||
|
platforms := CurrentNowPlaying[userId]
|
||||||
|
if platforms == nil {
|
||||||
|
return NowPlaying{}, false
|
||||||
|
}
|
||||||
|
np, ok := platforms["lastfm_api"]
|
||||||
|
if ok && np.SongName != "" {
|
||||||
|
return np, true
|
||||||
|
}
|
||||||
|
np, ok = platforms["spotify"]
|
||||||
|
if ok && np.SongName != "" {
|
||||||
|
return np, true
|
||||||
|
}
|
||||||
|
return NowPlaying{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearNowPlaying(userId int) {
|
||||||
|
delete(CurrentNowPlaying, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearNowPlayingPlatform(userId int, platform string) {
|
||||||
|
if CurrentNowPlaying[userId] != nil {
|
||||||
|
delete(CurrentNowPlaying[userId], platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserSpotifyCredentials(userId int) (clientId, clientSecret, accessToken, refreshToken string, expiresAt time.Time, err error) {
|
||||||
|
var clientIdPg, clientSecretPg, accessTokenPg, refreshTokenPg pgtype.Text
|
||||||
|
var expiresAtPg pgtype.Timestamptz
|
||||||
|
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(&clientIdPg, &clientSecretPg, &accessTokenPg, &refreshTokenPg, &expiresAtPg)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientIdPg.Status == pgtype.Present {
|
||||||
|
clientId = clientIdPg.String
|
||||||
|
}
|
||||||
|
if clientSecretPg.Status == pgtype.Present {
|
||||||
|
clientSecret = clientSecretPg.String
|
||||||
|
}
|
||||||
|
if accessTokenPg.Status == pgtype.Present {
|
||||||
|
accessToken = accessTokenPg.String
|
||||||
|
}
|
||||||
|
if refreshTokenPg.Status == pgtype.Present {
|
||||||
|
refreshToken = refreshTokenPg.String
|
||||||
|
}
|
||||||
|
if expiresAtPg.Status == pgtype.Present {
|
||||||
|
expiresAt = expiresAtPg.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
510
scrobble/spotify.go
Normal file
510
scrobble/spotify.go
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
package scrobble
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"muzi/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Id string `json:"id"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyCursors struct {
|
||||||
|
After string `json:"after"`
|
||||||
|
Before string `json:"before"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpotifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
if path == "/scrobble/spotify/authorize" {
|
||||||
|
h.handleAuthorize(w, r)
|
||||||
|
} else if path == "/scrobble/spotify/callback" {
|
||||||
|
h.handleCallback(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpotifyHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId := r.URL.Query().Get("user_id")
|
||||||
|
if userId == "" {
|
||||||
|
http.Error(w, "Missing user_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userIdToInt(userId))
|
||||||
|
fmt.Fprintf(os.Stderr, "handleAuthorize: userId=%s, clientId='%s', err=%v\n", userId, clientId, err)
|
||||||
|
if err != nil || clientId == "" {
|
||||||
|
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := getBaseURL(r)
|
||||||
|
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||||
|
|
||||||
|
scope := "user-read-currently-playing user-read-recently-played"
|
||||||
|
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s",
|
||||||
|
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId)
|
||||||
|
|
||||||
|
http.Redirect(w, r, authURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpotifyHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
userId := userIdToInt(state)
|
||||||
|
|
||||||
|
if code == "" || state == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId, clientSecret, _, _, _, err := GetUserSpotifyCredentials(userId)
|
||||||
|
if err != nil || clientId == "" {
|
||||||
|
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := getBaseURL(r)
|
||||||
|
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||||
|
|
||||||
|
token, err := exchangeCodeForToken(clientId, clientSecret, code, redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error exchanging code for token: %v\n", err)
|
||||||
|
http.Error(w, "Failed to authenticate", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = UpdateUserSpotifyTokens(userId, token.AccessToken, token.RefreshToken, token.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error saving Spotify tokens: %v\n", err)
|
||||||
|
http.Error(w, "Failed to save credentials", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<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 {
|
||||||
|
ClearNowPlayingPlatform(userId, "spotify")
|
||||||
|
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 == "" {
|
||||||
|
ClearNowPlayingPlatform(userId, "spotify")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
artistName := ""
|
||||||
|
if len(playing.Item.Artists) > 0 {
|
||||||
|
artistName = playing.Item.Artists[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAndScrobbleHalfway(userId, &playing.Item, playing.ProgressMs)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := time.Parse(time.RFC3339, item.PlayedAt)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " -> failed to parse timestamp %s: %v\n", item.PlayedAt, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobbles = append(scrobbles, Scrobble{
|
||||||
|
UserId: userId,
|
||||||
|
Timestamp: ts,
|
||||||
|
SongName: item.Track.Name,
|
||||||
|
Artist: artistName,
|
||||||
|
Album: item.Track.Album.Name,
|
||||||
|
MsPlayed: item.Track.DurationMs,
|
||||||
|
Platform: "spotify",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveScrobbles(scrobbles)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userIdToInt(s string) int {
|
||||||
|
var id int
|
||||||
|
fmt.Sscanf(s, "%d", &id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBaseURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "localhost:1234" || host == "localhost" {
|
||||||
|
host = "127.0.0.1:1234"
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSpotifyAuthURL(userId int, baseURL string) (string, error) {
|
||||||
|
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userId)
|
||||||
|
if err != nil || clientId == "" {
|
||||||
|
return "", fmt.Errorf("Spotify credentials not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||||
|
scope := "user-read-currently-playing user-read-recently-played"
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%d",
|
||||||
|
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LastTrack struct {
|
||||||
|
UserId int
|
||||||
|
TrackId string
|
||||||
|
SongName string
|
||||||
|
Artist string
|
||||||
|
AlbumName string
|
||||||
|
DurationMs int
|
||||||
|
ProgressMs int
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLastTrack(userId int) (*LastTrack, error) {
|
||||||
|
var track LastTrack
|
||||||
|
err := db.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at
|
||||||
|
FROM spotify_last_track WHERE user_id = $1`,
|
||||||
|
userId).Scan(&track.UserId, &track.TrackId, &track.SongName, &track.Artist,
|
||||||
|
&track.AlbumName, &track.DurationMs, &track.ProgressMs, &track.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLastTrack(userId int, trackId, songName, artist, albumName string, durationMs, progressMs int) error {
|
||||||
|
_, err := db.Pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO spotify_last_track (user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
track_id = $2, song_name = $3, artist = $4, album_name = $5, duration_ms = $6, progress_ms = $7, updated_at = NOW()`,
|
||||||
|
userId, trackId, songName, artist, albumName, durationMs, progressMs)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error saving last track: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAndScrobbleHalfway(userId int, currentTrack *SpotifyTrack, progressMs int) {
|
||||||
|
if currentTrack.Id == "" || currentTrack.DurationMs == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTrack, err := GetLastTrack(userId)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "no rows in result set" {
|
||||||
|
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||||
|
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastTrack.TrackId != currentTrack.Id {
|
||||||
|
if lastTrack.DurationMs > 0 {
|
||||||
|
percentagePlayed := float64(lastTrack.ProgressMs) / float64(lastTrack.DurationMs)
|
||||||
|
if percentagePlayed >= 0.5 || lastTrack.ProgressMs >= 240000 {
|
||||||
|
msPlayed := lastTrack.ProgressMs
|
||||||
|
if msPlayed > lastTrack.DurationMs {
|
||||||
|
msPlayed = lastTrack.DurationMs
|
||||||
|
}
|
||||||
|
scrobble := Scrobble{
|
||||||
|
UserId: userId,
|
||||||
|
Timestamp: lastTrack.UpdatedAt,
|
||||||
|
SongName: lastTrack.SongName,
|
||||||
|
Artist: lastTrack.Artist,
|
||||||
|
Album: lastTrack.AlbumName,
|
||||||
|
MsPlayed: msPlayed,
|
||||||
|
Platform: "spotify",
|
||||||
|
}
|
||||||
|
SaveScrobble(scrobble)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||||
|
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||||
|
} else {
|
||||||
|
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||||
|
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArtistName(artists []SpotifyArtist) string {
|
||||||
|
if len(artists) > 0 {
|
||||||
|
return artists[0].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -397,3 +397,60 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 10px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #1DB954;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{if .NowPlayingTitle}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.NowPlayingArtist}}</td>
|
||||||
|
<td>{{.NowPlayingTitle}}</td>
|
||||||
|
<td>Now Playing</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
{{$artists := .Artists}}
|
{{$artists := .Artists}}
|
||||||
{{$times := .Times}}
|
{{$times := .Times}}
|
||||||
{{range $index, $title := .Titles}}
|
{{range $index, $title := .Titles}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="tab-button active" data-tab="import">Import Data</button>
|
<button class="tab-button active" data-tab="import">Import Data</button>
|
||||||
|
<button class="tab-button" data-tab="scrobble">Scrobble API</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
@@ -52,8 +53,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 and .SpotifyClientId (not .SpotifyConnected)}}
|
||||||
|
<p><a href="/settings/spotify-connect" class="button">Connect Spotify</a></p>
|
||||||
|
<p class="info">Click to authorize Muzi to access your Spotify account.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .SpotifyConnected}}
|
||||||
|
<p class="success">Spotify is connected and importing!</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/files/import.js"></script>
|
<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}}
|
{{end}}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"muzi/db"
|
"muzi/db"
|
||||||
|
"muzi/scrobble"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgtype"
|
"github.com/jackc/pgtype"
|
||||||
@@ -29,6 +30,8 @@ type ProfileData struct {
|
|||||||
Title string
|
Title string
|
||||||
LoggedInUsername string
|
LoggedInUsername string
|
||||||
TemplateName string
|
TemplateName string
|
||||||
|
NowPlayingArtist string
|
||||||
|
NowPlayingTitle string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render a page of the profile in the URL
|
// Render a page of the profile in the URL
|
||||||
@@ -79,6 +82,13 @@ func profilePageHandler() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pageInt == 1 {
|
||||||
|
if np, ok := scrobble.GetNowPlaying(userId); ok {
|
||||||
|
profileData.NowPlayingArtist = np.Artist
|
||||||
|
profileData.NowPlayingTitle = np.SongName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := db.Pool.Query(
|
rows, err := db.Pool.Query(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
"SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
"SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
||||||
|
|||||||
154
web/settings.go
154
web/settings.go
@@ -1,6 +1,22 @@
|
|||||||
package web
|
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 {
|
func settingsPageHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -9,19 +25,143 @@ func settingsPageHandler() http.HandlerFunc {
|
|||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
type data struct {
|
|
||||||
Title string
|
userId, err := getUserIdByUsername(r.Context(), username)
|
||||||
LoggedInUsername string
|
if err != nil {
|
||||||
TemplateName string
|
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",
|
Title: "muzi | Settings",
|
||||||
LoggedInUsername: username,
|
LoggedInUsername: username,
|
||||||
TemplateName: "settings",
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func spotifyConnectHandler(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
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := scrobble.GetUserById(userId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: GetUserById error: %v\n", err)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: userId=%d, SpotifyClientId=%v\n", userId, user.SpotifyClientId)
|
||||||
|
|
||||||
|
if user.SpotifyClientId == nil || *user.SpotifyClientId == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: SpotifyClientId is nil or empty, redirecting to settings\n")
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/scrobble/spotify/authorize?user_id=%d", userId), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
21
web/web.go
21
web/web.go
@@ -10,14 +10,21 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"muzi/db"
|
"muzi/db"
|
||||||
|
"muzi/scrobble"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const serverAddr = "127.0.0.1:1234"
|
||||||
|
|
||||||
// 50 MiB
|
// 50 MiB
|
||||||
const maxHeaderSize int64 = 50 * 1024 * 1024
|
const maxHeaderSize int64 = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
func serverAddrStr() string {
|
||||||
|
return serverAddr
|
||||||
|
}
|
||||||
|
|
||||||
// Holds all the parsed HTML templates
|
// Holds all the parsed HTML templates
|
||||||
var templates *template.Template
|
var templates *template.Template
|
||||||
|
|
||||||
@@ -67,7 +74,7 @@ func rootHandler() http.HandlerFunc {
|
|||||||
|
|
||||||
// Serves all pages at the specified address.
|
// Serves all pages at the specified address.
|
||||||
func Start() {
|
func Start() {
|
||||||
addr := ":1234"
|
addr := serverAddr
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
|
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
|
||||||
@@ -82,7 +89,19 @@ func Start() {
|
|||||||
r.Post("/import/spotify", importSpotifyHandler)
|
r.Post("/import/spotify", importSpotifyHandler)
|
||||||
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
||||||
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
||||||
|
|
||||||
|
r.Handle("/2.0", scrobble.NewLastFMHandler())
|
||||||
|
r.Handle("/2.0/", scrobble.NewLastFMHandler())
|
||||||
|
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/spotify-connect", spotifyConnectHandler)
|
||||||
r.Get("/settings", settingsPageHandler())
|
r.Get("/settings", settingsPageHandler())
|
||||||
|
r.Post("/settings/generate-apikey", generateAPIKeyHandler)
|
||||||
|
r.Post("/settings/update-spotify", updateSpotifyCredentialsHandler)
|
||||||
fmt.Printf("WebUI starting on %s\n", addr)
|
fmt.Printf("WebUI starting on %s\n", addr)
|
||||||
prot := http.NewCrossOriginProtection()
|
prot := http.NewCrossOriginProtection()
|
||||||
http.ListenAndServe(addr, prot.Handler(r))
|
http.ListenAndServe(addr, prot.Handler(r))
|
||||||
|
|||||||
Reference in New Issue
Block a user