diff --git a/db/db.go b/db/db.go
index bb97843..e658bf4 100644
--- a/db/db.go
+++ b/db/db.go
@@ -6,39 +6,23 @@ import (
"os"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
)
-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
-}
+var Pool *pgxpool.Pool
-func DbExists() bool {
- conn, err := pgx.Connect(
- context.Background(),
- "postgres://postgres:postgres@localhost:5432/muzi",
- )
- if err != nil {
- return false
+func CreateAllTables() error {
+ if err := CreateHistoryTable(); err != nil {
+ return err
}
- defer conn.Close(context.Background())
- return true
+ if err := CreateUsersTable(); err != nil {
+ return err
+ }
+ return CreateSessionsTable()
}
func CreateDB() error {
- conn, err := pgx.Connect(
- context.Background(),
+ conn, err := pgx.Connect(context.Background(),
"postgres://postgres:postgres@localhost:5432",
)
if err != nil {
@@ -46,16 +30,29 @@ func CreateDB() error {
return err
}
defer conn.Close(context.Background())
+
+ var exists bool
+ err = conn.QueryRow(context.Background(),
+ "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'muzi')").Scan(&exists)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error checking if database exists: %v\n", err)
+ return err
+ }
+
+ if exists {
+ return nil
+ }
+
_, err = conn.Exec(context.Background(), "CREATE DATABASE muzi")
if err != nil {
- fmt.Fprintf(os.Stderr, "Cannot create muzi database: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err)
return err
}
return nil
}
-func CreateHistoryTable(conn *pgx.Conn) error {
- _, err := conn.Exec(context.Background(),
+func CreateHistoryTable() error {
+ _, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
@@ -64,7 +61,7 @@ func CreateHistoryTable(conn *pgx.Conn) error {
artist TEXT NOT NULL,
album_name TEXT,
ms_played INTEGER,
- platform TEXT DEFAULT 'spotify',
+ platform TEXT,
UNIQUE (user_id, song_name, artist, timestamp)
);
CREATE INDEX IF NOT EXISTS idx_history_user_timestamp ON history(user_id, timestamp DESC);
@@ -77,10 +74,11 @@ func CreateHistoryTable(conn *pgx.Conn) error {
return nil
}
-func CreateUsersTable(conn *pgx.Conn) error {
- _, err := conn.Exec(context.Background(),
+// TODO: move user settings to jsonb in db
+func CreateUsersTable() error {
+ _, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS users (
- username TEXT NOT NULL,
+ username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
bio TEXT DEFAULT 'This profile has no bio.',
pfp TEXT DEFAULT '/files/assets/default.png',
@@ -93,3 +91,29 @@ func CreateUsersTable(conn *pgx.Conn) error {
}
return nil
}
+
+func CreateSessionsTable() error {
+ _, err := Pool.Exec(context.Background(),
+ `CREATE TABLE IF NOT EXISTS sessions (
+ session_id TEXT PRIMARY KEY,
+ username TEXT NOT NULL REFERENCES users(username),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days'
+ );
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);`)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating sessions table: %v\n", err)
+ return err
+ }
+ return nil
+}
+
+func CleanupExpiredSessions() error {
+ _, err := Pool.Exec(context.Background(),
+ "DELETE FROM sessions WHERE expires_at < NOW();")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error cleaning up expired sessions: %v\n", err)
+ return err
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
index 5697fcb..8dc3218 100644
--- a/go.mod
+++ b/go.mod
@@ -6,12 +6,14 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/jackc/pgtype v1.14.4
github.com/jackc/pgx/v5 v5.7.6
+ golang.org/x/crypto v0.45.0
)
require (
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- golang.org/x/crypto v0.45.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
diff --git a/go.sum b/go.sum
index d135778..69d57a3 100644
--- a/go.sum
+++ b/go.sum
@@ -66,7 +66,6 @@ github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
diff --git a/main.go b/main.go
index e679890..9497a33 100644
--- a/main.go
+++ b/main.go
@@ -8,23 +8,11 @@ import (
"path/filepath"
"muzi/db"
- "muzi/migrate"
"muzi/web"
- "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
)
-func dbCheck() error {
- if !db.DbExists() {
- err := db.CreateDB()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating muzi DB: %v\n", err)
- return err
- }
- }
- return nil
-}
-
func dirCheck(path string) error {
_, err := os.Stat(path)
if err != nil {
@@ -35,45 +23,28 @@ func dirCheck(path string) error {
return err
}
}
-
return nil
}
func main() {
- dirImports := filepath.Join(".", "imports")
+ zipDir := filepath.Join(".", "imports", "spotify", "zip")
+ extDir := filepath.Join(".", "imports", "spotify", "extracted")
- dirSpotify := filepath.Join(".", "imports", "spotify")
- dirSpotifyZip := filepath.Join(".", "imports", "spotify", "zip")
- dirSpotifyExt := filepath.Join(".", "imports", "spotify", "extracted")
-
- fmt.Printf("Checking if directory %s exists...\n", dirImports)
- err := dirCheck(dirImports)
- if err != nil {
- return
+ dirs := []string{zipDir, extDir}
+ for _, dir := range dirs {
+ err := dirCheck(dir)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error checking dir: %s: %v\n", dir, err)
+ return
+ }
}
- fmt.Printf("Checking if directory %s exists...\n", dirSpotify)
- err = dirCheck(dirSpotify)
- if err != nil {
- return
- }
- fmt.Printf("Checking if directory %s exists...\n", dirSpotifyZip)
- err = dirCheck(dirSpotifyZip)
- if err != nil {
- return
- }
- fmt.Printf("Checking if directory %s exists...\n", dirSpotifyExt)
- err = dirCheck(dirSpotifyExt)
- if err != nil {
- return
- }
- fmt.Println("Checking if muzi database exists...")
- err = dbCheck()
+ err := db.CreateDB()
if err != nil {
+ fmt.Fprintf(os.Stderr, "Error ensuring muzi DB exists: %v\n", err)
return
}
- fmt.Println("Setting up database tables...")
- conn, err := pgx.Connect(
+ db.Pool, err = pgxpool.New(
context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi",
)
@@ -81,33 +52,25 @@ func main() {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
return
}
- defer conn.Close(context.Background())
+ defer db.Pool.Close()
- err = db.CreateHistoryTable(conn)
+ err = db.CreateAllTables()
if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating history table: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Error ensuring all tables exist: %v\n", err)
return
}
- err = db.CreateUsersTable(conn)
+ err = db.CleanupExpiredSessions()
if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Error cleaning expired sessions: %v\n", err)
return
}
- username := ""
- apiKey := ""
- fmt.Printf("Importing LastFM data for %s\n", username)
- // 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(1)
- if err != nil {
- return
- }
+ /*
+ err = migrate.ImportSpotify(1)
+ if err != nil {
+ return
+ }
+ */
web.Start()
}
diff --git a/migrate/lastfm.go b/migrate/lastfm.go
index c5cc8c9..f623e30 100644
--- a/migrate/lastfm.go
+++ b/migrate/lastfm.go
@@ -28,6 +28,15 @@ type pageResult struct {
err error
}
+type ProgressUpdate struct {
+ CurrentPage int `json:"current_page"`
+ CompletedPages int `json:"completed_pages"`
+ TotalPages int `json:"total_pages"`
+ TracksImported int `json:"tracks_imported"`
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
+}
+
type Response struct {
Recenttracks struct {
Track []struct {
@@ -51,13 +60,21 @@ type Response struct {
} `json:"recenttracks"`
}
-func ImportLastFM(username string, apiKey string, userId int) error {
+func ImportLastFM(
+ username string,
+ apiKey string,
+ userId int,
+ progressChan chan<- ProgressUpdate,
+) 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)
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
+ }
return err
}
defer conn.Close(context.Background())
@@ -70,6 +87,9 @@ func ImportLastFM(username string, apiKey string, userId int) error {
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err)
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
+ }
return err
}
var initialData Response
@@ -78,10 +98,21 @@ func ImportLastFM(username string, apiKey string, userId int) error {
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err)
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
+ }
return err
}
fmt.Printf("Total pages: %d\n", totalPages)
+ // send initial progress update
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{
+ TotalPages: totalPages,
+ Status: "running",
+ }
+ }
+
trackBatch := make([]LastFMTrack, 0, 1000)
pageChan := make(chan pageResult, 20)
@@ -137,6 +168,8 @@ func ImportLastFM(username string, apiKey string, userId int) error {
}()
batchSize := 500
+ completedPages := 0
+ var completedMu sync.Mutex
for result := range pageChan {
if result.err != nil {
@@ -153,6 +186,23 @@ func ImportLastFM(username string, apiKey string, userId int) error {
}
}
fmt.Printf("Processed page %d/%d\n", result.pageNum, totalPages)
+
+ // increment completed pages counter
+ completedMu.Lock()
+ completedPages++
+ currentCompleted := completedPages
+ completedMu.Unlock()
+
+ // send progress update after each page
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{
+ CurrentPage: result.pageNum,
+ CompletedPages: currentCompleted,
+ TotalPages: totalPages,
+ TracksImported: totalImported,
+ Status: "running",
+ }
+ }
}
if len(trackBatch) > 0 {
@@ -163,6 +213,17 @@ func ImportLastFM(username string, apiKey string, userId int) error {
}
fmt.Printf("%d tracks imported from LastFM for user %s\n", totalImported, username)
+
+ // send completion update
+ if progressChan != nil {
+ progressChan <- ProgressUpdate{
+ CurrentPage: totalPages,
+ TotalPages: totalPages,
+ TracksImported: totalImported,
+ Status: "completed",
+ }
+ }
+
return nil
}
diff --git a/static/style.css b/static/style.css
index 10911ad..98a1c03 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,17 +1,17 @@
-body {
- display: flex;
- flex-direction: column;
- background-color: #222;
- color: #AFA;
- align-content: center;
- justify-content: center;
- align-items: center;
- text-align: center;
- max-width: 70vw;
- margin: 0;
- width: 70vw;
- font-family: sans-serif;
-}
+ body {
+ display: flex;
+ flex-direction: column;
+ background-color: #222;
+ color: #AFA;
+ align-content: center;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ max-width: 70vw;
+ margin: 0 auto;
+ width: 70vw;
+ font-family: sans-serif;
+ }
.page_buttons {
display: flex;
@@ -27,7 +27,19 @@ body {
}
.user-stats-top {
- display: inline-block;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 20%;
+ h3 {
+ color: #FFF;
+ font-size: 25px;
+ margin: 0;
+ }
+ p {
+ margin: 0;
+ color: #EEE;
+ }
}
.username-bio {
@@ -37,10 +49,15 @@ body {
margin-left: 40px;
}
+.profile-top-blank {
+ width: 50%;
+ }
+
.profile-top {
display: flex;
flex-direction: row;
align-content: center;
+ width: 100%;
h1 {
color: #FFFFFF;
margin: 0;
@@ -50,11 +67,6 @@ body {
font-size: 15px;
margin: 0;
}
- h3 {
- color: #AAAAAA;
- font-size: 25px;
- margin: 0;
- }
img {
object-fit: cover;
width: 250px;
@@ -63,16 +75,24 @@ body {
}
}
+.login-form {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+ }
+
.login-error {
color: #AA0000;
}
.history {
display: flex;
+ flex-direction: column;
justify-content: center;
- width: 100vw;
+ width: 100%;
table {
- width: 90%;
+ width: auto;
}
tr {
display: flex;
@@ -90,3 +110,135 @@ body {
background-color: #111;
}
}
+
+.import-section {
+ margin: 20px 0;
+ padding: 20px;
+ background: #1a1a1a;
+ border-radius: 8px;
+}
+
+.import-section form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 15px;
+}
+
+.import-section input {
+ padding: 8px;
+ border: 1px solid #333;
+ border-radius: 4px;
+ background: #222;
+ color: #AFA;
+}
+
+.import-section button {
+ padding: 10px 20px;
+ background: #333;
+ color: #AFA;
+ border: 1px solid #444;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.import-section button:hover {
+ background: #444;
+}
+
+.progress-container {
+ margin-top: 15px;
+ padding: 15px;
+ background: #1a1a1a;
+ border-radius: 8px;
+ border: 1px solid #333;
+}
+
+.progress-bar-wrapper {
+ width: 100%;
+ height: 24px;
+ background: #2a2a2a;
+ border-radius: 12px;
+ overflow: hidden;
+ position: relative;
+ margin: 10px 0;
+ border: 2px solid #444;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, #5a5 0%, #7f7 50%, #5a5 100%);
+ background-size: 200% 100%;
+ border-radius: 10px;
+ transition: width 0.3s ease-out;
+ box-shadow:
+ inset 0 2px 4px rgba(255, 255, 255, 0.3),
+ inset 0 -2px 4px rgba(0, 0, 0, 0.3),
+ 0 0 15px rgba(0, 255, 0, 0.4);
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+}
+
+.progress-bar-fill.animating {
+ animation: shimmer 2s linear infinite, pulse-glow 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-glow {
+ 0%, 100% {
+ box-shadow:
+ inset 0 2px 4px rgba(255, 255, 255, 0.3),
+ inset 0 -2px 4px rgba(0, 0, 0, 0.3),
+ 0 0 15px rgba(0, 255, 0, 0.4);
+ }
+ 50% {
+ box-shadow:
+ inset 0 2px 4px rgba(255, 255, 255, 0.3),
+ inset 0 -2px 4px rgba(0, 0, 0, 0.3),
+ 0 0 25px rgba(0, 255, 0, 0.7);
+ }
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.progress-text {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #fff;
+ font-weight: bold;
+ font-size: 12px;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
+ z-index: 2;
+ pointer-events: none;
+}
+
+.progress-status {
+ color: #AFA;
+ font-size: 14px;
+ margin-bottom: 5px;
+}
+
+.progress-tracks {
+ color: #888;
+ font-size: 12px;
+ margin-bottom: 5px;
+}
+
+.progress-error {
+ color: #F88;
+ font-size: 14px;
+ margin-top: 10px;
+}
+
+.progress-success {
+ color: #8F8;
+ font-size: 14px;
+ margin-top: 10px;
+}
diff --git a/templates/login.gohtml b/templates/login.gohtml
index afdfcaf..d9719ce 100644
--- a/templates/login.gohtml
+++ b/templates/login.gohtml
@@ -14,11 +14,16 @@
- {{if .ShowError}}
+ {{if eq .Error "1"}}