Compare commits

..

10 Commits

Author SHA1 Message Date
riwiwa
ad455a36e8 update readme 2026-02-08 04:54:52 -08:00
riwiwa
c1e1243151 update go version in workflow to 1.25 2026-02-08 04:53:24 -08:00
e4425bc1e2 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-02-08 04:49:11 -08:00
f7f6b132dc remove patch version for github action 2026-02-08 04:12:49 -08:00
riwiwa
ef5b3ec571 go build 2026-02-08 04:12:09 -08:00
1849aae43f add error check to rand in generateID 2026-02-08 03:02:00 -08:00
1d273248ab replace single pgx conn with pool 2026-02-08 02:34:18 -08:00
22f7f3cf46 better password validation 2026-02-08 00:51:15 -08:00
9e21d0d5c7 added http timeouts when calling lastfm api 2026-02-08 00:24:48 -08:00
77796e79a4 defer close on files after opening 2026-02-08 00:14:40 -08:00
8 changed files with 101 additions and 42 deletions

25
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25'
- name: Build
run: go build -v ./...

View File

@@ -2,7 +2,7 @@
## Self-hosted music listening statistics
### Requirements
- Go 1.25.4+
- Go 1.25+
- PostgreSQL
### Roadmap:

2
go.mod
View File

@@ -1,6 +1,6 @@
module muzi
go 1.25.4
go 1.25
require (
github.com/go-chi/chi/v5 v5.2.3

View File

@@ -11,7 +11,7 @@ import (
"sync"
"time"
"github.com/jackc/pgx/v5"
"muzi/db"
)
type LastFMTrack struct {
@@ -66,22 +66,13 @@ func ImportLastFM(
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())
totalImported := 0
resp, err := http.Get(
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
username + "&api_key=" + apiKey + "&format=json&limit=100",
)
@@ -124,7 +115,7 @@ func ImportLastFM(
go func(workerID int) {
defer wg.Done()
for page := workerID + 1; page <= totalPages; page += 10 {
resp, err := http.Get(
resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page),
)
@@ -180,7 +171,7 @@ func ImportLastFM(
for len(trackBatch) >= batchSize {
batch := trackBatch[:batchSize]
trackBatch = trackBatch[batchSize:]
err := insertBatch(conn, batch, &totalImported, batchSize)
err := insertBatch(batch, &totalImported, batchSize)
if err != nil {
fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err)
}
@@ -206,7 +197,7 @@ func ImportLastFM(
}
if len(trackBatch) > 0 {
err := insertBatch(conn, trackBatch, &totalImported, batchSize)
err := insertBatch(trackBatch, &totalImported, batchSize)
if err != nil {
fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err)
}
@@ -227,8 +218,8 @@ func ImportLastFM(
return nil
}
func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batchSize int) error {
tx, err := conn.Begin(context.Background())
func insertBatch(tracks []LastFMTrack, totalImported *int, batchSize int) error {
tx, err := db.Pool.Begin(context.Background())
if err != nil {
return err
}
@@ -283,6 +274,7 @@ func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batch
}
if err := tx.Commit(context.Background()); err != nil {
tx.Rollback(context.Background())
return err
}

View File

@@ -107,7 +107,12 @@ func getExistingTracks(conn *pgx.Conn, userId int, tracks []SpotifyTrack) (map[s
diff = -diff
}
if diff < 20*time.Second {
key := fmt.Sprintf("%s|%s|%s", newTrack.Artist, newTrack.Name, newTrack.Timestamp)
key := fmt.Sprintf(
"%s|%s|%s",
newTrack.Artist,
newTrack.Name,
newTrack.Timestamp,
)
existing[key] = true
break
}
@@ -269,12 +274,13 @@ func ImportSpotify(userId int) error {
return err
}
for _, f := range entries {
_, err := zip.OpenReader(filepath.Join(path, f.Name()))
reader, err := zip.OpenReader(filepath.Join(path, f.Name()))
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n",
filepath.Join(path, f.Name()), err)
continue
}
defer reader.Close()
fileName := f.Name()
fileFullPath := filepath.Join(path, fileName)
fileBaseName := fileName[:(strings.LastIndex(fileName, "."))]
@@ -339,11 +345,13 @@ func Extract(path string, target string) error {
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err)
return err
}
defer fileToExtract.Close()
extractedFile, err := f.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err)
return err
}
defer extractedFile.Close()
if _, err := io.Copy(fileToExtract, extractedFile); err != nil {
fmt.Fprintf(
os.Stderr,
@@ -354,8 +362,6 @@ func Extract(path string, target string) error {
)
return err
}
fileToExtract.Close()
extractedFile.Close()
}
return nil
}

View File

@@ -14,6 +14,16 @@
<label for="pass">Password:</label>
<input type="password" id="pass" name="pass"> <br> <br>
<input type="submit" value="Create Account">
{{if eq .Error "length"}}
<div class="login-error">
Password must be 8-64 chars (inclusive).
</div>
{{end}}
{{if eq .Error "session"}}
<div class="login-error">
Unable to create session. Please try again.
</div>
{{end}}
</form>
</div>
</body>

View File

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

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
@@ -42,15 +43,22 @@ func init() {
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}
func generateID() string {
func generateID() (string, error) {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func createSession(username string) string {
sessionID := generateID()
_, err := db.Pool.Exec(
sessionID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating sessionID: %v\n", err)
return ""
}
_, err = db.Pool.Exec(
context.Background(),
"INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');",
sessionID,
@@ -136,12 +144,16 @@ func getUserIdByUsername(ctx context.Context, username string) (int, error) {
return userId, err
}
func hashPassword(pass []byte) string {
func hashPassword(pass []byte) (string, error) {
if len(pass) < 8 || len(pass) > 64 {
return "", errors.New("Error: Password must be greater than 8 chars.")
}
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
return "", err
}
return string(hashedPassword)
return string(hashedPassword), nil
}
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
@@ -158,9 +170,14 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.FormValue("uname")
hashedPassword := hashPassword([]byte(r.FormValue("pass")))
hashedPassword, err := hashPassword([]byte(r.FormValue("pass")))
if err != nil {
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
http.Redirect(w, r, "/createaccount?error=length", http.StatusSeeOther)
return
}
err := db.CreateUsersTable()
err = db.CreateUsersTable()
if err != nil {
fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
@@ -179,7 +196,7 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
} else {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther)
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
@@ -196,7 +213,11 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := templates.ExecuteTemplate(w, "create_account.gohtml", nil)
type data struct {
Error string
}
d := data{Error: "len"}
err := templates.ExecuteTemplate(w, "create_account.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -219,7 +240,7 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) {
if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther)
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
@@ -231,7 +252,7 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) {
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
}
}
}
@@ -395,7 +416,12 @@ func importLastFMHandler(w http.ResponseWriter, r *http.Request) {
return
}
jobID := generateID()
jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock()