diff --git a/db/db.go b/db/db.go
new file mode 100644
index 0000000..bb97843
--- /dev/null
+++ b/db/db.go
@@ -0,0 +1,95 @@
+package db
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func TableExists(name string, conn *pgx.Conn) bool {
+ var exists bool
+ err := conn.QueryRow(
+ context.Background(),
+ `SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND
+ tablename = $1);`,
+ name,
+ ).
+ Scan(&exists)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "SELECT EXISTS failed: %v\n", err)
+ return false
+ }
+ return exists
+}
+
+func DbExists() bool {
+ conn, err := pgx.Connect(
+ context.Background(),
+ "postgres://postgres:postgres@localhost:5432/muzi",
+ )
+ if err != nil {
+ return false
+ }
+ defer conn.Close(context.Background())
+ return true
+}
+
+func CreateDB() error {
+ conn, err := pgx.Connect(
+ context.Background(),
+ "postgres://postgres:postgres@localhost:5432",
+ )
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Cannot connect to PostgreSQL: %v\n", err)
+ return err
+ }
+ defer conn.Close(context.Background())
+ _, err = conn.Exec(context.Background(), "CREATE DATABASE muzi")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Cannot create muzi database: %v\n", err)
+ return err
+ }
+ return nil
+}
+
+func CreateHistoryTable(conn *pgx.Conn) error {
+ _, err := conn.Exec(context.Background(),
+ `CREATE TABLE IF NOT EXISTS history (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ timestamp TIMESTAMPTZ NOT NULL,
+ song_name TEXT NOT NULL,
+ artist TEXT NOT NULL,
+ album_name TEXT,
+ ms_played INTEGER,
+ platform TEXT DEFAULT 'spotify',
+ UNIQUE (user_id, song_name, artist, timestamp)
+ );
+ CREATE INDEX IF NOT EXISTS idx_history_user_timestamp ON history(user_id, timestamp DESC);
+ CREATE INDEX IF NOT EXISTS idx_history_user_artist ON history(user_id, artist);
+ CREATE INDEX IF NOT EXISTS idx_history_user_song ON history(user_id, song_name);`)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating history table: %v\n", err)
+ return err
+ }
+ return nil
+}
+
+func CreateUsersTable(conn *pgx.Conn) error {
+ _, err := conn.Exec(context.Background(),
+ `CREATE TABLE IF NOT EXISTS users (
+ username TEXT NOT NULL,
+ password TEXT NOT NULL,
+ bio TEXT DEFAULT 'This profile has no bio.',
+ pfp TEXT DEFAULT '/files/assets/default.png',
+ allow_duplicate_edits BOOLEAN DEFAULT FALSE,
+ pk SERIAL PRIMARY KEY
+ );`)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
+ return err
+ }
+ return nil
+}
diff --git a/main.go b/main.go
index 7fdf755..e679890 100644
--- a/main.go
+++ b/main.go
@@ -1,18 +1,22 @@
package main
import (
+ "context"
"errors"
"fmt"
"os"
"path/filepath"
+ "muzi/db"
"muzi/migrate"
"muzi/web"
+
+ "github.com/jackc/pgx/v5"
)
func dbCheck() error {
- if !migrate.DbExists() {
- err := migrate.CreateDB()
+ if !db.DbExists() {
+ err := db.CreateDB()
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating muzi DB: %v\n", err)
return err
@@ -68,14 +72,40 @@ func main() {
return
}
+ fmt.Println("Setting up database tables...")
+ conn, err := pgx.Connect(
+ context.Background(),
+ "postgres://postgres:postgres@localhost:5432/muzi",
+ )
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
+ return
+ }
+ defer conn.Close(context.Background())
+
+ err = db.CreateHistoryTable(conn)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating history table: %v\n", err)
+ return
+ }
+
+ err = db.CreateUsersTable(conn)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
+ return
+ }
+
username := ""
apiKey := ""
fmt.Printf("Importing LastFM data for %s\n", username)
- err = migrate.ImportLastFM(username, apiKey)
+ // TODO:
+ // remove hardcoded userID by creating webUI import pages and getting
+ // userID from login session
+ err = migrate.ImportLastFM(username, apiKey, 1)
if err != nil {
return
}
- err = migrate.ImportSpotify()
+ err = migrate.ImportSpotify(1)
if err != nil {
return
}
diff --git a/migrate/lastfm.go b/migrate/lastfm.go
index 90fa03b..c5cc8c9 100644
--- a/migrate/lastfm.go
+++ b/migrate/lastfm.go
@@ -15,6 +15,7 @@ import (
)
type LastFMTrack struct {
+ UserId int
Timestamp time.Time
SongName string
Artist string
@@ -50,37 +51,17 @@ type Response struct {
} `json:"recenttracks"`
}
-func ImportLastFM(username string, apiKey string) error {
- if !DbExists() {
- err := CreateDB()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err)
- panic(err)
- }
- }
+func ImportLastFM(username string, apiKey string, userId int) error {
conn, err := pgx.Connect(
context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi",
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
- panic(err)
+ return err
}
defer conn.Close(context.Background())
- if !TableExists("history", conn) {
- _, err = conn.Exec(
- context.Background(),
- `CREATE TABLE history ( ms_played INTEGER, timestamp TIMESTAMPTZ,
- song_name TEXT, artist TEXT, album_name TEXT, PRIMARY KEY (timestamp,
- ms_played, artist, song_name));`,
- )
- if err != nil {
- fmt.Fprintf(os.Stderr, "Cannot create history table: %v\n", err)
- panic(err)
- }
- }
-
totalImported := 0
resp, err := http.Get(
@@ -106,13 +87,11 @@ func ImportLastFM(username string, apiKey string) error {
pageChan := make(chan pageResult, 20)
var wg sync.WaitGroup
- // use 10 workers
wg.Add(10)
for worker := range 10 {
go func(workerID int) {
defer wg.Done()
- // distrubute 10 pages to each worker
for page := workerID + 1; page <= totalPages; page += 10 {
resp, err := http.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
@@ -140,6 +119,7 @@ func ImportLastFM(username string, apiKey string) error {
continue
}
pageTracks = append(pageTracks, LastFMTrack{
+ UserId: userId,
Timestamp: time.Unix(unixTime, 0),
SongName: data.Recenttracks.Track[j].Name,
Artist: data.Recenttracks.Track[j].Artist.Text,
@@ -197,22 +177,35 @@ func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batch
for i, track := range tracks {
batchValues = append(batchValues, fmt.Sprintf(
- "($%d, $%d, $%d, $%d, $%d)",
- len(
- batchArgs,
- )+1,
+ "($%d, $%d, $%d, $%d, $%d, $%d, $%d)",
+ len(batchArgs)+1,
len(batchArgs)+2,
len(batchArgs)+3,
len(batchArgs)+4,
len(batchArgs)+5,
+ len(batchArgs)+6,
+ len(batchArgs)+7,
))
- batchArgs = append(batchArgs, track.Timestamp, track.SongName, track.Artist, track.Album, 0)
+ // lastfm doesn't store playtime for each track, so set to 0
+ batchArgs = append(
+ batchArgs,
+ track.UserId,
+ track.Timestamp,
+ track.SongName,
+ track.Artist,
+ track.Album,
+ 0,
+ "lastfm",
+ )
if len(batchValues) >= batchSize || i == len(tracks)-1 {
result, err := tx.Exec(
context.Background(),
- `INSERT INTO history (timestamp, song_name, artist, album_name, ms_played) VALUES `+
- strings.Join(batchValues, ", ")+` ON CONFLICT DO NOTHING;`,
+ `INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform) VALUES `+
+ strings.Join(
+ batchValues,
+ ", ",
+ )+` ON CONFLICT ON CONSTRAINT history_user_id_song_name_artist_timestamp_key DO NOTHING;`,
batchArgs...,
)
if err != nil {
diff --git a/migrate/migrate.go b/migrate/migrate.go
deleted file mode 100644
index 63a3faf..0000000
--- a/migrate/migrate.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package migrate
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/jackc/pgx/v5"
-)
-
-func TableExists(name string, conn *pgx.Conn) bool {
- var exists bool
- err := conn.QueryRow(
- context.Background(),
- `SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND
- tablename = $1);`,
- name,
- ).
- Scan(&exists)
- if err != nil {
- fmt.Fprintf(os.Stderr, "SELECT EXISTS failed: %v\n", err)
- return false
- }
- return exists
-}
-
-func DbExists() bool {
- conn, err := pgx.Connect(
- context.Background(),
- "postgres://postgres:postgres@localhost:5432/muzi",
- )
- if err != nil {
- return false
- }
- defer conn.Close(context.Background())
- return true
-}
-
-func CreateDB() error {
- conn, err := pgx.Connect(
- context.Background(),
- "postgres://postgres:postgres@localhost:5432",
- )
- if err != nil {
- fmt.Fprintf(os.Stderr, "Cannot connect to PostgreSQL: %v\n", err)
- return err
- }
- defer conn.Close(context.Background())
- _, err = conn.Exec(context.Background(), "CREATE DATABASE muzi")
- if err != nil {
- fmt.Fprintf(os.Stderr, "Cannot create muzi database: %v\n", err)
- return err
- }
- return nil
-}
diff --git a/migrate/spotify.go b/migrate/spotify.go
index 41260e1..fe5ce41 100644
--- a/migrate/spotify.go
+++ b/migrate/spotify.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
"github.com/jackc/pgx/v5"
)
@@ -39,59 +40,85 @@ type SpotifyTrack struct {
Incognito bool `json:"-"`
}
-func trackKey(t SpotifyTrack) string {
- return fmt.Sprintf("%s|%d|%s|%s", t.Timestamp, t.Played, t.Artist, t.Name)
+type existingTrack struct {
+ Timestamp time.Time
+ SongName string
+ Artist string
}
-func getExistingTracks(conn *pgx.Conn, tracks []SpotifyTrack) (map[string]bool, error) {
+func getExistingTracks(conn *pgx.Conn, userId int, tracks []SpotifyTrack) (map[string]bool, error) {
if len(tracks) == 0 {
return map[string]bool{}, nil
}
- var conditions []string
- var args []any
-
- for i, t := range tracks {
- base := i * 4
- conditions = append(conditions,
- fmt.Sprintf("(timestamp=$%d AND ms_played=$%d AND artist=$%d AND song_name=$%d)",
- base+1, base+2, base+3, base+4))
- args = append(args, t.Timestamp, t.Played, t.Artist, t.Name)
+ // find min/max timestamps in this batch to create time window
+ 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
+ }
}
- query := fmt.Sprintf(
- "SELECT timestamp, ms_played, artist, song_name FROM history WHERE %s",
- strings.Join(conditions, " OR "))
+ if minTs.IsZero() {
+ return map[string]bool{}, nil
+ }
- rows, err := conn.Query(context.Background(), query, args...)
+ // query only tracks within [min-20s, max+20s] window using timestamp index
+ rows, err := conn.Query(context.Background(),
+ `SELECT song_name, artist, timestamp
+ FROM history
+ WHERE user_id = $1
+ AND timestamp BETWEEN $2 AND $3`,
+ userId,
+ minTs.Add(-20*time.Second),
+ maxTs.Add(20*time.Second))
if err != nil {
return nil, err
}
defer rows.Close()
existing := make(map[string]bool)
+ var existingTracks []existingTrack
for rows.Next() {
- var ts string
- var played int
- var artist, song string
- if err := rows.Scan(&ts, &played, &artist, &song); err != nil {
+ var t existingTrack
+ if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
continue
}
- key := fmt.Sprintf("%s|%d|%s|%s", ts, played, artist, song)
- existing[key] = true
+ existingTracks = append(existingTracks, t)
+ }
+
+ // check each incoming track against existing ones within 20 second window
+ for _, newTrack := range tracks {
+ newTs, err := time.Parse(time.RFC3339Nano, newTrack.Timestamp)
+ if err != nil {
+ continue
+ }
+ for _, existTrack := range existingTracks {
+ if newTrack.Name == existTrack.SongName && newTrack.Artist == existTrack.Artist {
+ diff := newTs.Sub(existTrack.Timestamp)
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff < 20*time.Second {
+ key := fmt.Sprintf("%s|%s|%s", newTrack.Artist, newTrack.Name, newTrack.Timestamp)
+ existing[key] = true
+ break
+ }
+ }
+ }
}
return existing, nil
}
-func JsonToDB(jsonFile string) error {
- if !DbExists() {
- err := CreateDB()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err)
- panic(err)
- }
- }
+func JsonToDB(jsonFile string, userId int) error {
conn, err := pgx.Connect(
context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi",
@@ -101,18 +128,7 @@ func JsonToDB(jsonFile string) error {
panic(err)
}
defer conn.Close(context.Background())
- if !TableExists("history", conn) {
- _, err = conn.Exec(
- context.Background(),
- `CREATE TABLE history ( ms_played INTEGER, timestamp TIMESTAMPTZ,
- song_name TEXT, artist TEXT, album_name TEXT, PRIMARY KEY (timestamp,
- ms_played, artist, song_name));`,
- )
- }
- if err != nil {
- fmt.Fprintf(os.Stderr, "Cannot create history table: %v\n", err)
- panic(err)
- }
+
jsonData, err := os.ReadFile(jsonFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read %s: %v\n", jsonFile, err)
@@ -136,7 +152,7 @@ func JsonToDB(jsonFile string) error {
var validTracks []SpotifyTrack
for i := batchStart; i < batchEnd; i++ {
- if tracks[i].Played >= 20000 {
+ if tracks[i].Played >= 20000 && tracks[i].Name != "" && tracks[i].Artist != "" {
validTracks = append(validTracks, tracks[i])
}
}
@@ -145,7 +161,7 @@ func JsonToDB(jsonFile string) error {
continue
}
- existing, err := getExistingTracks(conn, validTracks)
+ existing, err := getExistingTracks(conn, userId, validTracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err)
continue
@@ -155,20 +171,31 @@ func JsonToDB(jsonFile string) error {
var batchArgs []any
for _, t := range validTracks {
- key := trackKey(t)
+ key := fmt.Sprintf("%s|%s|%s", t.Artist, t.Name, t.Timestamp)
if existing[key] {
continue
}
batchValues = append(batchValues, fmt.Sprintf(
- "($%d, $%d, $%d, $%d, $%d)",
+ "($%d, $%d, $%d, $%d, $%d, $%d, $%d)",
len(batchArgs)+1,
len(batchArgs)+2,
len(batchArgs)+3,
len(batchArgs)+4,
len(batchArgs)+5,
+ len(batchArgs)+6,
+ len(batchArgs)+7,
))
- batchArgs = append(batchArgs, t.Timestamp, t.Name, t.Artist, t.Album, t.Played)
+ batchArgs = append(
+ batchArgs,
+ userId,
+ t.Timestamp,
+ t.Name,
+ t.Artist,
+ t.Album,
+ t.Played,
+ "spotify",
+ )
}
if len(batchValues) == 0 {
@@ -177,9 +204,12 @@ func JsonToDB(jsonFile string) error {
_, err = conn.Exec(
context.Background(),
- `INSERT INTO history (timestamp, song_name, artist, album_name, ms_played) VALUES `+
- strings.Join(batchValues, ", ")+
- ` ON CONFLICT DO NOTHING;`,
+ `INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform) VALUES `+
+ strings.Join(
+ batchValues,
+ ", ",
+ )+
+ ` ON CONFLICT ON CONSTRAINT history_user_id_song_name_artist_timestamp_key DO NOTHING;`,
batchArgs...,
)
if err != nil {
@@ -193,7 +223,7 @@ func JsonToDB(jsonFile string) error {
return nil
}
-func AddDirToDB(path string) error {
+func AddDirToDB(path string, userId int) error {
dirs, err := os.ReadDir(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", path, err)
@@ -215,12 +245,11 @@ func AddDirToDB(path string) error {
if !strings.Contains(jsonFileName, ".json") {
continue
}
- // prevents parsing spotify video data that causes duplicates
if strings.Contains(jsonFileName, "Video") {
continue
}
jsonFilePath := filepath.Join(subPath, jsonFileName)
- err = JsonToDB(jsonFilePath)
+ err = JsonToDB(jsonFilePath, userId)
if err != nil {
fmt.Fprintf(os.Stderr,
"Error adding json data (%s) to muzi database: %v", jsonFilePath, err)
@@ -231,7 +260,7 @@ func AddDirToDB(path string) error {
return nil
}
-func ImportSpotify() error {
+func ImportSpotify(userId int) error {
path := filepath.Join(".", "imports", "spotify", "zip")
targetBase := filepath.Join(".", "imports", "spotify", "extracted")
entries, err := os.ReadDir(path)
@@ -258,7 +287,7 @@ func ImportSpotify() error {
return err
}
}
- err = AddDirToDB(targetBase)
+ err = AddDirToDB(targetBase, userId)
if err != nil {
fmt.Fprintf(os.Stderr,
"Error adding directory of data (%s) to muzi database: %v\n",
diff --git a/static/style.css b/static/style.css
index 8b1b4d1..10911ad 100644
--- a/static/style.css
+++ b/static/style.css
@@ -5,9 +5,12 @@ body {
color: #AFA;
align-content: center;
justify-content: center;
+ align-items: center;
text-align: center;
- max-width: 100vw;
+ max-width: 70vw;
margin: 0;
+ width: 70vw;
+ font-family: sans-serif;
}
.page_buttons {
@@ -23,6 +26,47 @@ body {
}
}
+.user-stats-top {
+ display: inline-block;
+}
+
+.username-bio {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-left: 40px;
+}
+
+.profile-top {
+ display: flex;
+ flex-direction: row;
+ align-content: center;
+ h1 {
+ color: #FFFFFF;
+ margin: 0;
+ }
+ h2 {
+ color: #777777;
+ font-size: 15px;
+ margin: 0;
+ }
+ h3 {
+ color: #AAAAAA;
+ font-size: 25px;
+ margin: 0;
+ }
+ img {
+ object-fit: cover;
+ width: 250px;
+ height: 250px;
+ border-radius: 100%;
+ }
+}
+
+.login-error {
+ color: #AA0000;
+}
+
.history {
display: flex;
justify-content: center;
diff --git a/templates/history.gohtml b/templates/history.gohtml
deleted file mode 100644
index b63218d..0000000
--- a/templates/history.gohtml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
- muzi | History
-
-
-
- Scrobbles: {{.Content}}
-
-
-
- | Artist |
- Title |
- Timestamp |
-
- {{$artists := .Artists}}
- {{$times := .Times}}
- {{range $index, $title := .Titles}}
-
- | {{index $artists $index}} |
- {{$title}} |
- {{index $times $index}} |
-
- {{end}}
-
-
- {{$page := .Page}}
-
-
-
diff --git a/templates/profile.gohtml b/templates/profile.gohtml
index 4178237..cda5bb3 100644
--- a/templates/profile.gohtml
+++ b/templates/profile.gohtml
@@ -14,9 +14,32 @@
{{.Bio}}
-
101238 Listens
- 1298 Artists
+ {{.ScrobbleCount}} Listens
+ {{.ArtistCount}} Artists
+
+
Listening History
+
+
+ | Artist |
+ Title |
+ Timestamp |
+
+ {{$artists := .Artists}}
+ {{$times := .Times}}
+ {{range $index, $title := .Titles}}
+
+ | {{index $artists $index}} |
+ {{$title}} |
+ {{index $times $index}} |
+
+ {{end}}
+
+
+