Compare commits

...

49 Commits

Author SHA1 Message Date
riwiwa
a2ffbbdce4 Update README
Add completed features
2026-02-28 02:54:15 -08:00
78712188d2 finish lastfm endpoint and fix clearing spotify now playing status 2026-02-28 02:50:54 -08:00
1af3efd7b4 only display now playing on page 1 2026-02-28 01:23:38 -08:00
a5d0860292 fix spotify scrobbling 2026-02-28 00:46:30 -08:00
659b68f11d fix spotify connection 2026-02-28 00:13:37 -08:00
d2d325ba46 add now playing to history 2026-02-27 23:49:40 -08:00
9979456719 add scrobbling through listenbrainz-like endpoint and lastfm-like endpoint 2026-02-27 23:27:02 -08:00
90121b4fd1 add remaining settings and navigation pages 2026-02-13 23:15:09 -08:00
78bc1a9974 format time locally for profile history 2026-02-13 23:13:32 -08:00
b3c2446add add settings page, better navigation 2026-02-13 23:10:08 -08:00
5c5b295961 improve readability of the web package 2026-02-13 21:48:21 -08:00
332460b90d redirect to profile on root, login if logged out, create account if no accounts. move pfp location 2026-02-12 23:43:47 -08:00
1478410d0c remove redundant time parsing 2026-02-11 17:23:39 -08:00
fd2e2b0f8a add comments and increase readability 2026-02-11 17:23:17 -08:00
riwiwa
1df12b1755 update readme 2026-02-09 19:54:02 -08:00
c4314456ae add spotify import progress bar 2026-02-09 19:51:30 -08:00
7fe4d02721 clean up backend spotify import functionality 2026-02-09 18:34:49 -08:00
d35e7bffd3 remove db name environment variable 2026-02-09 18:34:37 -08:00
a70dc4882b add database name envvar to GetDbUrl 2026-02-09 04:52:44 -08:00
riwiwa
8ba7ac55d6 update issue templates 2026-02-09 04:31:24 -08:00
38adb391be clean up main.go 2026-02-09 04:15:11 -08:00
b9a7a972e2 remove imports folder and add binary to gitignore 2026-02-09 03:55:48 -08:00
126b77fa87 rework spotify import, add import webUI 2026-02-09 03:53:05 -08:00
4d77999edb clean and check username and passw better, handle parseForm errors 2026-02-08 21:56:47 -08:00
32ccdcd5f3 add username to lastfm import logs 2026-02-08 21:49:55 -08:00
a33e724199 add CSRF protection, add cookie security 2026-02-08 21:46:50 -08:00
349c28e29c move fetchPage to own function, defer resp.Body.Close 2026-02-08 21:40:25 -08:00
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
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
b8150a2f34 censor password on account creation and login 2026-02-06 18:44:06 -08:00
riwiwa
44d3ea1204 Update README 2026-02-05 03:27:39 -08:00
813f510a9e Added userID in history table for per profile history, reworked primary key. Profile recent history table 2026-02-05 03:16:05 -08:00
0043d83330 cleaned up project structure and optimized lastfm and spotify migration 2026-02-05 00:20:42 -08:00
4fa797d36a started working on user profiles 2026-01-29 20:20:36 -08:00
91c6bea0c6 improved error handling in importsongs/importsongs.go 2026-01-08 21:22:33 -08:00
ea1ac394d5 Merge branch 'main' of github.com:riwiwa/muzi
merging readme change from github
2026-01-08 19:21:05 -08:00
8c3bef6a43 lastfm import accepts dynamic track amount and now allows scrobbling while
importing
2026-01-08 19:18:19 -08:00
riwiwa
a8352c5bf9 Update README.md 2026-01-08 04:41:03 -08:00
679d7f9202 early working LastFM import functionality 2026-01-08 04:36:54 -08:00
36 changed files with 4231 additions and 464 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

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 ./...

2
.gitignore vendored
View File

@@ -1 +1 @@
imports
muzi

View File

@@ -1,30 +1,22 @@
# Muzi
## Self-hosted music listening statistics
### Dependencies
### Requirements
- Go 1.25+
- PostgreSQL
### Installation Instructions (for testing and development) \[Only Supports Spotify Imports ATM\]:
1. Clone the repo:<br>```git clone https://github.com/riwiwa/muzi```
2. Copy over all zip archives obtained from Spotify into the ```imports/spotify-data/zip/``` directory.
3. Ensure PostgreSQL is installed and running locally on port 5432.
4. Run the app with:<br>```go run main.go```
5. Navigate to ```localhost:1234/history``` to see your sorted listening history.
6. Comment out ```importsongs.ImportSpotify()``` from ```main.go``` to prevent the app's attempts to import the Spotify data again
### Roadmap:
- Ability to import all listening statistics and scrobbles from: \[In Progress\]
- lastfm
- spotify \[Complete\]
- apple music
- LastFM \[Complete\]
- Spotify \[Complete\]
- Apple Music \[Planned\]
- WebUI \[In Progress\]
- Full listening history with time \[Functional\]
- Full listening history with time \[Complete\]
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
- Ability to specify a certain point in time from one datetime to another to list data
- Grid maker (3x3-10x10)
- Ability to change artist image
- Multi artist scrobbling
- Ability to "sync" offline scrobbles (send from a device to the server)
- Live scrobbling to the server
- Live scrobbling to the server (With Now playing status) \[Complete\]
- Batch scrobble editor

163
db/db.go Normal file
View File

@@ -0,0 +1,163 @@
package db
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var Pool *pgxpool.Pool
func CreateAllTables() error {
if err := CreateHistoryTable(); err != nil {
return err
}
if err := CreateUsersTable(); err != nil {
return err
}
if err := CreateSessionsTable(); err != nil {
return err
}
return CreateSpotifyLastTrackTable()
}
func GetDbUrl(dbName bool) string {
host := os.Getenv("PGHOST")
port := os.Getenv("PGPORT")
user := os.Getenv("PGUSER")
pass := os.Getenv("PGPASSWORD")
if dbName {
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s",
user, pass, host, port, "muzi")
} else {
return fmt.Sprintf("postgres://%s:%s@%s:%s", user, pass, host, port)
}
}
func CreateDB() error {
conn, err := pgx.Connect(context.Background(),
GetDbUrl(false),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot connect to PostgreSQL: %v\n", err)
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, "Error creating muzi database: %v\n", err)
return err
}
return nil
}
func CreateHistoryTable() error {
_, err := Pool.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,
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() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
bio TEXT DEFAULT 'This profile has no bio.',
pfp TEXT DEFAULT '/files/assets/pfps/default.png',
allow_duplicate_edits BOOLEAN DEFAULT FALSE,
api_key TEXT,
api_secret TEXT,
spotify_client_id TEXT,
spotify_client_secret TEXT,
spotify_access_token TEXT,
spotify_refresh_token TEXT,
spotify_token_expires TIMESTAMPTZ,
last_spotify_check TIMESTAMPTZ,
pk SERIAL PRIMARY KEY
);
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
return err
}
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 sessions: %v\n", err)
return err
}
return nil
}
func CreateSpotifyLastTrackTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS spotify_last_track (
user_id INTEGER PRIMARY KEY REFERENCES users(pk) ON DELETE CASCADE,
track_id TEXT NOT NULL,
song_name TEXT NOT NULL,
artist TEXT NOT NULL,
album_name TEXT,
duration_ms INTEGER NOT NULL,
progress_ms INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating spotify_last_track table: %v\n", err)
return err
}
return nil
}

6
go.mod
View File

@@ -1,17 +1,19 @@
module muzi
go 1.25.4
go 1.25
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
)

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

View File

@@ -1,214 +0,0 @@
package importsongs
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
"io"
"os"
"path/filepath"
"strings"
)
const (
spotify = iota
lastfm
apple
)
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 JsonToDB(jsonFile string, platform int) {
if !DbExists() {
err := CreateDB()
if err != nil {
panic(err)
}
}
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)
}
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)
panic(err)
}
if platform == spotify {
type Track struct {
Timestamp string `json:"ts"`
//Platform string `json:"platform"`
Played int `json:"ms_played"`
//Country string `json:"conn_country"`
//IP string `json:"ip_addr"`
Name string `json:"master_metadata_track_name"`
Artist string `json:"master_metadata_album_artist_name"`
Album string `json:"master_metadata_album_album_name"`
//TrackURI string `json:"spotify_track_uri"`
//Episode string `json:"episode_name"`
//Show string `json:"episode_show_name"`
//EpisodeURI string `json:"spotify_episode_uri"`
//Audiobook string `json:"audiobook_title"`
//AudiobookURI string `json:"audiobook_uri"`
//AudiobookChapterURI string `json:"audiobook_chapter_uri"`
//AudiobookChapter string `json:"audiobook_chapter_title"`
//ReasonStart string `json:"reason_start"`
//ReasonEnd string `json:"reason_end"`
//Shuffle bool `json:"shuffle"`
//Skipped bool `json:"skipped"`
//Offline bool `json:"offline"`
//OfflineTimestamp int `json:"offline_timestamp"`
//Incognito bool `json:"incognito_mode"`
}
var tracks []Track
err := json.Unmarshal(jsonData, &tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot unmarshal %s: %v\n", jsonFile, err)
panic(err)
}
for _, track := range tracks {
// skip adding a song if it was only listed to for less than 20 seconds
if track.Played < 20000 {
continue
}
_, err = conn.Exec(context.Background(), "INSERT INTO history (timestamp, song_name, artist, album_name, ms_played) VALUES ($1, $2, $3, $4, $5);", track.Timestamp, track.Name, track.Artist, track.Album, track.Played)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't add track to muzi DB (%s): %v\n", (track.Artist + " - " + track.Name), err)
}
}
}
}
func AddDirToDB(path string, platform int) {
dirs, err := os.ReadDir(path)
if err != nil {
panic(err)
}
for _, dir := range dirs {
subPath := filepath.Join(path, dir.Name(), "Spotify Extended Streaming History")
entries, err := os.ReadDir(subPath)
if err != nil {
panic(err)
}
for _, f := range entries {
jsonFileName := f.Name()
if platform == spotify {
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)
JsonToDB(jsonFilePath, platform)
}
}
}
func ImportSpotify() {
path := filepath.Join(".", "imports", "spotify", "zip")
targetBase := filepath.Join(".", "imports", "spotify", "extracted")
entries, err := os.ReadDir(path)
if err != nil {
panic(err)
}
for _, f := range entries {
_, err := zip.OpenReader(filepath.Join(path, f.Name()))
if err == nil {
fileName := f.Name()
fileFullPath := filepath.Join(path, fileName)
fileBaseName := fileName[:(strings.LastIndex(fileName, "."))]
targetDirFullPath := filepath.Join(targetBase, fileBaseName)
Extract(fileFullPath, targetDirFullPath)
}
}
AddDirToDB(targetBase, spotify)
}
func Extract(path string, target string) {
archive, err := zip.OpenReader(path)
if err != nil {
panic(err)
}
defer archive.Close()
zipDir := filepath.Base(path)
zipDir = zipDir[:(strings.LastIndex(zipDir, "."))]
for _, f := range archive.File {
filePath := filepath.Join(target, f.Name)
fmt.Println("extracting:", filePath)
if !strings.HasPrefix(filePath, filepath.Clean(target)+string(os.PathSeparator)) {
fmt.Println("Invalid file path")
return
}
if f.FileInfo().IsDir() {
fmt.Println("Creating Directory", filePath)
os.MkdirAll(filePath, os.ModePerm)
continue
}
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
panic(err)
}
fileToExtract, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
panic(err)
}
extractedFile, err := f.Open()
if err != nil {
panic(err)
}
if _, err := io.Copy(fileToExtract, extractedFile); err != nil {
panic(err)
}
fileToExtract.Close()
extractedFile.Close()
}
}

