Compare commits

..

2 Commits

Author SHA1 Message Date
riwiwa
c01ecaa4a6 Update README
Need to add webui spotify import interface to be considered complete
2026-02-08 00:04:27 -08:00
eb06ddc35c added webui lastfm importing, account sessions, partial codebase cleanup 2026-02-07 23:57:43 -08:00
10 changed files with 624 additions and 317 deletions

View File

@@ -8,7 +8,7 @@
### Roadmap: ### Roadmap:
- Ability to import all listening statistics and scrobbles from: \[In Progress\] - Ability to import all listening statistics and scrobbles from: \[In Progress\]
- LastFM \[Complete\] - LastFM \[Complete\]
- Spotify \[Complete\] - Spotify \[In Progress\]
- Apple Music \[Planned\] - Apple Music \[Planned\]
- WebUI \[In Progress\] - WebUI \[In Progress\]

View File

@@ -6,39 +6,23 @@ import (
"os" "os"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
) )
func TableExists(name string, conn *pgx.Conn) bool { var Pool *pgxpool.Pool
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 { func CreateAllTables() error {
conn, err := pgx.Connect( if err := CreateHistoryTable(); err != nil {
context.Background(), return err
"postgres://postgres:postgres@localhost:5432/muzi",
)
if err != nil {
return false
} }
defer conn.Close(context.Background()) if err := CreateUsersTable(); err != nil {
return true return err
}
return CreateSessionsTable()
} }
func CreateDB() error { func CreateDB() error {
conn, err := pgx.Connect( conn, err := pgx.Connect(context.Background(),
context.Background(),
"postgres://postgres:postgres@localhost:5432", "postgres://postgres:postgres@localhost:5432",
) )
if err != nil { if err != nil {
@@ -46,16 +30,29 @@ func CreateDB() error {
return err return err
} }
defer conn.Close(context.Background()) 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") _, err = conn.Exec(context.Background(), "CREATE DATABASE muzi")
if err != nil { 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 err
} }
return nil return nil
} }
func CreateHistoryTable(conn *pgx.Conn) error { func CreateHistoryTable() error {
_, err := conn.Exec(context.Background(), _, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS history ( `CREATE TABLE IF NOT EXISTS history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -64,7 +61,7 @@ func CreateHistoryTable(conn *pgx.Conn) error {
artist TEXT NOT NULL, artist TEXT NOT NULL,
album_name TEXT, album_name TEXT,
ms_played INTEGER, ms_played INTEGER,
platform TEXT DEFAULT 'spotify', platform TEXT,
UNIQUE (user_id, song_name, artist, timestamp) 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_timestamp ON history(user_id, timestamp DESC);
@@ -77,10 +74,11 @@ func CreateHistoryTable(conn *pgx.Conn) error {
return nil return nil
} }
func CreateUsersTable(conn *pgx.Conn) error { // TODO: move user settings to jsonb in db
_, err := conn.Exec(context.Background(), func CreateUsersTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL, 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/default.png',
@@ -93,3 +91,29 @@ func CreateUsersTable(conn *pgx.Conn) error {
} }
return nil 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
}

4
go.mod
View File

@@ -6,12 +6,14 @@ require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/jackc/pgtype v1.14.4 github.com/jackc/pgtype v1.14.4
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.45.0
) )
require ( require (
github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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 golang.org/x/text v0.31.0 // indirect
) )

1
go.sum
View File

@@ -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-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 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.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 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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=

85
main.go
View File

@@ -8,23 +8,11 @@ import (
"path/filepath" "path/filepath"
"muzi/db" "muzi/db"
"muzi/migrate"
"muzi/web" "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 { func dirCheck(path string) error {
_, err := os.Stat(path) _, err := os.Stat(path)
if err != nil { if err != nil {
@@ -35,45 +23,28 @@ func dirCheck(path string) error {
return err return err
} }
} }
return nil return nil
} }
func main() { func main() {
dirImports := filepath.Join(".", "imports") zipDir := filepath.Join(".", "imports", "spotify", "zip")
extDir := filepath.Join(".", "imports", "spotify", "extracted")
dirSpotify := filepath.Join(".", "imports", "spotify") dirs := []string{zipDir, extDir}
dirSpotifyZip := filepath.Join(".", "imports", "spotify", "zip") for _, dir := range dirs {
dirSpotifyExt := filepath.Join(".", "imports", "spotify", "extracted") err := dirCheck(dir)
if err != nil {
fmt.Printf("Checking if directory %s exists...\n", dirImports) fmt.Fprintf(os.Stderr, "Error checking dir: %s: %v\n", dir, err)
err := dirCheck(dirImports) return
if err != nil { }
return
} }
fmt.Printf("Checking if directory %s exists...\n", dirSpotify) err := db.CreateDB()
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()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error ensuring muzi DB exists: %v\n", err)
return return
} }
fmt.Println("Setting up database tables...") db.Pool, err = pgxpool.New(
conn, err := pgx.Connect(
context.Background(), context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi", "postgres://postgres:postgres@localhost:5432/muzi",
) )
@@ -81,33 +52,25 @@ func main() {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err) fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
return return
} }
defer conn.Close(context.Background()) defer db.Pool.Close()
err = db.CreateHistoryTable(conn) err = db.CreateAllTables()
if err != nil { 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 return
} }
err = db.CreateUsersTable(conn) err = db.CleanupExpiredSessions()
if err != nil { 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 return
} }
username := "" /*
apiKey := "" err = migrate.ImportSpotify(1)
fmt.Printf("Importing LastFM data for %s\n", username) if err != nil {
// TODO: return
// 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
}
web.Start() web.Start()
} }

View File

@@ -28,6 +28,15 @@ type pageResult struct {
err error 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 { type Response struct {
Recenttracks struct { Recenttracks struct {
Track []struct { Track []struct {
@@ -51,13 +60,21 @@ type Response struct {
} `json:"recenttracks"` } `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( conn, err := pgx.Connect(
context.Background(), context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi", "postgres://postgres:postgres@localhost:5432/muzi",
) )
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err) fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
if progressChan != nil {
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
}
return err return err
} }
defer conn.Close(context.Background()) defer conn.Close(context.Background())
@@ -70,6 +87,9 @@ func ImportLastFM(username string, apiKey string, userId int) error {
) )
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err)
if progressChan != nil {
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
}
return err return err
} }
var initialData Response var initialData Response
@@ -78,10 +98,21 @@ func ImportLastFM(username string, apiKey string, userId int) error {
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err)
if progressChan != nil {
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
}
return err return err
} }
fmt.Printf("Total pages: %d\n", totalPages) 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) trackBatch := make([]LastFMTrack, 0, 1000)
pageChan := make(chan pageResult, 20) pageChan := make(chan pageResult, 20)
@@ -137,6 +168,8 @@ func ImportLastFM(username string, apiKey string, userId int) error {
}() }()
batchSize := 500 batchSize := 500
completedPages := 0
var completedMu sync.Mutex
for result := range pageChan { for result := range pageChan {
if result.err != nil { 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) 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 { 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) 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 return nil
} }

