mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 11:56:57 -08:00
Compare commits
10 Commits
c01ecaa4a6
...
ad455a36e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad455a36e8 | ||
|
|
c1e1243151 | ||
| e4425bc1e2 | |||
| f7f6b132dc | |||
|
|
ef5b3ec571 | ||
| 1849aae43f | |||
| 1d273248ab | |||
| 22f7f3cf46 | |||
| 9e21d0d5c7 | |||
| 77796e79a4 |
25
.github/workflows/go.yml
vendored
Normal file
25
.github/workflows/go.yml
vendored
Normal 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 ./...
|
||||
@@ -2,7 +2,7 @@
|
||||
## Self-hosted music listening statistics
|
||||
|
||||
### Requirements
|
||||
- Go 1.25.4+
|
||||
- Go 1.25+
|
||||
- PostgreSQL
|
||||
|
||||
### Roadmap:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module muzi
|
||||
|
||||
go 1.25.4
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
web/web.go
54
web/web.go
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user