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

-
- - - - - - - {{$artists := .Artists}} - {{$times := .Times}} - {{range $index, $title := .Titles}} - - - - - - {{end}} -
ArtistTitleTimestamp
{{index $artists $index}}{{$title}}{{index $times $index}}
-
- {{$page := .Page}} -
- Prev Page - Next 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

+ + + + + + + {{$artists := .Artists}} + {{$times := .Times}} + {{range $index, $title := .Titles}} + + + + + + {{end}} +
ArtistTitleTimestamp
{{index $artists $index}}{{$title}}{{index $times $index}}
+
+
+ Prev Page + Next Page +
diff --git a/web/web.go b/web/web.go index 1e0d3c1..30e1aab 100644 --- a/web/web.go +++ b/web/web.go @@ -8,7 +8,7 @@ import ( "os" "strconv" - "muzi/migrate" + "muzi/db" "golang.org/x/crypto/bcrypt" @@ -18,12 +18,17 @@ import ( "github.com/jackc/pgx/v5" ) -type PageData struct { - Content int - Artists []string - Titles []string - Times []string - Page int +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 { @@ -34,16 +39,24 @@ func Add(a int, b int) int { return a + b } -func getTimes(conn *pgx.Conn, lim int, off int) []string { +func getUserIdByUsername(conn *pgx.Conn, username string) (int, error) { + var userId int + err := conn.QueryRow(context.Background(), "SELECT pk FROM users WHERE username = $1;", username). + Scan(&userId) + return userId, err +} + +func getTimes(conn *pgx.Conn, userId int, lim int, off int) []string { var times []string rows, err := conn.Query( context.Background(), - "SELECT timestamp FROM history ORDER BY timestamp DESC LIMIT $1 OFFSET $2;", + "SELECT 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 COUNT failed: %v\n", err) + fmt.Fprintf(os.Stderr, "SELECT timestamp failed: %v\n", err) return nil } for rows.Next() { @@ -58,16 +71,17 @@ func getTimes(conn *pgx.Conn, lim int, off int) []string { return times } -func getTitles(conn *pgx.Conn, lim int, off int) []string { +func getTitles(conn *pgx.Conn, userId int, lim int, off int) []string { var titles []string rows, err := conn.Query( context.Background(), - "SELECT song_name FROM history ORDER BY timestamp DESC LIMIT $1 OFFSET $2;", + "SELECT song_name 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 COUNT failed: %v\n", err) + fmt.Fprintf(os.Stderr, "SELECT song_name failed: %v\n", err) return nil } for rows.Next() { @@ -82,16 +96,17 @@ func getTitles(conn *pgx.Conn, lim int, off int) []string { return titles } -func getArtists(conn *pgx.Conn, lim int, off int) []string { +func getArtists(conn *pgx.Conn, userId int, lim int, off int) []string { var artists []string rows, err := conn.Query( context.Background(), - "SELECT artist FROM history ORDER BY timestamp DESC LIMIT $1 OFFSET $2;", + "SELECT artist 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 COUNT failed: %v\n", err) + fmt.Fprintf(os.Stderr, "SELECT artist failed: %v\n", err) return nil } for rows.Next() { @@ -106,9 +121,10 @@ func getArtists(conn *pgx.Conn, lim int, off int) []string { return artists } -func getScrobbles(conn *pgx.Conn) int { +func getScrobbles(conn *pgx.Conn, userId int) int { var count int - err := conn.QueryRow(context.Background(), "SELECT COUNT (*) FROM history;").Scan(&count) + err := conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM history WHERE user_id = $1;", userId). + Scan(&count) if err != nil { fmt.Fprintf(os.Stderr, "SELECT COUNT failed: %v\n", err) return 0 @@ -116,6 +132,17 @@ func getScrobbles(conn *pgx.Conn) int { return count } +func getArtistCount(conn *pgx.Conn, userId int) int { + var count int + err := conn.QueryRow(context.Background(), "SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1;", userId). + Scan(&count) + if err != nil { + fmt.Fprintf(os.Stderr, "SELECT artist count failed: %v\n", err) + return 0 + } + return count +} + func hashPassword(pass []byte) string { hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost) if err != nil { @@ -150,31 +177,18 @@ func createAccount(w http.ResponseWriter, r *http.Request) { username := r.FormValue("uname") hashedPassword := hashPassword([]byte(r.FormValue("pass"))) - if !migrate.TableExists("users", conn) { - _, err = conn.Exec( - context.Background(), - `CREATE TABLE users ( - username TEXT, - password TEXT, - bio TEXT, - pfp TEXT, - pk SERIAL, - PRIMARY KEY (pk) - );`, - ) - if err != nil { - fmt.Fprintf(os.Stderr, "Cannot create users table: %v\n", err) - panic(err) - } + err = db.CreateUsersTable(conn) + if err != nil { + fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err) + http.Redirect(w, r, "/createaccount", http.StatusSeeOther) + return } _, err = conn.Exec( context.Background(), - `INSERT INTO users (username, password, bio, pfp) VALUES ($1, $2, $3, $4);`, + `INSERT INTO users (username, password) VALUES ($1, $2);`, username, hashedPassword, - "This profile has no bio.", - "/files/assets/default.png", ) if err != nil { fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err) @@ -255,61 +269,6 @@ func loginPageHandler() http.HandlerFunc { } } -func historyPage(w http.ResponseWriter, r *http.Request) { - 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()) - - var pageInt int - - pageStr := r.URL.Query().Get("page") - 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) - return - } - } - - lim := 25 - off := 0 + (25 * (pageInt - 1)) - - data := PageData{ - Content: getScrobbles(conn), - Artists: getArtists(conn, lim, off), - Titles: getTitles(conn, lim, off), - Times: getTimes(conn, lim, off), - Page: pageInt, - } - - funcMap := template.FuncMap{ - "Sub": Sub, - "Add": Add, - } - - tmp, err := template.New("history.gohtml"). - Funcs(funcMap). - ParseFiles("./templates/history.gohtml") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - err = tmp.Execute(w, data) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - func profilePageHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") @@ -325,21 +284,56 @@ func profilePageHandler() http.HandlerFunc { } defer conn.Close(context.Background()) - var profileData Profile + userId, err := getUserIdByUsername(conn, 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 err = conn.QueryRow( context.Background(), - "SELECT bio, pfp FROM users WHERE username = $1;", - username, - ).Scan(&profileData.Bio, &profileData.Pfp) + "SELECT bio, pfp, allow_duplicate_edits FROM users WHERE pk = $1;", + userId, + ).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits) if err != nil { fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } profileData.Username = username + profileData.ScrobbleCount = getScrobbles(conn, userId) + profileData.ArtistCount = getArtistCount(conn, userId) + profileData.Artists = getArtists(conn, userId, lim, off) + profileData.Titles = getTitles(conn, userId, lim, off) + profileData.Times = getTimes(conn, userId, lim, off) + profileData.Page = pageInt - tmp, err := template.ParseFiles("./templates/profile.gohtml") + funcMap := template.FuncMap{ + "Sub": Sub, + "Add": Add, + } + + tmp, err := template.New("profile.gohtml"). + Funcs(funcMap). + ParseFiles("./templates/profile.gohtml") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -348,10 +342,33 @@ func profilePageHandler() http.HandlerFunc { } } -type Profile struct { - Username string - Bio string - Pfp string +func updateDuplicateEditsSetting(w http.ResponseWriter, r *http.Request) { + 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()) + + if r.Method == "POST" { + r.ParseForm() + username := r.FormValue("username") + allow := r.FormValue("allow") == "true" + + _, err = conn.Exec( + context.Background(), + `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 Start() { @@ -359,12 +376,12 @@ func Start() { r := chi.NewRouter() r.Use(middleware.Logger) r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static")))) - r.Get("/history", historyPage) r.Get("/login", loginPageHandler()) r.Get("/createaccount", createAccountPageHandler()) r.Get("/profile/{username}", profilePageHandler()) r.Post("/loginsubmit", loginSubmit) r.Post("/createaccountsubmit", createAccount) + r.Post("/settings/duplicate-edits", updateDuplicateEditsSetting) fmt.Printf("WebUI starting on %s\n", addr) http.ListenAndServe(addr, r) }