View File

@@ -1,17 +1,17 @@
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; margin: 0 auto;
width: 70vw; width: 70vw;
font-family: sans-serif; font-family: sans-serif;
} }
.page_buttons { .page_buttons {
display: flex; display: flex;
@@ -27,7 +27,19 @@ body {
} }
.user-stats-top { .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 { .username-bio {
@@ -37,10 +49,15 @@ body {
margin-left: 40px; margin-left: 40px;
} }
.profile-top-blank {
width: 50%;
}
.profile-top { .profile-top {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-content: center; align-content: center;
width: 100%;
h1 { h1 {
color: #FFFFFF; color: #FFFFFF;
margin: 0; margin: 0;
@@ -50,11 +67,6 @@ body {
font-size: 15px; font-size: 15px;
margin: 0; margin: 0;
} }
h3 {
color: #AAAAAA;
font-size: 25px;
margin: 0;
}
img { img {
object-fit: cover; object-fit: cover;
width: 250px; width: 250px;
@@ -63,16 +75,24 @@ body {
} }
} }
.login-form {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
}
.login-error { .login-error {
color: #AA0000; color: #AA0000;
} }
.history { .history {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
width: 100vw; width: 100%;
table { table {
width: 90%; width: auto;
} }
tr { tr {
display: flex; display: flex;
@@ -90,3 +110,135 @@ body {
background-color: #111; 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;
}

View File

@@ -14,11 +14,16 @@
<label for="pass">Password:</label> <label for="pass">Password:</label>
<input type="password" id="pass" name="pass"> <br> <br> <input type="password" id="pass" name="pass"> <br> <br>
<input type="submit" value="Login"> <input type="submit" value="Login">
{{if .ShowError}} {{if eq .Error "1"}}
<div class="login-error"> <div class="login-error">
Invalid credentials. Please try again. Invalid credentials. Please try again.
</div> </div>
{{end}} {{end}}
{{if eq .Error "2"}}
<div class="login-error">
Unable to create session. Please try again.
</div>
{{end}}
</form> </form>
</div> </div>
</body> </body>

View File

@@ -13,10 +13,15 @@
<h1>{{.Username}}</h1> <h1>{{.Username}}</h1>
<h2>{{.Bio}}</h2> <h2>{{.Bio}}</h2>
</div> </div>
<div class="user-stats-top"> <div class="profile-top-blank">
<h3>{{.ScrobbleCount}} Listens</h3>
<h3>{{.ArtistCount}} Artists</h3>
</div> </div>
<div class="user-stats-top">
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
</div>
<div class="profile-actions">
<a href="/import" class="btn">Import Data</a>
</div> </div>
<div class="history"> <div class="history">
<h3>Listening History</h3> <h3>Listening History</h3>
@@ -38,8 +43,10 @@
</table> </table>
</div> </div>
<div class="page_buttons"> <div class="page_buttons">
<a href="/profile/{{.Username}}?page={{Sub .Page 1}}">Prev Page</a> {{if gt .Page 1 }}
<a href="/profile/{{.Username}}?page={{Add .Page 1}}">Next Page</a> <a href="/profile/{{.Username}}?page={{sub .Page 1}}">Prev Page</a>
{{end}}
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -2,22 +2,104 @@ package web
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"sync"
"muzi/db" "muzi/db"
"muzi/migrate"
"golang.org/x/crypto/bcrypt" "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" "github.com/jackc/pgtype"
"github.com/jackc/pgx/v5"
) )
// will add permissions later
type Session struct {
Username string
}
var (
importJobs = make(map[string]chan migrate.ProgressUpdate)
jobsMu sync.RWMutex
templates *template.Template
)
func init() {
funcMap := template.FuncMap{
"sub": sub,
"add": add,
"formatInt": formatInt,
}
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}
func generateID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func createSession(username string) string {
sessionID := generateID()
_, 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 { type ProfileData struct {
Username string Username string
Bio string Bio string
@@ -31,118 +113,29 @@ type ProfileData struct {
Page int Page int
} }
func Sub(a int, b int) int { func sub(a int, b int) int {
return a - b return a - b
} }
func Add(a int, b int) int { func add(a int, b int) int {
return a + b return a + b
} }
func getUserIdByUsername(conn *pgx.Conn, username string) (int, error) { 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 var userId int
err := conn.QueryRow(context.Background(), "SELECT pk FROM users WHERE username = $1;", username). err := db.Pool.QueryRow(ctx, "SELECT pk FROM users WHERE username = $1;", username).
Scan(&userId) Scan(&userId)
return userId, err 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 WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId,
lim,
off,
)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT timestamp failed: %v\n", err)
return nil
}
for rows.Next() {
var time pgtype.Timestamptz
err = rows.Scan(&time)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning time failed: %v\n", err)
return nil
}
times = append(times, time.Time.String())
}
return times
}
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 WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId,
lim,
off,
)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT song_name failed: %v\n", err)
return nil
}
for rows.Next() {
var title string
err = rows.Scan(&title)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning title failed: %v\n", err)
return nil
}
titles = append(titles, title)
}
return titles
}
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 WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId,
lim,
off,
)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT artist failed: %v\n", err)
return nil
}
for rows.Next() {
var artist string
err = rows.Scan(&artist)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning artist name failed: %v\n", err)
return nil
}
artists = append(artists, artist)
}
return artists
}
func getScrobbles(conn *pgx.Conn, userId int) int {
var count int
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
}
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 { func hashPassword(pass []byte) string {
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -161,31 +154,21 @@ func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
} }
func createAccount(w http.ResponseWriter, r *http.Request) { func createAccount(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" { if r.Method == "POST" {
r.ParseForm() r.ParseForm()
username := r.FormValue("uname") username := r.FormValue("uname")
hashedPassword := hashPassword([]byte(r.FormValue("pass"))) hashedPassword := hashPassword([]byte(r.FormValue("pass")))
err = db.CreateUsersTable(conn) err := db.CreateUsersTable()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err) fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther) http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
return return
} }
_, err = conn.Exec( _, err = db.Pool.Exec(
context.Background(), r.Context(),
`INSERT INTO users (username, password) VALUES ($1, $2);`, `INSERT INTO users (username, password) VALUES ($1, $2);`,
username, username,
hashedPassword, hashedPassword,
@@ -194,6 +177,18 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err) 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 { } else {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
MaxAge: 86400 * 30, // 30 days
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther) http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} }
} }
@@ -201,44 +196,39 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
func createAccountPageHandler() http.HandlerFunc { func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tmp, err := template.New("create_account.gohtml"). err := templates.ExecuteTemplate(w, "create_account.gohtml", nil)
ParseFiles("./templates/create_account.gohtml")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = tmp.Execute(w, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
func loginSubmit(w http.ResponseWriter, r *http.Request) { func loginSubmit(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" { if r.Method == "POST" {
r.ParseForm() r.ParseForm()
username := r.FormValue("uname") username := r.FormValue("uname")
password := r.FormValue("pass") password := r.FormValue("pass")
var storedPassword string var storedPassword string
err := conn.QueryRow(context.Background(), "SELECT password FROM users WHERE username = $1;", username). err := db.Pool.QueryRow(r.Context(), "SELECT password FROM users WHERE username = $1;", username).
Scan(&storedPassword) Scan(&storedPassword)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err) fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
} }
if verifyPassword(storedPassword, []byte(password)) { if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
MaxAge: 86400 * 30, // 30 days
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther) http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else { } else {
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
@@ -249,22 +239,12 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) {
func loginPageHandler() http.HandlerFunc { func loginPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
type data struct { type data struct {
ShowError bool Error string
} }
d := data{ShowError: false} d := data{Error: r.URL.Query().Get("error")}
if r.URL.Query().Get("error") != "" { err := templates.ExecuteTemplate(w, "login.gohtml", d)
d.ShowError = true
}
tmp, err := template.New("login.gohtml").ParseFiles("./templates/login.gohtml")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = tmp.Execute(w, d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -273,18 +253,7 @@ func profilePageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username") username := chi.URLParam(r, "username")
conn, err := pgx.Connect( userId, err := getUserIdByUsername(r.Context(), username)
context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi",
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close(context.Background())
userId, err := getUserIdByUsername(conn, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err) fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound) http.Error(w, "User not found", http.StatusNotFound)
@@ -307,59 +276,66 @@ func profilePageHandler() http.HandlerFunc {
off := (pageInt - 1) * lim off := (pageInt - 1) * lim
var profileData ProfileData var profileData ProfileData
profileData.Username = username
profileData.Page = pageInt
err = conn.QueryRow( err = db.Pool.QueryRow(
context.Background(), r.Context(),
"SELECT bio, pfp, allow_duplicate_edits FROM users WHERE pk = $1;", `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, userId,
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits) ).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.ArtistCount)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err) fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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
funcMap := template.FuncMap{ rows, err := db.Pool.Query(
"Sub": Sub, r.Context(),
"Add": Add, "SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
} userId,
lim,
tmp, err := template.New("profile.gohtml"). off,
Funcs(funcMap). )
ParseFiles("./templates/profile.gohtml")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "SELECT history failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
tmp.Execute(w, profileData) 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) { 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" { if r.Method == "POST" {
r.ParseForm() r.ParseForm()
username := r.FormValue("username") username := r.FormValue("username")
allow := r.FormValue("allow") == "true" allow := r.FormValue("allow") == "true"
_, err = conn.Exec( _, err := db.Pool.Exec(
context.Background(), r.Context(),
`UPDATE users SET allow_duplicate_edits = $1 WHERE username = $2;`, `UPDATE users SET allow_duplicate_edits = $1 WHERE username = $2;`,
allow, allow,
username, username,
@@ -371,6 +347,121 @@ func updateDuplicateEditsSetting(w http.ResponseWriter, r *http.Request) {
} }
} }
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)
}
}
}
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
}
r.ParseForm()
lastfmUsername := r.FormValue("lastfm_username")
lastfmAPIKey := r.FormValue("lastfm_api_key")
if lastfmUsername == "" || lastfmAPIKey == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
jobID := generateID()
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportLastFM(lastfmUsername, lastfmAPIKey, 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 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 Start() { func Start() {
addr := ":1234" addr := ":1234"
r := chi.NewRouter() r := chi.NewRouter()
@@ -379,9 +470,12 @@ func Start() {
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.Post("/loginsubmit", loginSubmit) r.Post("/loginsubmit", loginSubmit)
r.Post("/createaccountsubmit", createAccount) r.Post("/createaccountsubmit", createAccount)
r.Post("/settings/duplicate-edits", updateDuplicateEditsSetting) r.Post("/settings/duplicate-edits", updateDuplicateEditsSetting)
r.Post("/import/lastfm", importLastFMHandler)
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
fmt.Printf("WebUI starting on %s\n", addr) fmt.Printf("WebUI starting on %s\n", addr)
http.ListenAndServe(addr, r) http.ListenAndServe(addr, r)
} }