75
main.go
View File

@@ -1,73 +1,34 @@
package main
import (
"errors"
"context"
"fmt"
"muzi/importsongs"
"muzi/web"
"os"
"muzi/db"
"muzi/scrobble"
"muzi/web"
"github.com/jackc/pgx/v5/pgxpool"
)
func dbCheck() error {
if !importsongs.DbExists() {
err := importsongs.CreateDB()
func check(msg string, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating muzi DB: %v\n", err)
return err
fmt.Fprintf(os.Stderr, "Error %s: %v\n", msg, err)
os.Exit(1)
}
}
return nil
}
func dirCheck(path string) error {
_, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
os.MkdirAll(path, os.ModePerm)
} else {
fmt.Fprintf(os.Stderr, "Error checking dir: %s: %v\n", path, err)
return err
}
}
return nil
}
func main() {
dirImports := "./imports/"
check("ensuring muzi DB exists", db.CreateDB())
dirSpotify := "./imports/spotify/"
dirSpotifyZip := "./imports/spotify/zip/"
dirSpotifyExt := "./imports/spotify/extracted/"
var err error
db.Pool, err = pgxpool.New(context.Background(), db.GetDbUrl(true))
check("connecting to muzi database", err)
defer db.Pool.Close()
dirLastFM := "./imports/lastfm/"
err := dirCheck(dirImports)
if err != nil {
return
}
err = dirCheck(dirSpotify)
if err != nil {
return
}
err = dirCheck(dirSpotifyZip)
if err != nil {
return
}
err = dirCheck(dirSpotifyExt)
if err != nil {
return
}
err = dirCheck(dirLastFM)
if err != nil {
return
}
err = dbCheck()
if err != nil {
return
}
importsongs.ImportSpotify()
check("ensuring all tables exist", db.CreateAllTables())
check("cleaning expired sessions", db.CleanupExpiredSessions())
scrobble.StartSpotifyPoller()
web.Start()
}

255
migrate/lastfm.go Normal file
View File

@@ -0,0 +1,255 @@
package migrate
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"muzi/db"
"github.com/jackc/pgx/v5"
)
type LastFMTrack struct {
UserId int
Timestamp time.Time
SongName string
Artist string
Album string
}
type pageResult struct {
pageNum int
tracks []LastFMTrack
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 {
Artist struct {
Text string `json:"#text"`
} `json:"artist"`
Album struct {
Text string `json:"#text"`
} `json:"album"`
Name string `json:"name"`
Attr struct {
Nowplaying string `json:"nowplaying"`
} `json:"@attr,omitempty"`
Date struct {
Uts string `json:"uts"`
} `json:"date"`
} `json:"track"`
Attr struct {
TotalPages string `json:"totalPages"`
} `json:"@attr"`
} `json:"recenttracks"`
}
func fetchPage(client *http.Client, page int, lfmUsername, apiKey string, userId int) pageResult {
resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
lfmUsername + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page),
)
if err != nil {
return pageResult{pageNum: page, err: err}
}
defer resp.Body.Close()
var data Response
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return pageResult{pageNum: page, err: err}
}
var pageTracks []LastFMTrack
for j := range data.Recenttracks.Track {
if data.Recenttracks.Track[j].Attr.Nowplaying == "true" {
continue
}
unixTime, err := strconv.ParseInt(data.Recenttracks.Track[j].Date.Uts, 10, 64)
if err != nil {
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,
Album: data.Recenttracks.Track[j].Album.Text,
})
}
return pageResult{pageNum: page, tracks: pageTracks, err: nil}
}
func ImportLastFM(
lfmUsername string,
apiKey string,
userId int,
progressChan chan<- ProgressUpdate,
username string,
) error {
totalImported := 0
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
lfmUsername + "&api_key=" + apiKey + "&format=json&limit=100",
)
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
}
defer resp.Body.Close()
var initialData Response
err = json.NewDecoder(resp.Body).Decode(&initialData)
if err != nil {
fmt.Fprintf(os.Stderr,
"Error decoding initial LastFM response: %v\n", err)
return err
}
totalPages, err := strconv.Atoi(initialData.Recenttracks.Attr.TotalPages)
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("%s started a LastFM import job of %d total pages\n", username,
totalPages)
// send initial progress update
if progressChan != nil {
progressChan <- ProgressUpdate{
TotalPages: totalPages,
Status: "running",
}
}
trackBatch := make([]LastFMTrack, 0, 1000)
pageChan := make(chan pageResult, 20)
var wg sync.WaitGroup
wg.Add(10)
for worker := range 10 {
go func(workerID int) {
defer wg.Done()
for page := workerID + 1; page <= totalPages; page += 10 {
pageChan <- fetchPage(client, page, lfmUsername, apiKey, userId)
}
}(worker)
}
go func() {
wg.Wait()
close(pageChan)
}()
batchSize := 500
completedPages := 0
var completedMu sync.Mutex
for result := range pageChan {
if result.err != nil {
fmt.Fprintf(os.Stderr, "Error on page %d: %v\n", result.pageNum, result.err)
continue
}
trackBatch = append(trackBatch, result.tracks...)
for len(trackBatch) >= batchSize {
batch := trackBatch[:batchSize]
trackBatch = trackBatch[batchSize:]
err := insertBatch(batch, &totalImported)
if err != nil {
// prevent logs being filled by duplicate warnings
if !strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err)
}
}
}
// 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 {
err := insertBatch(trackBatch, &totalImported)
if err != nil {
// prevent logs being filled by duplicate warnings
if !strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err)
}
}
}
fmt.Printf("User %s imported %d tracks from LastFM account %s\n",
username,
totalImported,
lfmUsername)
// send completion update
if progressChan != nil {
progressChan <- ProgressUpdate{
CurrentPage: totalPages,
TotalPages: totalPages,
TracksImported: totalImported,
Status: "completed",
}
}
return nil
}
func insertBatch(tracks []LastFMTrack, totalImported *int) error {
copyCount, err := db.Pool.CopyFrom(context.Background(),
pgx.Identifier{"history"},
[]string{
"user_id", "timestamp", "song_name", "artist", "album_name",
"ms_played", "platform",
},
pgx.CopyFromSlice(len(tracks), func(i int) ([]any, error) {
t := tracks[i]
return []any{
t.UserId, t.Timestamp, t.SongName, t.Artist,
t.Album, 0, "lastfm",
}, nil
}),
)
*totalImported += int(copyCount)
return err
}

359
migrate/spotify.go Normal file
View File

