Compare commits

..

12 Commits

23 changed files with 2940 additions and 713 deletions

View File

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

View File

@@ -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,17 +91,25 @@ 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 (
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
bio TEXT DEFAULT 'This profile has no bio.', bio TEXT DEFAULT 'This profile has no bio.',
pfp TEXT DEFAULT '/files/assets/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

View File

@@ -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()
} }

View File

@@ -3,7 +3,7 @@ package migrate
// Spotify import functionality for migrating Spotify listening history // Spotify import functionality for migrating Spotify listening history
// from JSON export files into the database // from JSON export files into the database
// This package handles: // This file handles:
// - Parsing Spotify JSON track data // - Parsing Spotify JSON track data
// - Batch processing with deduplication (20-second window) // - Batch processing with deduplication (20-second window)
// - Efficient bulk inserts using pgx.CopyFrom // - Efficient bulk inserts using pgx.CopyFrom

362
scrobble/lastfm.go Normal file
View 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
View 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
View 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
View 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 ""
}

View 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

View 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

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

32
static/menu.js Normal file
View 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();
}
});
});

View File

@@ -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
View 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}}

View File

@@ -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
View 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}}

183
web/auth.go Normal file
View File

@@ -0,0 +1,183 @@
package web
// Functions used to authenticate web UI users.
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"muzi/db"
"golang.org/x/crypto/bcrypt"
)
// Generates a hex string 32 characters in length.
func generateID() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Returns a salted hash of a password if valid (8-64 chars).
func hashPassword(pass []byte) (string, error) {
if len([]rune(string(pass))) < 8 || len(pass) > 64 {
return "", errors.New("Error: Password must be greater than 8 chars.")
}
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
return "", err
}
return string(hashedPassword), nil
}
// Compares a plaintext password and a hashed password. Returns T/F depending
// on comparison result.
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), enteredPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Error while comparing passwords: %v\n", err)
return false
}
return true
}
// Handles the submission of new account credentials. Stores credentials in
// the users table. Sets a browser cookie for successful new users.
func createAccount(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("uname")
if len([]rune(string(username))) == 0 {
http.Redirect(w, r, "/createaccount?error=userlength", http.StatusSeeOther)
return
}
var usertaken bool
err = db.Pool.QueryRow(r.Context(),
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).
Scan(&usertaken)
if usertaken == true {
http.Redirect(w, r, "/createaccount?error=usertaken", http.StatusSeeOther)
return
}
hashedPassword, err := hashPassword([]byte(r.FormValue("pass")))
if err != nil {
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
http.Redirect(w, r, "/createaccount?error=passlength", http.StatusSeeOther)
return
}
_, err = db.Pool.Exec(
r.Context(),
`INSERT INTO users (username, password) VALUES ($1, $2);`,
username,
hashedPassword,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
} else {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30,
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
}
}
}
// Renders the create account page
func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Error string
}
d := data{Error: "len"}
err := templates.ExecuteTemplate(w, "create_account.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
// Handles submission of login credentials by checking if the username
// is in the database and the stored password for that username matches the
// given password. Sets browser cookie on successful login.
func loginSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("uname")
if username == "" {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
return
}
password := r.FormValue("pass")
var storedPassword string
err = db.Pool.QueryRow(r.Context(), "SELECT password FROM users WHERE username = $1;", username).
Scan(&storedPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
}
if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30,
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
}
}
}
// Renders the login page
func loginPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Error string
}
d := data{Error: r.URL.Query().Get("error")}
err := templates.ExecuteTemplate(w, "login.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

311
web/import.go Normal file
View File

@@ -0,0 +1,311 @@
package web
// Functions that the web UI uses for importing
import (
"encoding/json"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"sync"
"muzi/migrate"
)
// Global vars to hold active import jobs and a mutex to lock access to
// importJobs
var (
importJobs = make(map[string]chan migrate.ProgressUpdate)
jobsMu sync.RWMutex
)
// Renders the import page
func importPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
type ImportData struct {
Username string
}
data := ImportData{Username: username}
err := templates.ExecuteTemplate(w, "import.gohtml", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
// Validates and parses tracks from uploaded Spotify JSON files
func parseUploads(uploads []*multipart.FileHeader, w http.ResponseWriter) []migrate.SpotifyTrack {
if len(uploads) < 1 {
http.Error(w, "No files uploaded", http.StatusBadRequest)
return nil
}
if len(uploads) > 30 {
http.Error(w, "Too many files uploaded (30 max)", http.StatusBadRequest)
return nil
}
var allTracks []migrate.SpotifyTrack
for _, u := range uploads {
if u.Size > maxHeaderSize {
fmt.Fprintf(os.Stderr, "File too large: %s\n", u.Filename)
continue
}
if strings.Contains(u.Filename, "..") ||
strings.Contains(u.Filename, "/") ||
strings.Contains(u.Filename, "\x00") {
fmt.Fprintf(os.Stderr, "Invalid filename: %s\n", u.Filename)
continue
}
file, err := u.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", u.Filename, err)
continue
}
reader := io.LimitReader(file, maxHeaderSize)
data, err := io.ReadAll(reader)
file.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", u.Filename, err)
continue
}
if !json.Valid(data) {
http.Error(w, fmt.Sprintf("Invalid JSON in %s", u.Filename),
http.StatusBadRequest)
return nil
}
var tracks []migrate.SpotifyTrack
if err := json.Unmarshal(data, &tracks); err != nil {
fmt.Fprintf(os.Stderr,
"Error parsing %s: %v\n", u.Filename, err)
continue
}
allTracks = append(allTracks, tracks...)
}
return allTracks
}
// Imports the uploaded JSON files into the database
func importSpotifyHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
err = r.ParseMultipartForm(32 * 1024 * 1024) // 32 MiB
if err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
allTracks := parseUploads(r.MultipartForm.File["json_files"], w)
if allTracks == nil {
return
}
jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportSpotify(allTracks, userId, progressChan)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
// Fetch a LastFM account's scrobbles and insert them into the database
func importLastFMHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
lastfmUsername := template.HTMLEscapeString(r.FormValue("lastfm_username"))
lastfmAPIKey := template.HTMLEscapeString(r.FormValue("lastfm_api_key"))
if lastfmUsername == "" || lastfmAPIKey == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportLastFM(lastfmUsername, lastfmAPIKey, userId, progressChan,
username)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
// Controls the progress bar for a LastFM import
func importLastFMProgressHandler(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job")
if jobID == "" {
http.Error(w, "Missing job ID", http.StatusBadRequest)
return
}
jobsMu.RLock()
job, exists := importJobs[jobID]
jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
flusher.Flush()
for update := range job {
data, err := json.Marshal(update)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", string(data))
flusher.Flush()
if update.Status == "completed" || update.Status == "error" {
return
}
}
}
// Controls the progress bar for a Spotify import
func importSpotifyProgressHandler(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job")
if jobID == "" {
http.Error(w, "Missing job ID", http.StatusBadRequest)
return
}
jobsMu.RLock()
job, exists := importJobs[jobID]
jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
flusher.Flush()
for update := range job {
data, err := json.Marshal(update)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", string(data))
flusher.Flush()
if update.Status == "completed" || update.Status == "error" {
return
}
}
}

