Compare commits

...

14 Commits

23 changed files with 3201 additions and 940 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

@@ -1,7 +1,16 @@
package migrate package migrate
// Spotify import functionality for migrating Spotify listening history
// from JSON export files into the database
// This file handles:
// - Parsing Spotify JSON track data
// - Batch processing with deduplication (20-second window)
// - Efficient bulk inserts using pgx.CopyFrom
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -12,231 +21,65 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
const batchSize = 1000 const (
batchSize = 1000
minPlayTime = 20000 // 20000 ms = 20 sec
timeDiff = 20 * time.Second
)
// Represents a single listening event from Spotify's JSON export format
type SpotifyTrack struct { type SpotifyTrack struct {
Timestamp string `json:"ts"` Timestamp time.Time `json:"ts"`
Played int `json:"ms_played"` Played int `json:"ms_played"`
Name string `json:"master_metadata_track_name"` Name string `json:"master_metadata_track_name"`
Artist string `json:"master_metadata_album_artist_name"` Artist string `json:"master_metadata_album_artist_name"`
Album string `json:"master_metadata_album_album_name"` Album string `json:"master_metadata_album_album_name"`
} }
// Implements pgx.CopyFromSource for efficient bulk inserts.
// Filters out duplicates in-memory before sending to PostgreSQL
type trackSource struct { type trackSource struct {
tracks []SpotifyTrack tracks []SpotifyTrack // Full batch of tracks to process
tracksToSkip map[string]struct{} tracksToSkip map[string]struct{} // Set of duplicate keys to skip
idx int idx int // Current position in tracks slice
userId int userId int // User ID to associate with imported tracks
} }
// Represents a track already stored in the database, used for duplicate
// detection during import
type dbTrack struct { type dbTrack struct {
Timestamp time.Time Timestamp time.Time
SongName string SongName string
Artist string Artist string
} }
func (s *trackSource) Next() bool { // Import Spotify listening history into the database.
for s.idx < len(s.tracks) { // Processes tracks in batches of 1000 (default), filters out tracks played <
t := s.tracks[s.idx] // 20 seconds, deduplicates against existing data, and sends progress updates
ts, err := normalizeTs(t.Timestamp) // via progressChan.
if err != nil { // The progressChan must not be closed by the caller. The receiver should
fmt.Fprintf(os.Stderr, "Error normalizing timestamp: %v\n", err) // stop reading when Status is "completed". This avoids panics from
s.idx++ // sending on a closed channel.
continue
}
key := fmt.Sprintf("%s|%s|%s", t.Artist, t.Name, ts)
if _, shouldSkip := s.tracksToSkip[key]; shouldSkip {
s.idx++
continue
}
s.idx++
return true
}
return false
}
func (s *trackSource) Values() ([]any, error) { func ImportSpotify(tracks []SpotifyTrack,
// idx is already incremented in Next(), so use idx-1 userId int, progressChan chan ProgressUpdate,
t := s.tracks[s.idx-1] ) {
ts, err := time.Parse(time.RFC3339Nano, t.Timestamp)
if err != nil {
return nil, err
}
return []any{
s.userId,
ts,
t.Name,
t.Artist,
t.Album,
t.Played,
"spotify",
}, nil
}
func (s *trackSource) Err() error {
return nil
}
func normalizeTs(ts string) (string, error) {
t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return "", err
}
return t.Format(time.RFC3339Nano), nil
}
func getExistingTracks(
userId int, tracks []SpotifyTrack,
) (map[string]struct{}, error) {
minTs, maxTs := findTimeRange(tracks)
if minTs.IsZero() {
return map[string]struct{}{}, nil
}
dbTracks, err := fetchDbTracks(userId, minTs, maxTs)
if err != nil {
return nil, err
}
dbIndex := buildDbTrackIndex(dbTracks)
return findDuplicates(tracks, dbIndex), nil
}
// get the min/max timestamp range for a batch of tracks
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
var minTs, maxTs time.Time
for _, t := range tracks {
ts, err := time.Parse(time.RFC3339Nano, t.Timestamp)
if err != nil {
continue
}
if minTs.IsZero() || ts.Before(minTs) {
minTs = ts
}
if ts.After(maxTs) {
maxTs = ts
}
}
return minTs, maxTs
}
/*
get all tracks in the database for a user that have the same timestamp
range as the current batch
*/
func fetchDbTracks(userId int, minTs, maxTs time.Time) ([]dbTrack, error) {
rows, err := db.Pool.Query(context.Background(),
`SELECT song_name, artist, timestamp
FROM history
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3`,
userId,
// adjust 20 seconds to find duplicates on edges of batch
minTs.Add(-20*time.Second),
maxTs.Add(20*time.Second))
if err != nil {
return nil, err
}
defer rows.Close()
var dbTracks []dbTrack
for rows.Next() {
var t dbTrack
if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
continue
}
dbTracks = append(dbTracks, t)
}
return dbTracks, nil
}
func buildDbTrackIndex(tracks []dbTrack) map[string][]time.Time {
index := make(map[string][]time.Time)
for _, t := range tracks {
key := t.Artist + "|" + t.SongName
index[key] = append(index[key], t.Timestamp)
}
return index
}
func findDuplicates(tracks []SpotifyTrack, dbIndex map[string][]time.Time) map[string]struct{} {
duplicates := make(map[string]struct{})
seenInBatch := make(map[string]struct{})
for _, track := range tracks {
trackKey, err := createTrackKey(track)
if err != nil {
continue
}
// in batch check
if _, seen := seenInBatch[trackKey]; seen {
duplicates[trackKey] = struct{}{}
continue
}
seenInBatch[trackKey] = struct{}{}
// in db check
lookupKey := fmt.Sprintf("%s|%s", track.Artist, track.Name)
if dbTimestamps, found := dbIndex[lookupKey]; found {
if isDuplicateWithinWindow(track, dbTimestamps) {
duplicates[trackKey] = struct{}{}
}
}
}
return duplicates
}
func createTrackKey(track SpotifyTrack) (string, error) {
ts, err := normalizeTs(track.Timestamp)
if err != nil {
return "", err
}
return fmt.Sprintf("%s|%s|%s", track.Artist, track.Name, ts), nil
}
// check if a track timestamp falls < 20 seconds of another
func isDuplicateWithinWindow(track SpotifyTrack, existingTimestamps []time.Time) bool {
trackTime, err := time.Parse(time.RFC3339Nano, track.Timestamp)
if err != nil {
return false
}
for _, existingTime := range existingTimestamps {
diff := trackTime.Sub(existingTime)
if diff < 0 {
diff = -diff
}
if diff < 20*time.Second {
return true
}
}
return false
}
func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan ProgressUpdate) error {
totalImported := 0 totalImported := 0
totalTracks := len(tracks) totalTracks := len(tracks)
batchStart := 0 batchStart := 0
totalBatches := (totalTracks + batchSize - 1) / batchSize totalBatches := (totalTracks + batchSize - 1) / batchSize
// Send initial progress update // Send initial progress update
if progressChan != nil { sendProgressUpdate(progressChan, 0, 0, totalBatches, totalImported, "running")
progressChan <- ProgressUpdate{
TotalPages: totalBatches,
Status: "running",
}
}
for batchStart < totalTracks { for batchStart < totalTracks {
// cap batchEnd at total track count on final batch to prevent OOB error // Cap batchEnd at total track count on final batch to prevent OOB error
batchEnd := min(batchStart+batchSize, totalTracks) batchEnd := min(batchStart+batchSize, totalTracks)
currentBatch := (batchStart / batchSize) + 1 currentBatch := (batchStart / batchSize) + 1
var validTracks []SpotifyTrack var validTracks []SpotifyTrack
for i := batchStart; i < batchEnd; i++ { for i := batchStart; i < batchEnd; i++ {
if tracks[i].Played >= 20000 && // 20 seconds if tracks[i].Played >= minPlayTime &&
tracks[i].Name != "" && tracks[i].Name != "" &&
tracks[i].Artist != "" { tracks[i].Artist != "" {
validTracks = append(validTracks, tracks[i]) validTracks = append(validTracks, tracks[i])
@@ -246,19 +89,18 @@ func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan Progress
if len(validTracks) == 0 { if len(validTracks) == 0 {
batchStart += batchSize batchStart += batchSize
// Send progress update even for empty batches // Send progress update even for empty batches
if progressChan != nil { sendProgressUpdate(
progressChan <- ProgressUpdate{ progressChan,
CurrentPage: currentBatch, currentBatch,
CompletedPages: currentBatch, currentBatch,
TotalPages: totalBatches, totalBatches,
TracksImported: totalImported, totalImported,
Status: "running", "running",
} )
}
continue continue
} }
tracksToSkip, err := getExistingTracks(userId, validTracks) tracksToSkip, err := getDupes(userId, validTracks)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err) fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err)
batchStart += batchSize batchStart += batchSize
@@ -286,6 +128,7 @@ func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan Progress
}, },
src, src,
) )
// Do not log errors that come from adding duplicate songs
if err != nil { if err != nil {
if !strings.Contains(err.Error(), "duplicate") { if !strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Spotify batch insert failed: %v\n", err) fmt.Fprintf(os.Stderr, "Spotify batch insert failed: %v\n", err)
@@ -294,30 +137,223 @@ func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan Progress
totalImported += int(copyCount) totalImported += int(copyCount)
} }
// Send progress update sendProgressUpdate(
if progressChan != nil { progressChan,
progressChan <- ProgressUpdate{ currentBatch,
CurrentPage: currentBatch, currentBatch,
CompletedPages: currentBatch, totalBatches,
TotalPages: totalBatches, totalImported,
TracksImported: totalImported, "running",
Status: "running", )
}
}
batchStart += batchSize batchStart += batchSize
} }
// Send completion update sendProgressUpdate(
if progressChan != nil { progressChan,
progressChan <- ProgressUpdate{ totalBatches,
CurrentPage: totalBatches, totalBatches,
CompletedPages: totalBatches, totalBatches,
TotalPages: totalBatches, totalImported,
TracksImported: totalImported, "completed",
Status: "completed", )
}
// Sends a progress update to the channel if it's not nil.
// To avoid panics from sending on a closed channel, the channel
// must never be closed by the receiver. The receiver should stop reading when
// Status reads "completed".
func sendProgressUpdate(
ch chan ProgressUpdate,
current, completed, total, imported int,
status string,
) {
if ch != nil {
ch <- ProgressUpdate{
CurrentPage: current,
CompletedPages: completed,
TotalPages: total,
TracksImported: imported,
Status: status,
}
}
}
// Finds tracks that already exist in the database or are duplicates within the
// current batch, using a 20-second window to handle minor timestamp variations
func getDupes(userId int, tracks []SpotifyTrack) (map[string]struct{}, error) {
minTs, maxTs := findTimeRange(tracks)
if minTs.IsZero() {
return map[string]struct{}{}, nil
}
dbTracks, err := fetchDbTracks(userId, minTs, maxTs)
if err != nil {
return nil, err
}
dbIndex := buildDbTrackIndex(dbTracks)
duplicates := make(map[string]struct{})
seenInBatch := make(map[string]struct{})
for _, track := range tracks {
trackKey := createTrackKey(track)
// Check in batch
if _, seen := seenInBatch[trackKey]; seen {
duplicates[trackKey] = struct{}{}
continue
}
seenInBatch[trackKey] = struct{}{}
// Check in DB
lookupKey := fmt.Sprintf("%s|%s", track.Artist, track.Name)
if dbTimestamps, found := dbIndex[lookupKey]; found {
if isDuplicateWithinWindow(track, dbTimestamps) {
duplicates[trackKey] = struct{}{}
}
} }
} }
return duplicates, nil
}
// Get the min/max timestamp range for a batch of tracks
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
var minTs, maxTs time.Time
for _, t := range tracks {
if minTs.IsZero() || t.Timestamp.Before(minTs) {
minTs = t.Timestamp
}
if t.Timestamp.After(maxTs) {
maxTs = t.Timestamp
}
}
return minTs, maxTs
}
// Get all tracks in the database for a user that have the same timestamp
// range as the current batch
func fetchDbTracks(userId int, minTs, maxTs time.Time) ([]dbTrack, error) {
rows, err := db.Pool.Query(context.Background(),
`SELECT song_name, artist, timestamp
FROM history
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3`,
userId,
// Adjust 20 seconds to find duplicates on edges of batch
minTs.Add(-timeDiff),
maxTs.Add(timeDiff))
if err != nil {
return nil, err
}
defer rows.Close()
var dbTracks []dbTrack
for rows.Next() {
var t dbTrack
if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
continue
}
dbTracks = append(dbTracks, t)
}
err = rows.Err()
if err != nil {
return nil, err
}
return dbTracks, nil
}
// Create a lookup map from Artist|Name to timestamps for efficient duplicate
// detection.
func buildDbTrackIndex(tracks []dbTrack) map[string][]time.Time {
index := make(map[string][]time.Time)
for _, t := range tracks {
key := t.Artist + "|" + t.SongName
index[key] = append(index[key], t.Timestamp)
}
return index
}
// Generate a unique identifier for a track using artist, name, and
// normalized timestamp.
func createTrackKey(track SpotifyTrack) string {
ts := track.Timestamp.Format(time.RFC3339Nano)
return fmt.Sprintf("%s|%s|%s", track.Artist, track.Name, ts)
}
// Check if a track timestamp falls < 20 seconds of another
func isDuplicateWithinWindow(track SpotifyTrack,
existingTimestamps []time.Time,
) bool {
for _, existingTime := range existingTimestamps {
diff := track.Timestamp.Sub(existingTime)
if diff < 0 {
diff = -diff
}
if diff < timeDiff {
return true
}
}
return false
}
// Advances to the next valid track, skipping duplicates and invalid timestamps.
// Returns false when all tracks have been processed
func (s *trackSource) Next() bool {
for s.idx < len(s.tracks) {
t := s.tracks[s.idx]
key := createTrackKey(t)
if _, shouldSkip := s.tracksToSkip[key]; shouldSkip {
s.idx++
continue
}
s.idx++
return true
}
return false
}
// Returns the current track's data formatted for database insertion.
// Must only be called after Next() returns true
func (s *trackSource) Values() ([]any, error) {
// idx is already incremented in Next(), so use idx-1
t := s.tracks[s.idx-1]
return []any{
s.userId,
t.Timestamp,
t.Name,
t.Artist,
t.Album,
t.Played,
"spotify",
}, nil
}
// Returns any error encountered during iteration.
// Currently always returns nil as errors are logged in Next()
func (s *trackSource) Err() error {
return nil
}
// Implements custom JSON unmarshaling to parse the timestamp
func (s *SpotifyTrack) UnmarshalJSON(data []byte) error {
type Alias SpotifyTrack
aux := &struct {
Timestamp string `json:"ts"`
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
ts, err := time.Parse(time.RFC3339Nano, aux.Timestamp)
if err != nil {
return fmt.Errorf("parsing timestamp: %w", err)
}
s.Timestamp = ts
return nil return nil
} }

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,704 +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()
close(progressChan)
}()
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()
close(progressChan)
}()
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))