@@ -0,0 +1,359 @@
package migrate
// Spotify import functionality for migrating Spotify listening history
// from JSON export files into the database
// This file handles:
// - Parsing Spotify JSON track data
// - Batch processing with deduplication (20-second window)
// - Efficient bulk inserts using pgx.CopyFrom
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"muzi/db"
"github.com/jackc/pgx/v5"
)
const (
batchSize = 1000
minPlayTime = 20000 // 20000 ms = 20 sec
timeDiff = 20 * time.Second
)
// Represents a single listening event from Spotify's JSON export format
type SpotifyTrack struct {
Timestamp time.Time `json:"ts"`
Played int `json:"ms_played"`
Name string `json:"master_metadata_track_name"`
Artist string `json:"master_metadata_album_artist_name"`
Album string `json:"master_metadata_album_album_name"`
}
// Implements pgx.CopyFromSource for efficient bulk inserts.
// Filters out duplicates in-memory before sending to PostgreSQL
type trackSource struct {
tracks []SpotifyTrack // Full batch of tracks to process
tracksToSkip map[string]struct{} // Set of duplicate keys to skip
idx int // Current position in tracks slice
userId int // User ID to associate with imported tracks
}
// Represents a track already stored in the database, used for duplicate
// detection during import
type dbTrack struct {
Timestamp time.Time
SongName string
Artist string
}
// Import Spotify listening history into the database.
// Processes tracks in batches of 1000 (default), filters out tracks played <
// 20 seconds, deduplicates against existing data, and sends progress updates
// via progressChan.
// The progressChan must not be closed by the caller. The receiver should
// stop reading when Status is "completed". This avoids panics from
// sending on a closed channel.
func ImportSpotify(tracks []SpotifyTrack,
userId int, progressChan chan ProgressUpdate,
) {
totalImported := 0
totalTracks := len(tracks)
batchStart := 0
totalBatches := (totalTracks + batchSize - 1) / batchSize
// Send initial progress update
sendProgressUpdate(progressChan, 0, 0, totalBatches, totalImported, "running")
for batchStart < totalTracks {
// Cap batchEnd at total track count on final batch to prevent OOB error
batchEnd := min(batchStart+batchSize, totalTracks)
currentBatch := (batchStart / batchSize) + 1
var validTracks []SpotifyTrack
for i := batchStart; i < batchEnd; i++ {
if tracks[i].Played >= minPlayTime &&
tracks[i].Name != "" &&
tracks[i].Artist != "" {
validTracks = append(validTracks, tracks[i])
}
}
if len(validTracks) == 0 {
batchStart += batchSize
// Send progress update even for empty batches
sendProgressUpdate(
progressChan,
currentBatch,
currentBatch,
totalBatches,
totalImported,
"running",
)
continue
}
tracksToSkip, err := getDupes(userId, validTracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err)
batchStart += batchSize
continue
}
src := &trackSource{
tracks: validTracks,
tracksToSkip: tracksToSkip,
idx: 0,
userId: userId,
}
copyCount, err := db.Pool.CopyFrom(
context.Background(),
pgx.Identifier{"history"},
[]string{
"user_id",
"timestamp",
"song_name",
"artist",
"album_name",
"ms_played",
"platform",
},
src,
)
// Do not log errors that come from adding duplicate songs
if err != nil {
if !strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Spotify batch insert failed: %v\n", err)
}
} else {
totalImported += int(copyCount)
}
sendProgressUpdate(
progressChan,
currentBatch,
currentBatch,
totalBatches,
totalImported,
"running",
)
batchStart += batchSize
}
sendProgressUpdate(
progressChan,
totalBatches,
totalBatches,
totalBatches,
totalImported,
"completed",
)
}
// Sends a progress update to the channel if it's not nil.
// To avoid panics from sending on a closed channel, the channel
// must never be closed by the receiver. The receiver should stop reading when
// Status reads "completed".
func sendProgressUpdate(
ch chan ProgressUpdate,
current, completed, total, imported int,
status string,
) {
if ch != nil {
ch <- ProgressUpdate{
CurrentPage: current,
CompletedPages: completed,
TotalPages: total,
TracksImported: imported,
Status: status,
}
}
}
// Finds tracks that already exist in the database or are duplicates within the
// current batch, using a 20-second window to handle minor timestamp variations
func getDupes(userId int, tracks []SpotifyTrack) (map[string]struct{}, error) {
minTs, maxTs := findTimeRange(tracks)
if minTs.IsZero() {
return map[string]struct{}{}, nil
}
dbTracks, err := fetchDbTracks(userId, minTs, maxTs)
if err != nil {
return nil, err
}
dbIndex := buildDbTrackIndex(dbTracks)
duplicates := make(map[string]struct{})
seenInBatch := make(map[string]struct{})
for _, track := range tracks {
trackKey := createTrackKey(track)
// Check in batch
if _, seen := seenInBatch[trackKey]; seen {
duplicates[trackKey] = struct{}{}
continue
}
seenInBatch[trackKey] = struct{}{}
// Check in DB
lookupKey := fmt.Sprintf("%s|%s", track.Artist, track.Name)
if dbTimestamps, found := dbIndex[lookupKey]; found {
if isDuplicateWithinWindow(track, dbTimestamps) {
duplicates[trackKey] = struct{}{}
}
}
}
return duplicates, nil
}
// Get the min/max timestamp range for a batch of tracks
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
var minTs, maxTs time.Time
for _, t := range tracks {
if minTs.IsZero() || t.Timestamp.Before(minTs) {
minTs = t.Timestamp
}
if t.Timestamp.After(maxTs) {
maxTs = t.Timestamp
}
}
return minTs, maxTs
}
// Get all tracks in the database for a user that have the same timestamp
// range as the current batch
func fetchDbTracks(userId int, minTs, maxTs time.Time) ([]dbTrack, error) {
rows, err := db.Pool.Query(context.Background(),
`SELECT song_name, artist, timestamp
FROM history
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3`,
userId,
// Adjust 20 seconds to find duplicates on edges of batch
minTs.Add(-timeDiff),
maxTs.Add(timeDiff))
if err != nil {
return nil, err
}
defer rows.Close()
var dbTracks []dbTrack
for rows.Next() {
var t dbTrack
if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
continue
}
dbTracks = append(dbTracks, t)
}
err = rows.Err()
if err != nil {
return nil, err
}
return dbTracks, nil
}
// Create a lookup map from Artist|Name to timestamps for efficient duplicate
// detection.
func buildDbTrackIndex(tracks []dbTrack) map[string][]time.Time {
index := make(map[string][]time.Time)
for _, t := range tracks {
key := t.Artist + "|" + t.SongName
index[key] = append(index[key], t.Timestamp)
}
return index
}
// Generate a unique identifier for a track using artist, name, and
// normalized timestamp.
func createTrackKey(track SpotifyTrack) string {
ts := track.Timestamp.Format(time.RFC3339Nano)
return fmt.Sprintf("%s|%s|%s", track.Artist, track.Name, ts)
}
// Check if a track timestamp falls < 20 seconds of another
func isDuplicateWithinWindow(track SpotifyTrack,
existingTimestamps []time.Time,
) bool {
for _, existingTime := range existingTimestamps {
diff := track.Timestamp.Sub(existingTime)
if diff < 0 {
diff = -diff
}
if diff < timeDiff {
return true
}
}
return false
}
// Advances to the next valid track, skipping duplicates and invalid timestamps.
// Returns false when all tracks have been processed
func (s *trackSource) Next() bool {
for s.idx < len(s.tracks) {
t := s.tracks[s.idx]
key := createTrackKey(t)
if _, shouldSkip := s.tracksToSkip[key]; shouldSkip {
s.idx++
continue
}
s.idx++
return true
}
return false
}
// Returns the current track's data formatted for database insertion.
// Must only be called after Next() returns true
func (s *trackSource) Values() ([]any, error) {
// idx is already incremented in Next(), so use idx-1
t := s.tracks[s.idx-1]
return []any{
s.userId,
t.Timestamp,
t.Name,
t.Artist,
t.Album,
t.Played,
"spotify",
}, nil
}
// Returns any error encountered during iteration.
// Currently always returns nil as errors are logged in Next()
func (s *trackSource) Err() error {
return nil
}
// Implements custom JSON unmarshaling to parse the timestamp
func (s *SpotifyTrack) UnmarshalJSON(data []byte) error {
type Alias SpotifyTrack
aux := &struct {
Timestamp string `json:"ts"`
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
ts, err := time.Parse(time.RFC3339Nano, aux.Timestamp)
if err != nil {
return fmt.Errorf("parsing timestamp: %w", err)
}
s.Timestamp = ts
return nil
}

362
scrobble/lastfm.go Normal file
View File

@@ -0,0 +1,362 @@
package scrobble
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"muzi/db"
)
type LastFMHandler struct{}
func NewLastFMHandler() *LastFMHandler {
return &LastFMHandler{}
}
func (h *LastFMHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
if r.URL.Query().Get("hs") == "true" {
h.handleHandshake(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseForm()
if err != nil {
h.respond(w, "failed", 400, "Invalid request")
return
}
method := r.PostForm.Get("method")
apiKey := r.PostForm.Get("api_key")
sk := r.PostForm.Get("s")
track := r.PostForm.Get("t")
if method != "" {
switch method {
case "auth.gettoken":
h.handleGetToken(w, apiKey)
case "auth.getsession":
h.handleGetSession(w, r)
case "track.updateNowPlaying":
h.handleNowPlaying(w, r)
case "track.scrobble":
h.handleScrobble(w, r)
default:
h.respond(w, "failed", 400, fmt.Sprintf("Invalid method: %s", method))
}
return
}
if sk != "" {
if r.PostForm.Get("a[0]") != "" && (r.PostForm.Get("t[0]") != "" || r.PostForm.Get("i[0]") != "") {
h.handleScrobble(w, r)
return
}
if track != "" {
h.handleNowPlaying(w, r)
return
}
}
h.respond(w, "failed", 400, "Missing required parameters")
}
func (h *LastFMHandler) respond(w http.ResponseWriter, status string, code int, message string) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(fmt.Sprintf("FAILED %s", message)))
}
func (h *LastFMHandler) respondOK(w http.ResponseWriter, content string) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Write([]byte(content))
}
func (h *LastFMHandler) handleHandshake(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("u")
token := r.URL.Query().Get("t")
authToken := r.URL.Query().Get("a")
if username == "" || token == "" || authToken == "" {
w.Write([]byte("BADAUTH"))
return
}
userId, err := GetUserByUsername(username)
if err != nil {
w.Write([]byte("BADAUTH"))
return
}
sessionKey, err := GenerateSessionKey()
if err != nil {
w.Write([]byte("FAILED Could not generate session"))
return
}
_, err = db.Pool.Exec(context.Background(),
`UPDATE users SET api_secret = $1 WHERE pk = $2`,
sessionKey, userId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating session key: %v\n", err)
w.Write([]byte("FAILED Database error"))
return
}
w.Write([]byte(fmt.Sprintf("OK\n%s\nhttp://127.0.0.1:1234/2.0/\nhttp://127.0.0.1:1234/2.0/\n", sessionKey)))
}
func (h *LastFMHandler) handleGetToken(w http.ResponseWriter, apiKey string) {
userId, _, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respond(w, "failed", 10, "Invalid API key")
return
}
token, err := GenerateSessionKey()
if err != nil {
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<token>%s</token>
</lfm>`, token))
_ = userId
}
func (h *LastFMHandler) handleGetSession(w http.ResponseWriter, r *http.Request) {
apiKey := r.FormValue("api_key")
userId, username, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respond(w, "failed", 10, "Invalid API key")
return
}
sessionKey, err := GenerateSessionKey()
if err != nil {
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
_, err = db.Pool.Exec(context.Background(),
`UPDATE users SET api_secret = $1 WHERE pk = $2`,
sessionKey, userId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating session key: %v\n", err)
h.respond(w, "failed", 16, "Service temporarily unavailable")
return
}
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<session>
<name>%s</name>
<key>%s</key>
<subscriber>0</subscriber>
</session>
</lfm>`, username, sessionKey))
}
func (h *LastFMHandler) handleNowPlaying(w http.ResponseWriter, r *http.Request) {
sessionKey := r.PostForm.Get("s")
if sessionKey == "" {
h.respond(w, "failed", 9, "Invalid session")
return
}
userId, _, err := GetUserBySessionKey(sessionKey)
if err != nil {
h.respond(w, "failed", 9, "Invalid session")
return
}
artist := r.PostForm.Get("a")
track := r.PostForm.Get("t")
album := r.PostForm.Get("b")
if track == "" {
h.respondOK(w, "OK")
return
}
duration := r.PostForm.Get("l")
msPlayed := 0
if duration != "" {
if d, err := strconv.Atoi(duration); err == nil {
msPlayed = d * 1000
}
}
UpdateNowPlaying(NowPlaying{
UserId: userId,
SongName: track,
Artist: artist,
Album: album,
MsPlayed: msPlayed,
Platform: "lastfm_api",
UpdatedAt: time.Now(),
})
h.respondOK(w, "OK")
}
func (h *LastFMHandler) handleScrobble(w http.ResponseWriter, r *http.Request) {
sessionKey := r.PostForm.Get("s")
if sessionKey == "" {
h.respond(w, "failed", 9, "Invalid session")
return
}
userId, _, err := GetUserBySessionKey(sessionKey)
if err != nil {
h.respond(w, "failed", 9, "Invalid session")
return
}
scrobbles := h.parseScrobbles(r.PostForm, userId)
if len(scrobbles) == 0 {
h.respond(w, "failed", 1, "No scrobbles to submit")
return
}
accepted, ignored := 0, 0
for _, scrobble := range scrobbles {
err := SaveScrobble(scrobble)
if err != nil {
if err.Error() == "duplicate scrobble" {
ignored++
}
continue
}
accepted++
}
ClearNowPlaying(userId)
h.respondOK(w, fmt.Sprintf("OK\n%d\n%d\n", accepted, ignored))
}
func (h *LastFMHandler) parseScrobbles(form url.Values, userId int) []Scrobble {
var scrobbles []Scrobble
for i := 0; i < 50; i++ {
var artist, track, album, timestampStr string
if i == 0 {
artist = form.Get("a[0]")
track = form.Get("t[0]")
album = form.Get("b[0]")
timestampStr = form.Get("i[0]")
} else {
artist = form.Get(fmt.Sprintf("a[%d]", i))
track = form.Get(fmt.Sprintf("t[%d]", i))
album = form.Get(fmt.Sprintf("b[%d]", i))
timestampStr = form.Get(fmt.Sprintf("i[%d]", i))
}
if artist == "" || track == "" || timestampStr == "" {
break
}
ts, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
continue
}
duration := form.Get(fmt.Sprintf("l[%d]", i))
msPlayed := 0
if duration != "" {
if d, err := strconv.Atoi(duration); err == nil {
msPlayed = d * 1000
}
}
scrobbles = append(scrobbles, Scrobble{
UserId: userId,
Timestamp: time.Unix(ts, 0).UTC(),
SongName: track,
Artist: artist,
Album: album,
MsPlayed: msPlayed,
Platform: "lastfm_api",
})
}
return scrobbles
}
func SignRequest(params map[string]string, secret string) string {
var keys []string
for k := range params {
keys = append(keys, k)
}
for i := 0; i < len(keys)-1; i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var str string
for _, k := range keys {
str += k + params[k]
}
str += secret
hash := md5.Sum([]byte(str))
return hex.EncodeToString(hash[:])
}
func SignAPIRequest(params map[string]string, secret string) string {
var pairs []string
for k, v := range params {
pairs = append(pairs, k+"="+url.QueryEscape(v))
}
signature := SignRequest(map[string]string{"api_key": params["api_key"], "method": params["method"]}, secret)
return signature
}
func FetchURL(client *http.Client, endpoint, method string, params map[string]string) (string, error) {
data := url.Values{}
for k, v := range params {
data.Set(k, v)
}
req, err := http.NewRequest(method, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}

195
scrobble/listenbrainz.go Normal file
View File

@@ -0,0 +1,195 @@
package scrobble
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type ListenbrainzHandler struct{}
func NewListenbrainzHandler() *ListenbrainzHandler {
return &ListenbrainzHandler{}
}
type SubmitListensRequest struct {
ListenType string `json:"listen_type"`
Payload []ListenPayload `json:"payload"`
}
type ListenPayload struct {
ListenedAt int64 `json:"listened_at"`
TrackMetadata TrackMetadata `json:"track_metadata"`
}
type TrackMetadata struct {
ArtistName string `json:"artist_name"`
TrackName string `json:"track_name"`
ReleaseName string `json:"release_name"`
AdditionalInfo AdditionalInfo `json:"additional_info"`
}
type AdditionalInfo struct {
Duration int `json:"duration"`
}
func (h *ListenbrainzHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey := r.Header.Get("Authorization")
if apiKey == "" {
apiKey = r.URL.Query().Get("token")
}
if apiKey == "" {
h.respondError(w, "No authorization token provided", 401)
return
}
apiKey = stripBearer(apiKey)
userId, _, err := GetUserByAPIKey(apiKey)
if err != nil {
h.respondError(w, "Invalid authorization token", 401)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
h.respondError(w, "Invalid request body", 400)
return
}
defer r.Body.Close()
var req SubmitListensRequest
if err := json.Unmarshal(body, &req); err != nil {
h.respondError(w, "Invalid JSON", 400)
return
}
switch req.ListenType {
case "single":
h.handleScrobbles(w, userId, req.Payload)
case "playing_now":
h.handleNowPlaying(w, userId, req.Payload)
case "import":
h.handleScrobbles(w, userId, req.Payload)
default:
h.respondError(w, "Invalid listen_type", 400)
}
}
func (h *ListenbrainzHandler) respondError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"message": message,
})
}
func (h *ListenbrainzHandler) respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func (h *ListenbrainzHandler) handleScrobbles(w http.ResponseWriter, userId int, payload []ListenPayload) {
if len(payload) == 0 {
h.respondError(w, "No listens provided", 400)
return
}
scrobbles := make([]Scrobble, 0, len(payload))
for _, p := range payload {
duration := 0
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
}
scrobbles = append(scrobbles, Scrobble{
UserId: userId,
Timestamp: time.Unix(p.ListenedAt, 0).UTC(),
SongName: p.TrackMetadata.TrackName,
Artist: p.TrackMetadata.ArtistName,
Album: p.TrackMetadata.ReleaseName,
MsPlayed: duration,
Platform: "listenbrainz",
})
}
accepted, ignored, err := SaveScrobbles(scrobbles)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobbles: %v\n", err)
h.respondError(w, "Error saving scrobbles", 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"accepted": accepted,
"ignored": ignored,
"mbids": []string{},
"submit_token": "",
})
}
func (h *ListenbrainzHandler) handleNowPlaying(w http.ResponseWriter, userId int, payload []ListenPayload) {
if len(payload) == 0 {
h.respondError(w, "No payload provided", 400)
return
}
p := payload[0]
duration := 0
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
}
UpdateNowPlaying(NowPlaying{
UserId: userId,
SongName: p.TrackMetadata.TrackName,
Artist: p.TrackMetadata.ArtistName,
Album: p.TrackMetadata.ReleaseName,
MsPlayed: duration,
Platform: "listenbrainz",
UpdatedAt: time.Now(),
})
h.respondOK(w)
}
func stripBearer(token string) string {
if len(token) > 7 && strings.HasPrefix(token, "Bearer ") {
return token[7:]
}
if len(token) > 6 && strings.HasPrefix(token, "Token ") {
return token[6:]
}
return token
}
func ParseTimestamp(ts interface{}) (time.Time, error) {
switch v := ts.(type) {
case float64:
return time.Unix(int64(v), 0).UTC(), nil
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(i, 0).UTC(), nil
default:
return time.Time{}, fmt.Errorf("unknown timestamp type")
}
}