125
web/profile.go Normal file
View File

@@ -0,0 +1,125 @@
package web
// Functions used for user profiles in the web UI
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
"muzi/db"
"muzi/scrobble"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgtype"
)
type ProfileData struct {
Username string
Bio string
Pfp string
AllowDuplicateEdits bool
ScrobbleCount int
ArtistCount int
Artists []string
Titles []string
Times []time.Time
Page int
Title string
LoggedInUsername string
TemplateName string
NowPlayingArtist string
NowPlayingTitle string
}
// Render a page of the profile in the URL
func profilePageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
pageStr := r.URL.Query().Get("page")
var pageInt int
if pageStr == "" {
pageInt = 1
} else {
pageInt, err = strconv.Atoi(pageStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot convert page URL query from string to int: %v\n", err)
pageInt = 1
}
}
lim := 15
off := (pageInt - 1) * lim
var profileData ProfileData
profileData.Username = username
profileData.Page = pageInt
profileData.Title = username + "'s Profile"
profileData.LoggedInUsername = getLoggedInUsername(r)
profileData.TemplateName = "profile"
err = db.Pool.QueryRow(
r.Context(),
`SELECT bio, pfp, allow_duplicate_edits,
(SELECT COUNT(*) FROM history WHERE user_id = $1) as scrobble_count,
(SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1) as artist_count
FROM users WHERE pk = $1;`,
userId,
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.ArtistCount)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if pageInt == 1 {
if np, ok := scrobble.GetNowPlaying(userId); ok {
profileData.NowPlayingArtist = np.Artist
profileData.NowPlayingTitle = np.SongName
}
}
rows, err := db.Pool.Query(
r.Context(),
"SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId,
lim,
off,
)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT history failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var artist, title string
var time pgtype.Timestamptz
err = rows.Scan(&artist, &title, &time)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profileData.Artists = append(profileData.Artists, artist)
profileData.Titles = append(profileData.Titles, title)
profileData.Times = append(profileData.Times, time.Time)
}
err = templates.ExecuteTemplate(w, "base", profileData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

78
web/session.go Normal file
View File

@@ -0,0 +1,78 @@
package web
// Functions that handle browser login sessions
import (
"context"
"fmt"
"net/http"
"os"
"muzi/db"
)
type Session struct {
Username string
}
func createSession(username string) string {
sessionID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating sessionID: %v\n", err)
return ""
}
_, err = db.Pool.Exec(
context.Background(),
"INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');",
sessionID,
username,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating session: %v\n", err)
return ""
}
return sessionID
}
func getSession(ctx context.Context, sessionID string) *Session {
var username string
err := db.Pool.QueryRow(
ctx,
"SELECT username FROM sessions WHERE session_id = $1 AND expires_at > NOW();",
sessionID,
).Scan(&username)
if err != nil {
return nil
}
return &Session{Username: username}
}
func deleteSession(sessionID string) {
_, err := db.Pool.Exec(
context.Background(),
"DELETE FROM sessions WHERE session_id = $1;",
sessionID,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting session: %v\n", err)
}
}
func getLoggedInUsername(r *http.Request) string {
cookie, err := r.Cookie("session")
if err != nil {
return ""
}
session := getSession(r.Context(), cookie.Value)
if session == nil {
return ""
}
return session.Username
}
func getUserIdByUsername(ctx context.Context, username string) (int, error) {
var userId int
err := db.Pool.QueryRow(ctx, "SELECT pk FROM users WHERE username = $1;", username).
Scan(&userId)
return userId, err
}

