mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 11:56:57 -08:00
Compare commits
10 Commits
5c5b295961
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ffbbdce4 | ||
| 78712188d2 | |||
| 1af3efd7b4 | |||
| a5d0860292 | |||
| 659b68f11d | |||
| d2d325ba46 | |||
| 9979456719 | |||
| 90121b4fd1 | |||
| 78bc1a9974 | |||
| b3c2446add |
@@ -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 ""
|
||||||
|
}
|
||||||
13
static/assets/icons/settings.svg
Normal file
13
static/assets/icons/settings.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!--
|
||||||
|
version: "2.0"
|
||||||
|
unicode: "f69e"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M14.647 4.081a.724 .724 0 0 0 1.08 .448c2.439 -1.485 5.23 1.305 3.745 3.744a.724 .724 0 0 0 .447 1.08c2.775 .673 2.775 4.62 0 5.294a.724 .724 0 0 0 -.448 1.08c1.485 2.439 -1.305 5.23 -3.744 3.745a.724 .724 0 0 0 -1.08 .447c-.673 2.775 -4.62 2.775 -5.294 0a.724 .724 0 0 0 -1.08 -.448c-2.439 1.485 -5.23 -1.305 -3.745 -3.744a.724 .724 0 0 0 -.447 -1.08c-2.775 -.673 -2.775 -4.62 0 -5.294a.724 .724 0 0 0 .448 -1.08c-1.485 -2.439 1.305 -5.23 3.744 -3.745a.722 .722 0 0 0 1.08 -.447c.673 -2.775 4.62 -2.775 5.294 0zm-2.647 4.919a3 3 0 1 0 0 6a3 3 0 0 0 0 -6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 732 B |
14
static/assets/icons/user.svg
Normal file
14
static/assets/icons/user.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!--
|
||||||
|
version: "2.39"
|
||||||
|
unicode: "fd19"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 2a5 5 0 1 1 -5 5l.005 -.217a5 5 0 0 1 4.995 -4.783z" />
|
||||||
|
<path d="M14 14a5 5 0 0 1 5 5v1a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-1a5 5 0 0 1 5 -5h4z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
32
static/menu.js
Normal file
32
static/menu.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const menuButton = document.getElementById('menuButton');
|
||||||
|
const sideMenu = document.getElementById('sideMenu');
|
||||||
|
const menuOverlay = document.getElementById('menuOverlay');
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
menuButton.classList.toggle('active');
|
||||||
|
sideMenu.classList.toggle('active');
|
||||||
|
menuOverlay.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuButton.classList.remove('active');
|
||||||
|
sideMenu.classList.remove('active');
|
||||||
|
menuOverlay.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuButton) {
|
||||||
|
menuButton.addEventListener('click', toggleMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuOverlay) {
|
||||||
|
menuOverlay.addEventListener('click', closeMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
240
static/style.css
240
static/style.css
@@ -1,17 +1,135 @@
|
|||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #222;
|
background-color: #222;
|
||||||
color: #AFA;
|
color: #AFA;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 70vw;
|
max-width: 70vw;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 70vw;
|
width: 70vw;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
padding-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger Menu Button - left side */
|
||||||
|
.menu-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button span {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: #AFA;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button.active span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(5px, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button.active span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button.active span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(5px, -6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-out Menu */
|
||||||
|
.side-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -280px;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
z-index: 999;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu.active {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #AFA;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
color: #EEE;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .menu-icon {
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: invert(1) brightness(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Overlay */
|
||||||
|
.menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.page_buttons {
|
.page_buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -146,6 +264,43 @@
|
|||||||
background: #444;
|
background: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings Tab Navigation */
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #AFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: #AFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #AFA;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@@ -242,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;
|
||||||
|
}
|
||||||
|
|||||||
51
templates/base.gohtml
Normal file
51
templates/base.gohtml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
{{block "head" .}}{{end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Hamburger Menu Button -->
|
||||||
|
<div class="menu-button" id="menuButton">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide-out Menu -->
|
||||||
|
<div class="side-menu" id="sideMenu">
|
||||||
|
<div class="menu-header">
|
||||||
|
<h3>muzi</h3>
|
||||||
|
</div>
|
||||||
|
<nav class="menu-nav">
|
||||||
|
{{if .LoggedInUsername}}
|
||||||
|
<a href="/profile/{{.LoggedInUsername}}" class="menu-item">
|
||||||
|
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
|
||||||
|
<span>My Profile</span>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login" class="menu-item">
|
||||||
|
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
|
||||||
|
<span>Login</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/settings" class="menu-item">
|
||||||
|
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay for closing menu -->
|
||||||
|
<div class="menu-overlay" id="menuOverlay"></div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
|
||||||
|
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
|
||||||
|
|
||||||
|
<script src="/files/menu.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -1,52 +1,47 @@
|
|||||||
<!doctype html>
|
{{define "profile"}}
|
||||||
<html>
|
<div class="profile-top">
|
||||||
<head>
|
<img src="{{.Pfp}}" alt="{{.Username}}'s avatar">
|
||||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
<div class="username-bio">
|
||||||
<title>
|
<h1>{{.Username}}</h1>
|
||||||
muzi | {{.Username}}'s Profile
|
<h2>{{.Bio}}</h2>
|
||||||
</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="profile-top">
|
|
||||||
<img src="{{.Pfp}}" alt="{{.Username}}'s avatar">
|
|
||||||
<div class="username-bio">
|
|
||||||
<h1>{{.Username}}</h1>
|
|
||||||
<h2>{{.Bio}}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="profile-top-blank">
|
|
||||||
</div>
|
|
||||||
<div class="user-stats-top">
|
|
||||||
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
|
|
||||||
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-actions">
|
<div class="profile-top-blank">
|
||||||
<a href="/import" class="btn">Import Data</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="history">
|
<div class="user-stats-top">
|
||||||
<h3>Listening History</h3>
|
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
|
||||||
<table>
|
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
||||||
<tr>
|
</div>
|
||||||
<th>Artist</th>
|
</div>
|
||||||
<th>Title</th>
|
<div class="history">
|
||||||
<th>Timestamp</th>
|
<h3>Listening History</h3>
|
||||||
</tr>
|
<table>
|
||||||
{{$artists := .Artists}}
|
<tr>
|
||||||
{{$times := .Times}}
|
<th>Artist</th>
|
||||||
{{range $index, $title := .Titles}}
|
<th>Title</th>
|
||||||
<tr>
|
<th>Timestamp</th>
|
||||||
<td>{{index $artists $index}}</td>
|
</tr>
|
||||||
<td>{{$title}}</td>
|
{{if .NowPlayingTitle}}
|
||||||
<td>{{index $times $index}}</td>
|
<tr>
|
||||||
</tr>
|
<td>{{.NowPlayingArtist}}</td>
|
||||||
{{end}}
|
<td>{{.NowPlayingTitle}}</td>
|
||||||
</table>
|
<td>Now Playing</td>
|
||||||
</div>
|
</tr>
|
||||||
<div class="page_buttons">
|
|
||||||
{{if gt .Page 1 }}
|
|
||||||
<a href="/profile/{{.Username}}?page={{sub .Page 1}}">Prev Page</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
|
{{$artists := .Artists}}
|
||||||
</div>
|
{{$times := .Times}}
|
||||||
</body>
|
{{range $index, $title := .Titles}}
|
||||||
</html>
|
<tr>
|
||||||
|
<td>{{index $artists $index}}</td>
|
||||||
|
<td>{{$title}}</td>
|
||||||
|
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="page_buttons">
|
||||||
|
{{if gt .Page 1 }}
|
||||||
|
<a href="/profile/{{.Username}}?page={{sub .Page 1}}">Prev Page</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|||||||
126
templates/settings.gohtml
Normal file
126
templates/settings.gohtml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{{define "settings"}}
|
||||||
|
<div class="settings-container">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Import Data Tab -->
|
||||||
|
<div class="tab-panel active" id="import">
|
||||||
|
<div class="import-section">
|
||||||
|
<h2>Spotify</h2>
|
||||||
|
<p>Import your Spotify listening history from your data export.</p>
|
||||||
|
<form id="spotify-form" method="POST" action="/settings/import/spotify" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="json_files" accept=".json,application/json" multiple required>
|
||||||
|
<button type="submit">Upload Spotify Data</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="spotify-progress" class="progress-container" style="display: none;">
|
||||||
|
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
|
||||||
|
<div class="progress-bar-wrapper">
|
||||||
|
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
|
||||||
|
<div class="progress-text" id="spotify-progress-text">0%</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-tracks" id="spotify-progress-tracks"></div>
|
||||||
|
<div class="progress-error" id="spotify-progress-error"></div>
|
||||||
|
<div class="progress-success" id="spotify-progress-success"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="import-section">
|
||||||
|
<h2>Last.fm</h2>
|
||||||
|
<p>Import your Last.fm scrobbles.</p>
|
||||||
|
<form id="lastfm-form" method="POST" action="/settings/import/lastfm">
|
||||||
|
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
|
||||||
|
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
|
||||||
|
<button type="submit">Import from Last.fm</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="lastfm-progress" class="progress-container" style="display: none;">
|
||||||
|
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
|
||||||
|
<div class="progress-bar-wrapper">
|
||||||
|
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
|
||||||
|
<div class="progress-text" id="lastfm-progress-text">0%</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
|
||||||
|
<div class="progress-error" id="lastfm-progress-error"></div>
|
||||||
|
<div class="progress-success" id="lastfm-progress-success"></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>
|
||||||
|
|
||||||
|
<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}}
|
||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"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"
|
||||||
@@ -23,8 +25,13 @@ type ProfileData struct {
|
|||||||
ArtistCount int
|
ArtistCount int
|
||||||
Artists []string
|
Artists []string
|
||||||
Titles []string
|
Titles []string
|
||||||
Times []string
|
Times []time.Time
|
||||||
Page int
|
Page int
|
||||||
|
Title string
|
||||||
|
LoggedInUsername 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
|
||||||
@@ -57,6 +64,9 @@ func profilePageHandler() http.HandlerFunc {
|
|||||||
var profileData ProfileData
|
var profileData ProfileData
|
||||||
profileData.Username = username
|
profileData.Username = username
|
||||||
profileData.Page = pageInt
|
profileData.Page = pageInt
|
||||||
|
profileData.Title = username + "'s Profile"
|
||||||
|
profileData.LoggedInUsername = getLoggedInUsername(r)
|
||||||
|
profileData.TemplateName = "profile"
|
||||||
|
|
||||||
err = db.Pool.QueryRow(
|
err = db.Pool.QueryRow(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
@@ -72,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;",
|
||||||
@@ -97,10 +114,10 @@ func profilePageHandler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
profileData.Artists = append(profileData.Artists, artist)
|
profileData.Artists = append(profileData.Artists, artist)
|
||||||
profileData.Titles = append(profileData.Titles, title)
|
profileData.Titles = append(profileData.Titles, title)
|
||||||
profileData.Times = append(profileData.Times, time.Time.String())
|
profileData.Times = append(profileData.Times, time.Time)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.ExecuteTemplate(w, "profile.gohtml", profileData)
|
err = templates.ExecuteTemplate(w, "base", profileData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
167
web/settings.go
Normal file
167
web/settings.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 {
|
||||||
|
http.Error(w, "Error loading user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := settingsData{
|
||||||
|
Title: "muzi | Settings",
|
||||||
|
LoggedInUsername: username,
|
||||||
|
TemplateName: "settings",
|
||||||
|
APIKey: "",
|
||||||
|
APISecret: "",
|
||||||
|
SpotifyClientId: "",
|
||||||
|
SpotifyConnected: user.IsSpotifyConnected(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
32
web/utils.go
32
web/utils.go
@@ -4,6 +4,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subtracts two integers
|
// Subtracts two integers
|
||||||
@@ -24,3 +25,34 @@ func formatInt(n int) string {
|
|||||||
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
|
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Formats timestamps compared to local time
|
||||||
|
func formatTimestamp(timestamp time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
duration := now.Sub(timestamp)
|
||||||
|
|
||||||
|
if duration < 24*time.Hour {
|
||||||
|
seconds := int(duration.Seconds())
|
||||||
|
if seconds < 60 {
|
||||||
|
return fmt.Sprintf("%d seconds ago", seconds)
|
||||||
|
}
|
||||||
|
minutes := seconds / 60
|
||||||
|
if minutes < 60 {
|
||||||
|
return fmt.Sprintf("%d minutes ago", minutes)
|
||||||
|
}
|
||||||
|
hours := minutes / 60
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
year := now.Year()
|
||||||
|
if timestamp.Year() == year {
|
||||||
|
return timestamp.Format("2 Jan 3:04pm")
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp.Format("2 Jan 2006 3:04pm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full timestamp format for browser hover
|
||||||
|
func formatTimestampFull(timestamp time.Time) string {
|
||||||
|
return timestamp.Format("Monday 2 Jan 2006, 3:04pm")
|
||||||
|
}
|
||||||
|
|||||||
30
web/web.go
30
web/web.go
@@ -10,23 +10,32 @@ 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
|
||||||
|
|
||||||
// Declares all functions for the HTML templates and parses them
|
// Declares all functions for the HTML templates and parses them
|
||||||
func init() {
|
func init() {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
"add": add,
|
"add": add,
|
||||||
"formatInt": formatInt,
|
"formatInt": formatInt,
|
||||||
|
"formatTimestamp": formatTimestamp,
|
||||||
|
"formatTimestampFull": formatTimestampFull,
|
||||||
}
|
}
|
||||||
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
||||||
}
|
}
|
||||||
@@ -65,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"))))
|
||||||
@@ -80,6 +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.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