359
scrobble/scrobble.go Normal file
View File

@@ -0,0 +1,359 @@
package scrobble
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"time"
"muzi/db"
"github.com/jackc/pgtype"
)
const DuplicateToleranceSeconds = 20
type Scrobble struct {
UserId int
Timestamp time.Time
SongName string
Artist string
Album string
MsPlayed int
Platform string
Source string
}
type NowPlaying struct {
UserId int
SongName string
Artist string
Album string
MsPlayed int
Platform string
UpdatedAt time.Time
}
var CurrentNowPlaying = make(map[int]map[string]NowPlaying)
func GenerateAPIKey() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GenerateAPISecret() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GenerateSessionKey() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func GetUserByAPIKey(apiKey string) (int, string, error) {
if apiKey == "" {
return 0, "", fmt.Errorf("empty API key")
}
var userId int
var username string
err := db.Pool.QueryRow(context.Background(),
"SELECT pk, username FROM users WHERE api_key = $1", apiKey).Scan(&userId, &username)
if err != nil {
return 0, "", err
}
return userId, username, nil
}
func GetUserByUsername(username string) (int, error) {
if username == "" {
return 0, fmt.Errorf("empty username")
}
var userId int
err := db.Pool.QueryRow(context.Background(),
"SELECT pk FROM users WHERE username = $1", username).Scan(&userId)
if err != nil {
return 0, err
}
return userId, nil
}
func GetUserBySessionKey(sessionKey string) (int, string, error) {
if sessionKey == "" {
return 0, "", fmt.Errorf("empty session key")
}
var userId int
var username string
err := db.Pool.QueryRow(context.Background(),
"SELECT pk, username FROM users WHERE api_secret = $1", sessionKey).Scan(&userId, &username)
if err != nil {
return 0, "", err
}
return userId, username, nil
}
func SaveScrobble(scrobble Scrobble) error {
exists, err := checkDuplicate(scrobble.UserId, scrobble.Artist, scrobble.SongName, scrobble.Timestamp)
if err != nil {
return err
}
if exists {
return fmt.Errorf("duplicate scrobble")
}
_, err = db.Pool.Exec(context.Background(),
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist,
scrobble.Album, scrobble.MsPlayed, scrobble.Platform)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
return err
}
return nil
}
func SaveScrobbles(scrobbles []Scrobble) (int, int, error) {
if len(scrobbles) == 0 {
return 0, 0, nil
}
accepted := 0
ignored := 0
batchSize := 100
for i := 0; i < len(scrobbles); i += batchSize {
end := i + batchSize
if end > len(scrobbles) {
end = len(scrobbles)
}
for _, scrobble := range scrobbles[i:end] {
err := SaveScrobble(scrobble)
if err != nil {
if err.Error() == "duplicate scrobble" {
ignored++
} else {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
}
continue
}
accepted++
}
}
return accepted, ignored, nil
}
func checkDuplicate(userId int, artist, songName string, timestamp time.Time) (bool, error) {
var exists bool
err := db.Pool.QueryRow(context.Background(),
`SELECT EXISTS(
SELECT 1 FROM history
WHERE user_id = $1
AND artist = $2
AND song_name = $3
AND ABS(EXTRACT(EPOCH FROM (timestamp - $4))) < $5
)`,
userId, artist, songName, timestamp, DuplicateToleranceSeconds).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func UpdateNowPlaying(np NowPlaying) {
if CurrentNowPlaying[np.UserId] == nil {
CurrentNowPlaying[np.UserId] = make(map[string]NowPlaying)
}
CurrentNowPlaying[np.UserId][np.Platform] = np
}
func GetNowPlaying(userId int) (NowPlaying, bool) {
platforms := CurrentNowPlaying[userId]
if platforms == nil {
return NowPlaying{}, false
}
np, ok := platforms["lastfm_api"]
if ok && np.SongName != "" {
return np, true
}
np, ok = platforms["spotify"]
if ok && np.SongName != "" {
return np, true
}
return NowPlaying{}, false
}
func ClearNowPlaying(userId int) {
delete(CurrentNowPlaying, userId)
}
func ClearNowPlayingPlatform(userId int, platform string) {
if CurrentNowPlaying[userId] != nil {
delete(CurrentNowPlaying[userId], platform)
}
}
func GetUserSpotifyCredentials(userId int) (clientId, clientSecret, accessToken, refreshToken string, expiresAt time.Time, err error) {
var clientIdPg, clientSecretPg, accessTokenPg, refreshTokenPg pgtype.Text
var expiresAtPg pgtype.Timestamptz
err = db.Pool.QueryRow(context.Background(),
`SELECT spotify_client_id, spotify_client_secret, spotify_access_token,
spotify_refresh_token, spotify_token_expires
FROM users WHERE pk = $1`,
userId).Scan(&clientIdPg, &clientSecretPg, &accessTokenPg, &refreshTokenPg, &expiresAtPg)
if err != nil {
return "", "", "", "", time.Time{}, err
}
if clientIdPg.Status == pgtype.Present {
clientId = clientIdPg.String
}
if clientSecretPg.Status == pgtype.Present {
clientSecret = clientSecretPg.String
}
if accessTokenPg.Status == pgtype.Present {
accessToken = accessTokenPg.String
}
if refreshTokenPg.Status == pgtype.Present {
refreshToken = refreshTokenPg.String
}
if expiresAtPg.Status == pgtype.Present {
expiresAt = expiresAtPg.Time
}
return clientId, clientSecret, accessToken, refreshToken, expiresAt, nil
}
func UpdateUserSpotifyTokens(userId int, accessToken, refreshToken string, expiresIn int) error {
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET
spotify_access_token = $1,
spotify_refresh_token = $2,
spotify_token_expires = $3
WHERE pk = $4`,
accessToken, refreshToken, expiresAt, userId)
return err
}
func UpdateUserSpotifyCheck(userId int) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET last_spotify_check = $1 WHERE pk = $2`,
time.Now(), userId)
return err
}
func GetUsersWithSpotify() ([]int, error) {
rows, err := db.Pool.Query(context.Background(),
`SELECT pk FROM users WHERE spotify_client_id IS NOT NULL AND spotify_client_secret IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
var userIds []int
for rows.Next() {
var userId int
if err := rows.Scan(&userId); err != nil {
return nil, err
}
userIds = append(userIds, userId)
}
return userIds, nil
}
type User struct {
Pk int
Username string
Bio string
Pfp string
AllowDuplicateEdits bool
ApiKey *string
ApiSecret *string
SpotifyClientId *string
SpotifyClientSecret *string
}
func GetUserById(userId int) (User, error) {
var user User
var apiKey, apiSecret, spotifyClientId, spotifyClientSecret pgtype.Text
err := db.Pool.QueryRow(context.Background(),
`SELECT pk, username, bio, pfp, allow_duplicate_edits, api_key, api_secret,
spotify_client_id, spotify_client_secret
FROM users WHERE pk = $1`,
userId).Scan(&user.Pk, &user.Username, &user.Bio, &user.Pfp,
&user.AllowDuplicateEdits, &apiKey, &apiSecret, &spotifyClientId, &spotifyClientSecret)
if err != nil {
return User{}, err
}
if apiKey.Status == pgtype.Present {
user.ApiKey = &apiKey.String
}
if apiSecret.Status == pgtype.Present {
user.ApiSecret = &apiSecret.String
}
if spotifyClientId.Status == pgtype.Present {
user.SpotifyClientId = &spotifyClientId.String
}
if spotifyClientSecret.Status == pgtype.Present {
user.SpotifyClientSecret = &spotifyClientSecret.String
}
return user, nil
}
func UpdateUserAPIKey(userId int, apiKey, apiSecret string) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET api_key = $1, api_secret = $2 WHERE pk = $3`,
apiKey, apiSecret, userId)
return err
}
func UpdateUserSpotifyCredentials(userId int, clientId, clientSecret string) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET spotify_client_id = $1, spotify_client_secret = $2 WHERE pk = $3`,
clientId, clientSecret, userId)
return err
}
func DeleteUserSpotifyCredentials(userId int) error {
_, err := db.Pool.Exec(context.Background(),
`UPDATE users SET
spotify_client_id = NULL,
spotify_client_secret = NULL,
spotify_access_token = NULL,
spotify_refresh_token = NULL,
spotify_token_expires = NULL
WHERE pk = $1`,
userId)
return err
}
func (u *User) IsSpotifyConnected() bool {
_, _, accessToken, _, expiresAt, err := GetUserSpotifyCredentials(u.Pk)
if err != nil || accessToken == "" {
return false
}
return time.Now().Before(expiresAt)
}

510
scrobble/spotify.go Normal file
View File

@@ -0,0 +1,510 @@
package scrobble
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"muzi/db"
)
const SpotifyTokenURL = "https://accounts.spotify.com/api/token"
const SpotifyAuthURL = "https://accounts.spotify.com/authorize"
const SpotifyAPIURL = "https://api.spotify.com/v1"
var (
spotifyClient = &http.Client{Timeout: 30 * time.Second}
spotifyMu sync.Mutex
)
type SpotifyHandler struct{}
func NewSpotifyHandler() *SpotifyHandler {
return &SpotifyHandler{}
}
type SpotifyTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
type SpotifyCurrentlyPlaying struct {
Timestamp int64 `json:"timestamp"`
ProgressMs int `json:"progress_ms"`
Item SpotifyTrack `json:"item"`
CurrentlyPlayingType string `json:"currently_playing_type"`
IsPlaying bool `json:"is_playing"`
}
type SpotifyTrack struct {
Id string `json:"id"`
Name string `json:"name"`
DurationMs int `json:"duration_ms"`
Artists []SpotifyArtist `json:"artists"`
Album SpotifyAlbum `json:"album"`
}
type SpotifyArtist struct {
Name string `json:"name"`
}
type SpotifyAlbum struct {
Name string `json:"name"`
}
type SpotifyRecentPlays struct {
Items []SpotifyPlayItem `json:"items"`
Cursors SpotifyCursors `json:"cursors"`
}
type SpotifyPlayItem struct {
Track SpotifyTrack `json:"track"`
PlayedAt string `json:"played_at"`
}
type SpotifyCursors struct {
After string `json:"after"`
Before string `json:"before"`
}
func (h *SpotifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/scrobble/spotify/authorize" {
h.handleAuthorize(w, r)
} else if path == "/scrobble/spotify/callback" {
h.handleCallback(w, r)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
}
func (h *SpotifyHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) {
userId := r.URL.Query().Get("user_id")
if userId == "" {
http.Error(w, "Missing user_id", http.StatusBadRequest)
return
}
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userIdToInt(userId))
fmt.Fprintf(os.Stderr, "handleAuthorize: userId=%s, clientId='%s', err=%v\n", userId, clientId, err)
if err != nil || clientId == "" {
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
return
}
baseURL := getBaseURL(r)
redirectURI := baseURL + "/scrobble/spotify/callback"
scope := "user-read-currently-playing user-read-recently-played"
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s",
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId)
http.Redirect(w, r, authURL, http.StatusSeeOther)
}
func (h *SpotifyHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
userId := userIdToInt(state)
if code == "" || state == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
clientId, clientSecret, _, _, _, err := GetUserSpotifyCredentials(userId)
if err != nil || clientId == "" {
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
return
}
baseURL := getBaseURL(r)
redirectURI := baseURL + "/scrobble/spotify/callback"
token, err := exchangeCodeForToken(clientId, clientSecret, code, redirectURI)
if err != nil {
fmt.Fprintf(os.Stderr, "Error exchanging code for token: %v\n", err)
http.Error(w, "Failed to authenticate", http.StatusInternalServerError)
return
}
err = UpdateUserSpotifyTokens(userId, token.AccessToken, token.RefreshToken, token.ExpiresIn)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving Spotify tokens: %v\n", err)
http.Error(w, "Failed to save credentials", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h1>Spotify connected successfully!</h1><p>You can close this window.</p><script>setTimeout(() => window.close(), 2000);</script></body></html>`)
}
func exchangeCodeForToken(clientId, clientSecret, code, redirectURI string) (*SpotifyTokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", redirectURI)
data.Set("client_id", clientId)
data.Set("client_secret", clientSecret)
req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := spotifyClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Spotify token exchange failed: %s", string(body))
}
var token SpotifyTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return nil, err
}
return &token, nil
}
func refreshSpotifyToken(clientId, clientSecret, refreshToken string) (*SpotifyTokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
data.Set("client_id", clientId)
data.Set("client_secret", clientSecret)
req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := spotifyClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Spotify token refresh failed: %s", string(body))
}
var token SpotifyTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return nil, err
}
return &token, nil
}
func StartSpotifyPoller() {
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
spotifyMu.Lock()
users, err := GetUsersWithSpotify()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting users with Spotify: %v\n", err)
spotifyMu.Unlock()
continue
}
for _, userId := range users {
err := pollSpotify(userId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error polling Spotify for user %d: %v\n", userId, err)
}
}
spotifyMu.Unlock()
}
}()
}
func pollSpotify(userId int) error {
clientId, clientSecret, accessToken, refreshToken, expiresAt, err := GetUserSpotifyCredentials(userId)
if err != nil {
return err
}
if accessToken == "" {
return fmt.Errorf("no access token")
}
if time.Now().After(expiresAt.Add(-60 * time.Second)) {
token, err := refreshSpotifyToken(clientId, clientSecret, refreshToken)
if err != nil {
return err
}
accessToken = token.AccessToken
if token.RefreshToken != "" {
refreshToken = token.RefreshToken
}
UpdateUserSpotifyTokens(userId, accessToken, refreshToken, token.ExpiresIn)
}
err = checkCurrentlyPlaying(userId, accessToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking currently playing: %v\n", err)
}
err = checkRecentPlays(userId, accessToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking recent plays: %v\n", err)
}
UpdateUserSpotifyCheck(userId)
return nil
}
func checkCurrentlyPlaying(userId int, accessToken string) error {
req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/currently-playing", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := spotifyClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == 204 {
ClearNowPlayingPlatform(userId, "spotify")
return nil
}
if resp.StatusCode != 200 {
return fmt.Errorf("currently playing returned %d", resp.StatusCode)
}
var playing SpotifyCurrentlyPlaying
if err := json.NewDecoder(resp.Body).Decode(&playing); err != nil {
return err
}
if !playing.IsPlaying || playing.Item.Name == "" {
ClearNowPlayingPlatform(userId, "spotify")
return nil
}
artistName := ""
if len(playing.Item.Artists) > 0 {
artistName = playing.Item.Artists[0].Name
}
checkAndScrobbleHalfway(userId, &playing.Item, playing.ProgressMs)
UpdateNowPlaying(NowPlaying{
UserId: userId,
SongName: playing.Item.Name,
Artist: artistName,
Album: playing.Item.Album.Name,
MsPlayed: playing.Item.DurationMs,
Platform: "spotify",
UpdatedAt: time.Now(),
})
return nil
}
func checkRecentPlays(userId int, accessToken string) error {
req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/recently-played?limit=50", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := spotifyClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("recently played returned %d", resp.StatusCode)
}
var recent SpotifyRecentPlays
if err := json.NewDecoder(resp.Body).Decode(&recent); err != nil {
return err
}
if len(recent.Items) == 0 {
return nil
}
scrobbles := make([]Scrobble, 0, len(recent.Items))
for _, item := range recent.Items {
artistName := ""
if len(item.Track.Artists) > 0 {
artistName = item.Track.Artists[0].Name
}
ts, err := time.Parse(time.RFC3339, item.PlayedAt)
if err != nil {
fmt.Fprintf(os.Stderr, " -> failed to parse timestamp %s: %v\n", item.PlayedAt, err)
continue
}
scrobbles = append(scrobbles, Scrobble{
UserId: userId,
Timestamp: ts,
SongName: item.Track.Name,
Artist: artistName,
Album: item.Track.Album.Name,
MsPlayed: item.Track.DurationMs,
Platform: "spotify",
})
}
SaveScrobbles(scrobbles)
return nil
}
func userIdToInt(s string) int {
var id int
fmt.Sscanf(s, "%d", &id)
return id
}
func getBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
host := r.Host
if host == "localhost:1234" || host == "localhost" {
host = "127.0.0.1:1234"
}
return scheme + "://" + host
}
func GetSpotifyAuthURL(userId int, baseURL string) (string, error) {
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userId)
if err != nil || clientId == "" {
return "", fmt.Errorf("Spotify credentials not configured")
}
redirectURI := baseURL + "/scrobble/spotify/callback"
scope := "user-read-currently-playing user-read-recently-played"
return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%d",
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId), nil
}
type LastTrack struct {
UserId int
TrackId string
SongName string
Artist string
AlbumName string
DurationMs int
ProgressMs int
UpdatedAt time.Time
}
func GetLastTrack(userId int) (*LastTrack, error) {
var track LastTrack
err := db.Pool.QueryRow(context.Background(),
`SELECT user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at
FROM spotify_last_track WHERE user_id = $1`,
userId).Scan(&track.UserId, &track.TrackId, &track.SongName, &track.Artist,
&track.AlbumName, &track.DurationMs, &track.ProgressMs, &track.UpdatedAt)
if err != nil {
return nil, err
}
return &track, nil
}
func SetLastTrack(userId int, trackId, songName, artist, albumName string, durationMs, progressMs int) error {
_, err := db.Pool.Exec(context.Background(),
`INSERT INTO spotify_last_track (user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (user_id) DO UPDATE SET
track_id = $2, song_name = $3, artist = $4, album_name = $5, duration_ms = $6, progress_ms = $7, updated_at = NOW()`,
userId, trackId, songName, artist, albumName, durationMs, progressMs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving last track: %v\n", err)
return err
}
return nil
}
func checkAndScrobbleHalfway(userId int, currentTrack *SpotifyTrack, progressMs int) {
if currentTrack.Id == "" || currentTrack.DurationMs == 0 {
return
}
lastTrack, err := GetLastTrack(userId)
if err != nil {
if err.Error() == "no rows in result set" {
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
}
return
}
if lastTrack.TrackId != currentTrack.Id {
if lastTrack.DurationMs > 0 {
percentagePlayed := float64(lastTrack.ProgressMs) / float64(lastTrack.DurationMs)
if percentagePlayed >= 0.5 || lastTrack.ProgressMs >= 240000 {
msPlayed := lastTrack.ProgressMs
if msPlayed > lastTrack.DurationMs {
msPlayed = lastTrack.DurationMs
}
scrobble := Scrobble{
UserId: userId,
Timestamp: lastTrack.UpdatedAt,
SongName: lastTrack.SongName,
Artist: lastTrack.Artist,
Album: lastTrack.AlbumName,
MsPlayed: msPlayed,
Platform: "spotify",
}
SaveScrobble(scrobble)
}
}
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
} else {
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
}
}
func getArtistName(artists []SpotifyArtist) string {
if len(artists) > 0 {
return artists[0].Name
}
return ""
}