167
web/settings.go Normal file
View 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)
}

58
web/utils.go Normal file
View File

@@ -0,0 +1,58 @@
package web
// Functions used in the HTML templates
import (
"fmt"
"time"
)
// Subtracts two integers
func sub(a int, b int) int {
return a - b
}
// Adds two integers
func add(a int, b int) int {
return a + b
}
// Put a comma in the thousands place, ten-thousands place etc.
func formatInt(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
} else {
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")
}

View File

@@ -1,702 +1,107 @@
package web package web
// Main web UI controller
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"io"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"strconv"
"strings"
"sync"
"muzi/db" "muzi/db"
"muzi/migrate" "muzi/scrobble"
"golang.org/x/crypto/bcrypt"
"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"
"github.com/jackc/pgtype"
) )
// 50 MB const serverAddr = "127.0.0.1:1234"
// 50 MiB
const maxHeaderSize int64 = 50 * 1024 * 1024 const maxHeaderSize int64 = 50 * 1024 * 1024
// will add permissions later func serverAddrStr() string {
type Session struct { return serverAddr
Username string
} }
var ( // Holds all the parsed HTML templates
importJobs = make(map[string]chan migrate.ProgressUpdate) var templates *template.Template
jobsMu sync.RWMutex
templates *template.Template
)
// 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"))
} }
func generateID() (string, error) { // Returns T/F if a user is found in the users table
b := make([]byte, 16) func hasUsers(ctx context.Context) bool {
_, err := rand.Read(b) var count int
err := db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM users;").Scan(&count)
if err != nil { if err != nil {
return "", err fmt.Fprintf(os.Stderr, "Error checking for users: %v\n", err)
}
return hex.EncodeToString(b), nil
}
func createSession(username string) string {
sessionID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating sessionID: %v\n", err)
return ""
}
_, err = db.Pool.Exec(
context.Background(),
"INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');",
sessionID,
username,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating session: %v\n", err)
return ""
}
return sessionID
}
func getSession(ctx context.Context, sessionID string) *Session {
var username string
err := db.Pool.QueryRow(
ctx,
"SELECT username FROM sessions WHERE session_id = $1 AND expires_at > NOW();",
sessionID,
).Scan(&username)
if err != nil {
return nil
}
return &Session{Username: username}
}
// for account deletion later
func deleteSession(sessionID string) {
_, err := db.Pool.Exec(
context.Background(),
"DELETE FROM sessions WHERE session_id = $1;",
sessionID,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting session: %v\n", err)
}
}
func getLoggedInUsername(r *http.Request) string {
cookie, err := r.Cookie("session")
if err != nil {
return ""
}
session := getSession(r.Context(), cookie.Value)
if session == nil {
return ""
}
return session.Username
}
type ProfileData struct {
Username string
Bio string
Pfp string
AllowDuplicateEdits bool
ScrobbleCount int
ArtistCount int
Artists []string
Titles []string
Times []string
Page int
}
func sub(a int, b int) int {
return a - b
}
func add(a int, b int) int {
return a + b
}
func formatInt(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
} else {
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
}
}
func getUserIdByUsername(ctx context.Context, username string) (int, error) {
var userId int
err := db.Pool.QueryRow(ctx, "SELECT pk FROM users WHERE username = $1;", username).
Scan(&userId)
return userId, err
}
func hashPassword(pass []byte) (string, error) {
if len([]rune(string(pass))) < 8 || len(pass) > 64 {
return "", errors.New("Error: Password must be greater than 8 chars.")
}
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
return "", err
}
return string(hashedPassword), nil
}
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), enteredPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Error while comparing passwords: %v\n", err)
return false return false
} }
return true return count > 0
} }
func createAccount(w http.ResponseWriter, r *http.Request) { // Controls what is displayed at the root URL.
if r.Method == "POST" { // If logged in: Logged in user's profile page.
err := r.ParseForm() // If logged out: Login page.
if err != nil { // If no users in DB yet: Create account page.
http.Error(w, err.Error(), http.StatusBadRequest) func rootHandler() http.HandlerFunc {
return return func(w http.ResponseWriter, r *http.Request) {
} if !hasUsers(r.Context()) {
username := r.FormValue("uname")
if len([]rune(string(username))) == 0 {
http.Redirect(w, r, "/createaccount?error=userlength", http.StatusSeeOther)
return
}
var usertaken bool
err = db.Pool.QueryRow(r.Context(),
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).
Scan(&usertaken)
if usertaken == true {
http.Redirect(w, r, "/createaccount?error=usertaken", http.StatusSeeOther)
return
}
hashedPassword, err := hashPassword([]byte(r.FormValue("pass")))
if err != nil {
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
http.Redirect(w, r, "/createaccount?error=passlength", http.StatusSeeOther)
return
}
_, err = db.Pool.Exec(
r.Context(),
`INSERT INTO users (username, password) VALUES ($1, $2);`,
username,
hashedPassword,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther) http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
} else {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30, // 30 days
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
}
}
}
func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Error string
}
d := data{Error: "len"}
err := templates.ExecuteTemplate(w, "create_account.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func loginSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
username := r.FormValue("uname")
if username == "" {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
return
}
password := r.FormValue("pass")
var storedPassword string
err = db.Pool.QueryRow(r.Context(), "SELECT password FROM users WHERE username = $1;", username).
Scan(&storedPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
}
if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30, // 30 days
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
}
}
}
func loginPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Error string
}
d := data{Error: r.URL.Query().Get("error")}
err := templates.ExecuteTemplate(w, "login.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func profilePageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
pageStr := r.URL.Query().Get("page")
var pageInt int
if pageStr == "" {
pageInt = 1
} else {
pageInt, err = strconv.Atoi(pageStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot convert page URL query from string to int: %v\n", err)
pageInt = 1
}
}
lim := 15
off := (pageInt - 1) * lim
var profileData ProfileData
profileData.Username = username
profileData.Page = pageInt
err = db.Pool.QueryRow(
r.Context(),
`SELECT bio, pfp, allow_duplicate_edits,
(SELECT COUNT(*) FROM history WHERE user_id = $1) as scrobble_count,
(SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1) as artist_count
FROM users WHERE pk = $1;`,
userId,
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.ArtistCount)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rows, err := db.Pool.Query(
r.Context(),
"SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId,
lim,
off,
)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT history failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var artist, title string
var time pgtype.Timestamptz
err = rows.Scan(&artist, &title, &time)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profileData.Artists = append(profileData.Artists, artist)
profileData.Titles = append(profileData.Titles, title)
profileData.Times = append(profileData.Times, time.Time.String())
}
err = templates.ExecuteTemplate(w, "profile.gohtml", profileData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func updateDuplicateEditsSetting(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("username")
allow := r.FormValue("allow") == "true"
_, err = db.Pool.Exec(
r.Context(),
`UPDATE users SET allow_duplicate_edits = $1 WHERE username = $2;`,
allow,
username,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating setting: %v\n", err)
}
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
}
}
func importPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r) username := getLoggedInUsername(r)
if username == "" { if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
type ImportData struct { http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
Username string
}
data := ImportData{Username: username}
err := templates.ExecuteTemplate(w, "import.gohtml", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func checkUploads(uploads []*multipart.FileHeader, w http.ResponseWriter) []migrate.SpotifyTrack {
if len(uploads) < 1 {
http.Error(w, "No files uploaded", http.StatusBadRequest)
return nil
}
if len(uploads) > 30 {
http.Error(w, "Too many files uploaded (30 max)", http.StatusBadRequest)
return nil
}
var allTracks []migrate.SpotifyTrack
for _, u := range uploads {
if u.Size > maxHeaderSize {
fmt.Fprintf(os.Stderr, "File too large: %s\n", u.Filename)
continue
}
if strings.Contains(u.Filename, "..") ||
strings.Contains(u.Filename, "/") ||
strings.Contains(u.Filename, "\x00") {
fmt.Fprintf(os.Stderr, "Invalid filename: %s\n", u.Filename)
continue
}
file, err := u.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", u.Filename, err)
continue
}
reader := io.LimitReader(file, maxHeaderSize)
data, err := io.ReadAll(reader)
file.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", u.Filename, err)
continue
}
if !json.Valid(data) {
http.Error(w, fmt.Sprintf("Invalid JSON in %s", u.Filename),
http.StatusBadRequest)
return nil
}
var tracks []migrate.SpotifyTrack
if err := json.Unmarshal(data, &tracks); err != nil {
fmt.Fprintf(os.Stderr,
"Error parsing %s: %v\n", u.Filename, err)
continue
}
allTracks = append(allTracks, tracks...)
}
return allTracks
}
func importSpotifyHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
// 32MB memory max
err = r.ParseMultipartForm(32 << 20)
if err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
allTracks := checkUploads(r.MultipartForm.File["json_files"], w)
if allTracks == nil {
return
}
jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportSpotify(allTracks, userId, progressChan)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
func importLastFMHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
lastfmUsername := template.HTMLEscapeString(r.FormValue("lastfm_username"))
lastfmAPIKey := template.HTMLEscapeString(r.FormValue("lastfm_api_key"))
if lastfmUsername == "" || lastfmAPIKey == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportLastFM(lastfmUsername, lastfmAPIKey, userId, progressChan,
username)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
func importLastFMProgressHandler(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job")
if jobID == "" {
http.Error(w, "Missing job ID", http.StatusBadRequest)
return
}
jobsMu.RLock()
job, exists := importJobs[jobID]
jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
flusher.Flush()
for update := range job {
data, err := json.Marshal(update)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", string(data))
flusher.Flush()
if update.Status == "completed" || update.Status == "error" {
return
}
}
}
func importSpotifyProgressHandler(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job")
if jobID == "" {
http.Error(w, "Missing job ID", http.StatusBadRequest)
return
}
jobsMu.RLock()
job, exists := importJobs[jobID]
jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
flusher.Flush()
for update := range job {
data, err := json.Marshal(update)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", string(data))
flusher.Flush()
if update.Status == "completed" || update.Status == "error" {
return
}
} }
} }
// 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"))))
r.Get("/", rootHandler())
r.Get("/login", loginPageHandler()) r.Get("/login", loginPageHandler())
r.Get("/createaccount", createAccountPageHandler()) r.Get("/createaccount", createAccountPageHandler())
r.Get("/profile/{username}", profilePageHandler()) r.Get("/profile/{username}", profilePageHandler())
r.Get("/import", importPageHandler()) r.Get("/import", importPageHandler())
r.Post("/loginsubmit", loginSubmit) r.Post("/loginsubmit", loginSubmit)
r.Post("/createaccountsubmit", createAccount) r.Post("/createaccountsubmit", createAccount)
r.Post("/settings/duplicate-edits", updateDuplicateEditsSetting)
r.Post("/import/lastfm", importLastFMHandler) r.Post("/import/lastfm", importLastFMHandler)
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))