View File

@@ -0,0 +1,13 @@
<!--
version: "2.0"
unicode: "f69e"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M14.647 4.081a.724 .724 0 0 0 1.08 .448c2.439 -1.485 5.23 1.305 3.745 3.744a.724 .724 0 0 0 .447 1.08c2.775 .673 2.775 4.62 0 5.294a.724 .724 0 0 0 -.448 1.08c1.485 2.439 -1.305 5.23 -3.744 3.745a.724 .724 0 0 0 -1.08 .447c-.673 2.775 -4.62 2.775 -5.294 0a.724 .724 0 0 0 -1.08 -.448c-2.439 1.485 -5.23 -1.305 -3.745 -3.744a.724 .724 0 0 0 -.447 -1.08c-2.775 -.673 -2.775 -4.62 0 -5.294a.724 .724 0 0 0 .448 -1.08c-1.485 -2.439 1.305 -5.23 3.744 -3.745a.722 .722 0 0 0 1.08 -.447c.673 -2.775 4.62 -2.775 5.294 0zm-2.647 4.919a3 3 0 1 0 0 6a3 3 0 0 0 0 -6" />
</svg>

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -0,0 +1,14 @@
<!--
version: "2.39"
unicode: "fd19"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2a5 5 0 1 1 -5 5l.005 -.217a5 5 0 0 1 4.995 -4.783z" />
<path d="M14 14a5 5 0 0 1 5 5v1a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-1a5 5 0 0 1 5 -5h4z" />
</svg>

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

83
static/import.js Normal file
View File

@@ -0,0 +1,83 @@
function handleImport(formId, progressPrefix, endpoint, progressUrl, formatLabel) {
const form = document.getElementById(formId);
const progressContainer = document.getElementById(progressPrefix + '-progress');
const progressFill = document.getElementById(progressPrefix + '-progress-fill');
const progressText = document.getElementById(progressPrefix + '-progress-text');
const progressStatus = document.getElementById(progressPrefix + '-progress-status');
const progressTracks = document.getElementById(progressPrefix + '-progress-tracks');
const progressError = document.getElementById(progressPrefix + '-progress-error');
const progressSuccess = document.getElementById(progressPrefix + '-progress-success');
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Reset and show progress
progressFill.style.width = '0%';
progressFill.classList.add('animating');
progressText.textContent = '0%';
progressStatus.textContent = 'Starting import...';
progressTracks.textContent = '';
progressError.textContent = '';
progressSuccess.textContent = '';
progressContainer.style.display = 'block';
try {
const response = await fetch(endpoint, {
method: 'POST',
body: progressPrefix === 'lastfm'
? new URLSearchParams(new FormData(form))
: new FormData(form)
});
if (!response.ok) throw new Error('Failed to start import: ' + response.statusText);
const { job_id } = await response.json();
const eventSource = new EventSource(progressUrl + job_id);
eventSource.onmessage = function(event) {
const update = JSON.parse(event.data);
if (update.status === 'connected') return;
if (update.total_pages > 0) {
const completed = update.completed_pages || update.current_page || 0;
const percent = Math.round((completed / update.total_pages) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
progressStatus.textContent = 'Processing ' + formatLabel + ' ' + completed + ' of ' + update.total_pages;
}
if (update.tracks_imported !== undefined) {
progressTracks.textContent = update.tracks_imported.toLocaleString() + ' tracks imported';
}
if (update.status === 'completed') {
progressFill.classList.remove('animating');
progressStatus.textContent = 'Import completed!';
progressSuccess.textContent = 'Successfully imported ' + update.tracks_imported.toLocaleString() + ' tracks from ' + (progressPrefix === 'spotify' ? 'Spotify' : 'Last.fm');
eventSource.close();
form.reset();
} else if (update.status === 'error') {
progressFill.classList.remove('animating');
progressStatus.textContent = 'Import failed';
progressError.textContent = 'Error: ' + (update.error || 'Unknown error');
eventSource.close();
}
};
eventSource.onerror = function() {
progressFill.classList.remove('animating');
progressStatus.textContent = 'Connection error';
progressError.textContent = 'Lost connection to server. The import may still be running in the background.';
eventSource.close();
};
} catch (err) {
progressFill.classList.remove('animating');
progressStatus.textContent = 'Import failed';
progressError.textContent = 'Error: ' + err.message;
}
});
}
handleImport('spotify-form', 'spotify', '/import/spotify', '/import/spotify/progress?job=', 'batch');
handleImport('lastfm-form', 'lastfm', '/import/lastfm', '/import/lastfm/progress?job=', 'page');

32
static/menu.js Normal file
View File

@@ -0,0 +1,32 @@
document.addEventListener('DOMContentLoaded', function() {
const menuButton = document.getElementById('menuButton');
const sideMenu = document.getElementById('sideMenu');
const menuOverlay = document.getElementById('menuOverlay');
function toggleMenu() {
menuButton.classList.toggle('active');
sideMenu.classList.toggle('active');
menuOverlay.classList.toggle('active');
}
function closeMenu() {
menuButton.classList.remove('active');
sideMenu.classList.remove('active');
menuOverlay.classList.remove('active');
}
if (menuButton) {
menuButton.addEventListener('click', toggleMenu);
}
if (menuOverlay) {
menuOverlay.addEventListener('click', closeMenu);
}
// Close menu on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeMenu();
}
});
});

View File

@@ -5,9 +5,130 @@ body {
color: #AFA;
align-content: center;
justify-content: center;
align-items: center;
text-align: center;
max-width: 100vw;
max-width: 70vw;
margin: 0 auto;
width: 70vw;
font-family: sans-serif;
padding-top: 80px;
}
/* Hamburger Menu Button - left side */
.menu-button {
position: fixed;
top: 20px;
left: 20px;
width: 40px;
height: 40px;
cursor: pointer;
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
}
.menu-button span {
display: block;
width: 28px;
height: 3px;
background-color: #AFA;
border-radius: 2px;
transition: all 0.3s ease;
}
.menu-button.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 6px);
}
.menu-button.active span:nth-child(2) {
opacity: 0;
}
.menu-button.active span:nth-child(3) {
transform: rotate(-45deg) translate(5px, -6px);
}
/* Slide-out Menu */
.side-menu {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
background-color: #1a1a1a;
z-index: 999;
transition: left 0.3s ease;
display: flex;
flex-direction: column;
padding-top: 60px;
}
.side-menu.active {
left: 0;
}
.menu-header {
padding: 20px;
border-bottom: 1px solid #333;
}
.menu-header h3 {
margin: 0;
color: #AFA;
font-size: 24px;
}
.menu-nav {
display: flex;
flex-direction: column;
padding: 10px 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px 20px;
color: #EEE;
text-decoration: none;
transition: background-color 0.2s;
}
.menu-item:hover {
background-color: #333;
}
.menu-item .menu-icon {
color: #FFF;
}
.menu-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
filter: invert(1) brightness(1.5);
}
/* Menu Overlay */
.menu-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.menu-overlay.active {
opacity: 1;
visibility: visible;
}
.page_buttons {
@@ -23,12 +144,73 @@ body {
}
}
.user-stats-top {
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 {
display: flex;
flex-direction: column;
justify-content: center;
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;
}
h2 {
color: #777777;
font-size: 15px;
margin: 0;
}
img {
object-fit: cover;
width: 250px;
height: 250px;
border-radius: 100%;
}
}
.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;
@@ -46,3 +228,229 @@ 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;
}
/* Settings Tab Navigation */
.settings-tabs {
display: flex;
flex-direction: row;
border-bottom: 1px solid #333;
margin-bottom: 20px;
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab-button:hover {
color: #AFA;
}
.tab-button.active {
color: #AFA;
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
background-color: #AFA;
}
.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;
}
/* Tab Panels */
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* API Key Display */
.api-key-display {
margin: 15px 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.api-key-display label {
color: #888;
font-size: 14px;
}
.api-key-display code {
background: #111;
padding: 8px 12px;
border-radius: 4px;
color: #AFA;
font-family: monospace;
word-break: break-all;
}
.success {
color: #8F8;
margin-top: 10px;
}
.info {
color: #888;
font-size: 14px;
margin-top: 10px;
}
a.button {
display: inline-block;
padding: 10px 20px;
background: #1DB954;
color: #fff;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
}
a.button:hover {
background: #1ed760;
}

51
templates/base.gohtml Normal file
View File

@@ -0,0 +1,51 @@
{{define "base"}}
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/files/style.css" type="text/css">
<title>{{.Title}}</title>
{{block "head" .}}{{end}}
</head>
<body>
<!-- Hamburger Menu Button -->
<div class="menu-button" id="menuButton">
<span></span>
<span></span>
<span></span>
</div>
<!-- Slide-out Menu -->
<div class="side-menu" id="sideMenu">
<div class="menu-header">
<h3>muzi</h3>
</div>
<nav class="menu-nav">
{{if .LoggedInUsername}}
<a href="/profile/{{.LoggedInUsername}}" class="menu-item">
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
<span>My Profile</span>
</a>
{{else}}
<a href="/login" class="menu-item">
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
<span>Login</span>
</a>
{{end}}
<a href="/settings" class="menu-item">
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
<span>Settings</span>
</a>
</nav>
</div>
<!-- Overlay for closing menu -->
<div class="menu-overlay" id="menuOverlay"></div>
<!-- Main Content -->
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
<script src="/files/menu.js"></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/files/style.css" type="text/css">
<title>
muzi | Create Account
</title>
</head>
<body>
<div class=login-form>
<form action="/createaccountsubmit" method="POST">
<label for="uname">Username:</label>
<input type="text" id="uname" name="uname"> <br> <br>
<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}}
{{if eq .Error "userlength"}}
<div class="login-error">
Username length must be greater than 0.
</div>
{{end}}
{{if eq .Error "usertaken"}}
<div class="login-error">
Username must be unique. Please try again.
</div>
{{end}}
</form>
</div>
</body>
</html>

View File

@@ -1,35 +0,0 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="static/style.css" type="text/css">
<title>
muzi | history
</title>
</head>
<body>
Scrobbles: {{.Content}}<br><br>
<div class=history>
<table>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Timestamp</th>
</tr>
{{$artists := .Artists}}
{{$times := .Times}}
{{range $index, $title := .Titles}}
<tr>
<td>{{index $artists $index}}</td>
<td>{{$title}}</td>
<td>{{index $times $index}}</td>
</tr>
{{end}}
</table>
</div>
{{$page := .Page}}
<div class=page_buttons>
<a href="/history?page={{Sub $page 1}}">Prev Page</a>
<a href="/history?page={{Add $page 1}}">Next Page</a>
</div>
</body>
</html>

63
templates/import.gohtml Normal file
View File

@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/files/style.css" type="text/css">
<title>muzi | Import Data</title>
<style>
/* Debug: Force bar visibility */
#progress-fill {
background: linear-gradient(90deg, #0f0, #0a0) !important;
min-width: 2px !important;
}
</style>
</head>
<body>
<div class="import-container">
<h1>Import Your Listening Data</h1>
<p>Welcome, {{.Username}}!</p>
<div class="import-section">
<h2>Spotify</h2>
<p>Import your Spotify listening history from your data export.</p>
<form id="spotify-form" method="POST" action="/import/spotify" enctype="multipart/form-data">
<input type="file" name="json_files" accept=".json,application/json" multiple required>
<button type="submit">Upload Spotify Data</button>
</form>
<div id="spotify-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
<div class="progress-text" id="spotify-progress-text">0%</div>
</div>
<div class="progress-tracks" id="spotify-progress-tracks"></div>
<div class="progress-error" id="spotify-progress-error"></div>
<div class="progress-success" id="spotify-progress-success"></div>
</div>
</div>
<div class="import-section">
<h2>Last.fm</h2>
<p>Import your Last.fm scrobbles.</p>
<form id="lastfm-form" method="POST" action="/import/lastfm">
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
<button type="submit">Import from Last.fm</button>
</form>
<div id="lastfm-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
<div class="progress-text" id="lastfm-progress-text">0%</div>
</div>
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
<div class="progress-error" id="lastfm-progress-error"></div>
<div class="progress-success" id="lastfm-progress-success"></div>
</div>
</div>
</div>
<script src="/files/import.js"></script>
</body>
</html>

30
templates/login.gohtml Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/files/style.css" type="text/css">
<title>
muzi | Login
</title>
</head>
<body>
<div class=login-form>
<form action="/loginsubmit" method="POST">
<label for="uname">Username:</label>
<input type="text" id="uname" name="uname"> <br> <br>
<label for="pass">Password:</label>
<input type="password" id="pass" name="pass"> <br> <br>
<input type="submit" value="Login">
{{if eq .Error "invalid-creds"}}
<div class="login-error">
Invalid credentials. Please try again.
</div>
{{end}}
{{if eq .Error "session"}}
<div class="login-error">
Unable to create session. Please try again.
</div>
{{end}}
</form>
</div>
</body>
</html>

47
templates/profile.gohtml Normal file
View File

@@ -0,0 +1,47 @@
{{define "profile"}}
<div class="profile-top">
<img src="{{.Pfp}}" alt="{{.Username}}'s avatar">
<div class="username-bio">
<h1>{{.Username}}</h1>
<h2>{{.Bio}}</h2>
</div>
<div class="profile-top-blank">
</div>
<div class="user-stats-top">
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
</div>
<div class="history">
<h3>Listening History</h3>
<table>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Timestamp</th>
</tr>
{{if .NowPlayingTitle}}
<tr>
<td>{{.NowPlayingArtist}}</td>
<td>{{.NowPlayingTitle}}</td>
<td>Now Playing</td>
</tr>
{{end}}
{{$artists := .Artists}}
{{$times := .Times}}
{{range $index, $title := .Titles}}
<tr>
<td>{{index $artists $index}}</td>
<td>{{$title}}</td>
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
</tr>
{{end}}
</table>
</div>
<div class="page_buttons">
{{if gt .Page 1 }}
<a href="/profile/{{.Username}}?page={{sub .Page 1}}">Prev Page</a>
{{end}}
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
</div>
{{end}}

126
templates/settings.gohtml Normal file
View File

@@ -0,0 +1,126 @@
{{define "settings"}}
<div class="settings-container">
<h1>Settings</h1>
<!-- Tab Navigation -->
<div class="settings-tabs">
<button class="tab-button active" data-tab="import">Import Data</button>
<button class="tab-button" data-tab="scrobble">Scrobble API</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Import Data Tab -->
<div class="tab-panel active" id="import">
<div class="import-section">
<h2>Spotify</h2>
<p>Import your Spotify listening history from your data export.</p>
<form id="spotify-form" method="POST" action="/settings/import/spotify" enctype="multipart/form-data">
<input type="file" name="json_files" accept=".json,application/json" multiple required>
<button type="submit">Upload Spotify Data</button>
</form>
<div id="spotify-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
<div class="progress-text" id="spotify-progress-text">0%</div>
</div>
<div class="progress-tracks" id="spotify-progress-tracks"></div>
<div class="progress-error" id="spotify-progress-error"></div>
<div class="progress-success" id="spotify-progress-success"></div>
</div>
</div>
<div class="import-section">
<h2>Last.fm</h2>
<p>Import your Last.fm scrobbles.</p>
<form id="lastfm-form" method="POST" action="/settings/import/lastfm">
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
<button type="submit">Import from Last.fm</button>
</form>
<div id="lastfm-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
<div class="progress-text" id="lastfm-progress-text">0%</div>
</div>
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
<div class="progress-error" id="lastfm-progress-error"></div>
<div class="progress-success" id="lastfm-progress-success"></div>
</div>
</div>
</div>
<!-- Scrobble API Tab -->
<div class="tab-panel" id="scrobble">
<div class="import-section">
<h2>API Keys</h2>
<p>Generate an API key to receive scrobbles from external apps.</p>
{{if .APIKey}}
<div class="api-key-display">
<label>API Key:</label>
<code>{{.APIKey}}</code>
</div>
<div class="api-key-display">
<label>API Secret:</label>
<code>{{.APISecret}}</code>
</div>
{{end}}
<form method="POST" action="/settings/generate-apikey">
<button type="submit">{{if .APIKey}}Regenerate{{else}}Generate{{end}} API Key</button>
</form>
</div>
<div class="import-section">
<h2>Endpoint URLs</h2>
<p>Use these URLs in your scrobbling apps:</p>
<div class="api-key-display">
<label>Last.fm Compatible:</label>
<code>/2.0/</code>
</div>
<div class="api-key-display">
<label>Listenbrainz JSON:</label>
<code>/1/submit-listens</code>
</div>
</div>
<div class="import-section">
<h2>Spotify Integration</h2>
<p>Connect your Spotify account to automatically import your listening history.</p>
<p>Create a Spotify app at <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a> and enter your credentials below.</p>
<form method="POST" action="/settings/update-spotify">
<input type="text" name="spotify_client_id" placeholder="Spotify Client ID" value="{{.SpotifyClientId}}">
<input type="password" name="spotify_client_secret" placeholder="Spotify Client Secret">
<button type="submit">Save Spotify Credentials</button>
</form>
{{if and .SpotifyClientId (not .SpotifyConnected)}}
<p><a href="/settings/spotify-connect" class="button">Connect Spotify</a></p>
<p class="info">Click to authorize Muzi to access your Spotify account.</p>
{{end}}
{{if .SpotifyConnected}}
<p class="success">Spotify is connected and importing!</p>
{{end}}
</div>
</div>
</div>
</div>
<script src="/files/import.js"></script>
<script>
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => {
document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
button.classList.add('active');
document.getElementById(button.dataset.tab).classList.add('active');
});
});
</script>
{{end}}

183
web/auth.go Normal file
View File

@@ -0,0 +1,183 @@
package web
// Functions used to authenticate web UI users.
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"muzi/db"
"golang.org/x/crypto/bcrypt"
)
// Generates a hex string 32 characters in length.
func generateID() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Returns a salted hash of a password if valid (8-64 chars).
func hashPassword(pass []byte) (string, error) {
if len([]rune(string(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), nil
}
// Compares a plaintext password and a hashed password. Returns T/F depending
// on comparison result.
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), enteredPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Error while comparing passwords: %v\n", err)
return false
}
return true
}
// Handles the submission of new account credentials. Stores credentials in
// the users table. Sets a browser cookie for successful new users.
func createAccount(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("uname")
if len([]rune(string(username))) == 0 {
http.Redirect(w, r, "/createaccount?error=userlength", http.StatusSeeOther)
return
}
var usertaken bool
err = db.Pool.QueryRow(r.Context(),
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).
Scan(&usertaken)
if usertaken == true {
http.Redirect(w, r, "/createaccount?error=usertaken", http.StatusSeeOther)
return
}
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=passlength", http.StatusSeeOther)
return
}
_, err = db.Pool.Exec(
r.Context(),
`INSERT INTO users (username, password) VALUES ($1, $2);`,
username,
hashedPassword,
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
} else {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30,
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
}
}
}
// Renders the create account page
func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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)
}
}
}
// Handles submission of login credentials by checking if the username
// is in the database and the stored password for that username matches the
// given password. Sets browser cookie on successful login.
func loginSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("uname")
if username == "" {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
return
}
password := r.FormValue("pass")
var storedPassword string
err = db.Pool.QueryRow(r.Context(), "SELECT password FROM users WHERE username = $1;", username).
Scan(&storedPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
}
if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username)
if sessionID == "" {
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 30,
})
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
}
}
}
// Renders the login page
func loginPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Error string
}
d := data{Error: r.URL.Query().Get("error")}
err := templates.ExecuteTemplate(w, "login.gohtml", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

311
web/import.go Normal file
View File

@@ -0,0 +1,311 @@
package web
// Functions that the web UI uses for importing
import (
"encoding/json"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"sync"
"muzi/migrate"
)
// Global vars to hold active import jobs and a mutex to lock access to
// importJobs
var (
importJobs = make(map[string]chan migrate.ProgressUpdate)
jobsMu sync.RWMutex
)
// Renders the import page
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)
}
}
}
// Validates and parses tracks from uploaded Spotify JSON files
func parseUploads(uploads []*multipart.FileHeader, w http.ResponseWriter) []migrate.SpotifyTrack {
if len(uploads) < 1 {
http.Error(w, "No files uploaded", http.StatusBadRequest)
return nil
}
if len(uploads) > 30 {
http.Error(w, "Too many files uploaded (30 max)", http.StatusBadRequest)
return nil
}
var allTracks []migrate.SpotifyTrack
for _, u := range uploads {
if u.Size > maxHeaderSize {
fmt.Fprintf(os.Stderr, "File too large: %s\n", u.Filename)
continue
}
if strings.Contains(u.Filename, "..") ||
strings.Contains(u.Filename, "/") ||
strings.Contains(u.Filename, "\x00") {
fmt.Fprintf(os.Stderr, "Invalid filename: %s\n", u.Filename)
continue
}
file, err := u.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", u.Filename, err)
continue
}
reader := io.LimitReader(file, maxHeaderSize)
data, err := io.ReadAll(reader)
file.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", u.Filename, err)
continue
}
if !json.Valid(data) {
http.Error(w, fmt.Sprintf("Invalid JSON in %s", u.Filename),
http.StatusBadRequest)
return nil
}
var tracks []migrate.SpotifyTrack
if err := json.Unmarshal(data, &tracks); err != nil {
fmt.Fprintf(os.Stderr,
"Error parsing %s: %v\n", u.Filename, err)
continue
}
allTracks = append(allTracks, tracks...)
}
return allTracks
}
// Imports the uploaded JSON files into the database
func importSpotifyHandler(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
}
err = r.ParseMultipartForm(32 * 1024 * 1024) // 32 MiB
if err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
allTracks := parseUploads(r.MultipartForm.File["json_files"], w)
if allTracks == nil {
return
}
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()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportSpotify(allTracks, userId, progressChan)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
// Fetch a LastFM account's scrobbles and insert them into the database
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
}
err = r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
lastfmUsername := template.HTMLEscapeString(r.FormValue("lastfm_username"))
lastfmAPIKey := template.HTMLEscapeString(r.FormValue("lastfm_api_key"))
if lastfmUsername == "" || lastfmAPIKey == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
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()
importJobs[jobID] = progressChan
jobsMu.Unlock()
go func() {
migrate.ImportLastFM(lastfmUsername, lastfmAPIKey, userId, progressChan,
username)
jobsMu.Lock()
delete(importJobs, jobID)
jobsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "started",
})
}
// Controls the progress bar for a LastFM import
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
}
}
}
// Controls the progress bar for a Spotify import
func importSpotifyProgressHandler(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
}
}
}

125
web/profile.go Normal file
View File

@@ -0,0 +1,125 @@
package web
// Functions used for user profiles in the web UI
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
"muzi/db"
"muzi/scrobble"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgtype"
)
type ProfileData struct {
Username string
Bio string
Pfp string
AllowDuplicateEdits bool
ScrobbleCount int
ArtistCount int
Artists []string
Titles []string
Times []time.Time
Page int
Title string
LoggedInUsername string
TemplateName string
NowPlayingArtist string
NowPlayingTitle string
}
// Render a page of the profile in the URL
func profilePageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
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
}
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
profileData.Username = username
profileData.Page = pageInt
profileData.Title = username + "'s Profile"
profileData.LoggedInUsername = getLoggedInUsername(r)
profileData.TemplateName = "profile"
err = db.Pool.QueryRow(
r.Context(),
`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,
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.ArtistCount)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if pageInt == 1 {
if np, ok := scrobble.GetNowPlaying(userId); ok {
profileData.NowPlayingArtist = np.Artist
profileData.NowPlayingTitle = np.SongName
}
}
rows, err := db.Pool.Query(
r.Context(),
"SELECT artist, song_name, 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 history failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
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)
}
err = templates.ExecuteTemplate(w, "base", profileData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

78
web/session.go Normal file
View File

@@ -0,0 +1,78 @@
package web
// Functions that handle browser login sessions
import (
"context"
"fmt"
"net/http"
"os"
"muzi/db"
)
type Session struct {
Username string
}
func createSession(username string) string {
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,
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}
}
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
}
func getUserIdByUsername(ctx context.Context, username string) (int, error) {
var userId int
err := db.Pool.QueryRow(ctx, "SELECT pk FROM users WHERE username = $1;", username).
Scan(&userId)
return userId, err
}

167
web/settings.go Normal file
View File

@@ -0,0 +1,167 @@
package web
import (
"fmt"
"net/http"
"os"
"muzi/scrobble"
)
type settingsData struct {
Title string
LoggedInUsername string
TemplateName string
APIKey string
APISecret string
SpotifyClientId string
SpotifyConnected bool
}
func settingsPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusInternalServerError)
return
}
user, err := scrobble.GetUserById(userId)
if err != nil {
http.Error(w, "Error loading user", http.StatusInternalServerError)
return
}
d := settingsData{
Title: "muzi | Settings",
LoggedInUsername: username,
TemplateName: "settings",
APIKey: "",
APISecret: "",
SpotifyClientId: "",
SpotifyConnected: user.IsSpotifyConnected(),
}
if user.ApiKey != nil {
d.APIKey = *user.ApiKey
}
if user.ApiSecret != nil {
d.APISecret = *user.ApiSecret
}
if user.SpotifyClientId != nil {
d.SpotifyClientId = *user.SpotifyClientId
}
err = templates.ExecuteTemplate(w, "base", d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func generateAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusInternalServerError)
return
}
apiKey, err := scrobble.GenerateAPIKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating API key: %v\n", err)
http.Error(w, "Error generating API key", http.StatusInternalServerError)
return
}
apiSecret, err := scrobble.GenerateAPISecret()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating API secret: %v\n", err)
http.Error(w, "Error generating API secret", http.StatusInternalServerError)
return
}
err = scrobble.UpdateUserAPIKey(userId, apiKey, apiSecret)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving API key: %v\n", err)
http.Error(w, "Error saving API key", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func updateSpotifyCredentialsHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusInternalServerError)
return
}
clientId := r.FormValue("spotify_client_id")
clientSecret := r.FormValue("spotify_client_secret")
if clientId == "" || clientSecret == "" {
err = scrobble.DeleteUserSpotifyCredentials(userId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error removing Spotify credentials: %v\n", err)
}
} else {
err = scrobble.UpdateUserSpotifyCredentials(userId, clientId, clientSecret)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving Spotify credentials: %v\n", err)
http.Error(w, "Error saving Spotify credentials", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func spotifyConnectHandler(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusInternalServerError)
return
}
user, err := scrobble.GetUserById(userId)
if err != nil {
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: GetUserById error: %v\n", err)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: userId=%d, SpotifyClientId=%v\n", userId, user.SpotifyClientId)
if user.SpotifyClientId == nil || *user.SpotifyClientId == "" {
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: SpotifyClientId is nil or empty, redirecting to settings\n")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/scrobble/spotify/authorize?user_id=%d", userId), http.StatusSeeOther)
}

58
web/utils.go Normal file
View File

@@ -0,0 +1,58 @@
package web
// Functions used in the HTML templates
import (
"fmt"
"time"
)
// Subtracts two integers
func sub(a int, b int) int {
return a - b
}
// Adds two integers
func add(a int, b int) int {
return a + b
}
// Put a comma in the thousands place, ten-thousands place etc.
func formatInt(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
} else {
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
}
}
// Formats timestamps compared to local time
func formatTimestamp(timestamp time.Time) string {
now := time.Now()
duration := now.Sub(timestamp)
if duration < 24*time.Hour {
seconds := int(duration.Seconds())
if seconds < 60 {
return fmt.Sprintf("%d seconds ago", seconds)
}
minutes := seconds / 60
if minutes < 60 {
return fmt.Sprintf("%d minutes ago", minutes)
}
hours := minutes / 60
return fmt.Sprintf("%d hours ago", hours)
}
year := now.Year()
if timestamp.Year() == year {
return timestamp.Format("2 Jan 3:04pm")
}
return timestamp.Format("2 Jan 2006 3:04pm")
}
// Full timestamp format for browser hover
func formatTimestampFull(timestamp time.Time) string {
return timestamp.Format("Monday 2 Jan 2006, 3:04pm")
}

View File

@@ -1,161 +1,108 @@
package web
// Main web UI controller
import (
"context"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
"muzi/db"
"muzi/scrobble"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v5"
)
type PageData struct {
Content int
Artists []string
Titles []string
Times []string
Page int
const serverAddr = "127.0.0.1:1234"
// 50 MiB
const maxHeaderSize int64 = 50 * 1024 * 1024
func serverAddrStr() string {
return serverAddr
}
func Sub(a int, b int) int {
return a - b
}
func Add(a int, b int) int {
return a + b
}
func getTimes(conn *pgx.Conn, 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;", lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT COUNT 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, 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;", lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT COUNT 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, 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;", lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT COUNT 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) int {
var count int
err := conn.QueryRow(context.Background(), "SELECT COUNT (*) FROM history;").Scan(&count)
if err != nil {
fmt.Fprintf(os.Stderr, "SELECT COUNT failed: %v\n", err)
return 0
}
return count
}
func tmp(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,
}
// Holds all the parsed HTML templates
var templates *template.Template
// Declares all functions for the HTML templates and parses them
func init() {
funcMap := template.FuncMap{
"Sub": Sub,
"Add": Add,
"sub": sub,
"add": add,
"formatInt": formatInt,
"formatTimestamp": formatTimestamp,
"formatTimestampFull": formatTimestampFull,
}
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}
t, err := template.New("history.gohtml").Funcs(funcMap).ParseFiles("./templates/history.gohtml")
// Returns T/F if a user is found in the users table
func hasUsers(ctx context.Context) bool {
var count int
err := db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM users;").Scan(&count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "Error checking for users: %v\n", err)
return false
}
return count > 0
}
// Controls what is displayed at the root URL.
// If logged in: Logged in user's profile page.
// If logged out: Login page.
// If no users in DB yet: Create account page.
func rootHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !hasUsers(r.Context()) {
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
}
}
// Serves all pages at the specified address.
func Start() {
addr := ":1234"
addr := serverAddr
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/static/style.css", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/style.css")
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
r.Get("/", rootHandler())
r.Get("/login", loginPageHandler())
r.Get("/createaccount", createAccountPageHandler())
r.Get("/profile/{username}", profilePageHandler())
r.Get("/import", importPageHandler())
r.Post("/loginsubmit", loginSubmit)
r.Post("/createaccountsubmit", createAccount)
r.Post("/import/lastfm", importLastFMHandler)
r.Post("/import/spotify", importSpotifyHandler)
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
r.Handle("/2.0", scrobble.NewLastFMHandler())
r.Handle("/2.0/", scrobble.NewLastFMHandler())
r.Post("/1/submit-listens", http.HandlerFunc(scrobble.NewListenbrainzHandler().ServeHTTP))
r.Route("/scrobble/spotify", func(r chi.Router) {
r.Get("/authorize", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
r.Get("/callback", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
})
r.Get("/history", tmp)
r.Get("/settings/spotify-connect", spotifyConnectHandler)
r.Get("/settings", settingsPageHandler())
r.Post("/settings/generate-apikey", generateAPIKeyHandler)
r.Post("/settings/update-spotify", updateSpotifyCredentialsHandler)
fmt.Printf("WebUI starting on %s\n", addr)
http.ListenAndServe(addr, r)
prot := http.NewCrossOriginProtection()
http.ListenAndServe(addr, prot.Handler(r))
}